Repository navigation
fix(objectql,driver-mongodb,formula): having compiles the whole-day bound it is handed; $contains asks membership on a JSON-stored field (#20822 group 3b) - #21196
Conversation
Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…ritten; the seam lowers it The having walker and the per-aggregation filter walker no longer apply the whole-day upper bound themselves (ADR-0053 D-D1 item 5, as amended). Pins the two halves: a direct call (matchesAggregationFilter, applyHaving and the public applyInMemoryAggregation) compares as written, and the same filter lowered by lowerFilterCondition with the engine's readers answers the whole day. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…red field translateFieldOperators wrote $regex for $contains on every field, and MongoDB applies a $regex to each element of an array value, so 'u1' matched a stored ['u10']. On a field whose declared shape is JSON-stored (STRUCTURED_JSON_TYPES or isMultiValueField) it now emits an array-only $elemMatch over the members @objectstack/core's jsonMembershipCandidates names, and $notContains its exact complement. A scalar column, and a field whose declaration the driver does not hold, keep the substring test. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
… in the no-declaration pin Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…ts declaration or else its stored value matchesFilterCondition answered $contains by substring alone, so a stored array never matched it and always matched $notContains. The question now follows the FILTER_OPERATORS $contains contract: the column's declaration decides when the caller supplies it (membership on STRUCTURED_JSON_TYPES or a multi-valued field, substring on any other), and the stored value's shape decides otherwise (an array asks membership, anything else substring), the by-value split this face already gives $empty. The member candidates are the set the SQL dialects bind. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…swers membership on read and on the write check Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…membership on every typed backend Comment only. The docblock said every backend answers $contains by substring, a superset; driver-sql, driver-memory and now driver-mongodb answer membership on a declared multi-valued column. It now says so, keeps the superset reading for a backend without the declaration, and names the off-shape bare-scalar slot the array-only membership test does not reach. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…the shared lowering; the contains contract lists mongodb and formula Docblock, test-title and docs prose only. filter.zod.ts named SqlDriver.calendarDayUpperBoundRewrite / calendarDayBetweenRewrite, deleted with F1; they now name lowerFilterCondition at the seams. The FILTER_OPERATORS $contains implementation-status list gains driver-mongodb and formula. The read-scope seam test no longer says a guard without types hands the RLS using bound as written (the RLS seam lowers it type-blind since the copies went). query-syntax.mdx's direct-call sentence names every driver that now compares a direct filter as written, and the aggregate positions the engine lowers. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
… group 3b Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…b-having-contains-tail
…implicit equality check:where-matcher discovered the double and its control probe (implicit equality) threw; it now answers it the MongoDB way and still refuses the combinators it does not model. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…b-having-contains-tail
📓 Docs Drift CheckThis PR changes 4 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f11768faf0d5688a4c0e08a523941ff237fc3219 && git checkout f11768faf0d5688a4c0e08a523941ff237fc3219
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cb45469e6765648a4886073b044af1854f7c31cb 966893c3aca83ce2c3386efc434bf96f58acd5a9 && git checkout -B drift-repro cb45469e6765648a4886073b044af1854f7c31cb && git merge --no-ff 966893c3aca83ce2c3386efc434bf96f58acd5a9
node scripts/docs-audit/affected-docs.mjs --json cb45469e6765648a4886073b044af1854f7c31cb
|
Contract reviewServed-tier: Inputs: card #20822 (body and all 34 comments), PR #21196 (body, 13 files, the net diff against Check-runs on the head (their conclusions are the gate verdicts): 14 ① Derived judgmentsF8, deleted — right.
mongodb
formula
The three value-level copies — a declared duplication, not a wrong answer at this head. formula spec
Docs, the
F7 ( ② Semver level
③ Boundary flags
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…dits packages/spec ships src/**/*.zod.ts, so the docblock-only edits in filter.zod.ts publish; the changeset now names @objectstack/spec at patch with one docblock-only bullet. Clause-②: no is unchanged. Claude-Session: https://claude.ai/code/session_01Ujdtvqs7ree7WyQmEDwEnG Co-authored-by: Claude <noreply@anthropic.com>
…b-having-contains-tail
Contract reviewServed-tier: Round-1 delta review on the head after FAIL 5935291820 @ The two commits after
Check-runs on this head (their conclusions are the gate verdicts): 21 ① Derived judgmentsEvery ① judgment of 5935291820 re-confirmed at this head, against the diff and the ref:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
…es a REST caller whole (objectstack-ai#21213) Fixes objectstack-ai#21067 Clause-②: no ## What this changes `jsonColumnOperatorRefusalText` (`packages/core/src/utils/json-column-operator-refusal.ts`) is the one builder of the `INVALID_FILTER` / 400 refusal a filter gets for a scalar comparison or text operator on a multi-value or JSON field. It is rewritten once, for both of the card's reasons together (seat 2's carried note folded in: one rewrite, not two passes): 1. **Under the REST bound.** The withheld message was 748 characters. The `/data` door cuts a 4xx message of 500 or more to 499 plus an ellipsis, so the wire ended `…Refused rather than compiled because the answ…`, and no caller read the sentence saying the field and the operator were withheld. It is now **486** characters, one constant text. 2. **True on every face.** The old reason named `driver-sql`'s storage ("a field this driver stores as a JSON TEXT column") and the two wrong answers SQL used to give. Both are untrue on the engine's per-aggregation `filter` and on `driver-memory`, which print the same text. The reason is now the field's declaration: `it aims a scalar comparison or text operator at a multi-value or JSON field, which it cannot test for one member.` 3. **The null comparand's repair (added at the PM's note on this card).** The refused set also catches `{ f: null }`, `$eq: null` and `$ne: null`, which ask whether the field has a value; `$contains` cannot express that. One constant clause, with no branch on the comparand, names the presence spellings: `For no value, use "$null" or "$empty".` Both answer on a multi-value or JSON field on every face (they are outside the refused set), and the REST pin now proves `$null: true`, `$null: false` and `$empty: true` answer on both the `where` and the per-aggregation faces. The new message, in full: ```text A constraint in this filter WAS NOT APPLIED: it aims a scalar comparison or text operator at a multi-value or JSON field, which it cannot test for one member. Use "$contains" for membership ({ "FIELD": { "$contains": "a" } }), or an $or of "$contains" for any-of ({ "$or": [{ "FIELD": { "$contains": "a" } }, { "FIELD": { "$contains": "b" } }] }). For no value, use "$null" or "$empty". The field and the operator are withheld from the message; the full diagnostic is in the server log. ``` The diagnostic (server log, and the author-disclosed wire text, see below) shares that reason, names the operator in it (`it aims "$in", a scalar comparison or text operator, at …`; the bare spelling's operator as `=`, as before), names the field, and spells the remedy with the field's name, presence clause included. It drops the same storage and SQL history. One `refusalReason()` and one `containsRemedy()` (which ends in `PRESENCE_REMEDY`) serve both texts, so the two cannot drift. The SQL mechanism and the measured wrong answers stay in the builder's docblock. **Unchanged:** `code`, `status`, the refused operator set (`JSON_COLUMN_INCOMPATIBLE_OPERATORS`, 27 spellings), the `$contains` remedy, no new export, no new code. **Faces.** Re-derived at `cb45469e` by name and by text: every face that prints this sentence calls the builder, and none builds its own copy. They are `driver-sql` `jsonColumnOperatorError` (`sql-driver.ts`), `objectql` `having-filter.ts` (the judgment and the per-row backstop, three call sites), and `driver-memory` `jsonStoredFieldOperatorError` (`filter-refusal.ts`). No face file changes except one stale docblock in `sql-driver.ts`. `driver-turso`'s `remote-transport.ts` and `driver-mongodb` share phrases with other refusals, not this sentence. No docs page or skill quotes it (`content/docs/**`, `skills/**`, `docs/**`, `apps/docs/**`, by builder name and by seven distinctive phrases: zero hits). ## Measured, before and after | reading | `BASE` `cb45469e` | this PR | |:--|:--|:--| | withheld message, constant | 748 chars | 486 chars | | its wire `error`, SQLite and live PostgreSQL 16.14, `mapDataError` | 499 plus an ellipsis, ending `Refused rather than compiled because the answ…` | the whole message | | author-disclosed diagnostic, field `owners`, `$in` | 643 chars | 402 chars | | that, at `POST /api/v1/data/:object/query` through the real `SecurityPlugin` | 499 plus an ellipsis, ending `Refused rather than compiled beca…` | the whole diagnostic (measured at 377 before the presence clause; the 402 text is pinned whole through `mapDataError`) | | field-name length from which the diagnostic is cut on the wire | every length | 27 characters (the presence clause goes first) | | field-name length from which the cut takes the any-of example | 10 characters | 37 characters | ### A dispatch hypothesis this falsified (H5) The dispatch read the diagnostic as server-log text the envelope never bounds. Measured, it is also a wire text. `driver-sql`'s `'author'` provenance arm (`resolveWithheldFilterRefusal`) swaps the refusal for the diagnostic when the predicate is the caller's own, and `plugin-security` marks the caller's verbatim `where` `'author'` (`security-plugin.ts`, the `markFilterSubtreeProvenance(callerWhere, 'author')` call). Through `POST /api/v1/data/:object/query` with a real `SecurityPlugin`, a member caller sending `{ owners: { $in: ['u1'] } }` received the diagnostic: cut to 500 at `BASE`, whole (377) at `caf0e3c7`, before the presence clause raised it to 402. That was a one-off measurement file, run twice and not committed; the `BASE` leg rebuilt `core`'s `dist/` with the old text and restored it, with dist preflight proofs both ways. The diagnostic was in the rewrite already, because its reason clause was the same untrue `driver-sql` mechanism seat 2's note names. All four conditions of the bounded in-place fix hold: same defect class (a refusal cut at the envelope), a mechanical fix in the shape triage pinned, the claimed file, and the same gate families. So it is fixed here, not filed. Its content is kept: field and operator named, remedy with the field's name. ## Pins New, compared with the builder's output and the bound's own function (`truncateClientMessage` / `mapDataError` from `@objectstack/types`), never with a copied sentence or a retyped 500. `CLIENT_MESSAGE_MAX` itself is module-private in `packages/types/src/data-error-classification.ts`, so it is not imported; `truncateClientMessage` is already exported there for `rest`, and nothing new is exported. A sibling that rewords nothing and only calls the builder cannot flip these. - `packages/core/src/utils/json-column-operator-refusal.test.ts`: re-captured hashes and lengths. For every refused spelling, `truncateClientMessage(message)` returns it unchanged. The message carries the one-member, any-of and no-value remedy and ends with the withheld sentence. Neither text names a storage form or a backend's wrong answer. The diagnostic gives the same reason with the operator named. - `packages/drivers/driver-sql/src/sql-driver-json-column-refusal-wire-bound.test.ts` (new, `DIALECT_CELLS`): for 14 operators plus bare equality, on a multi-value lookup, a `tags` field and a `json` field, `mapDataError(err).body.error` equals the shared message (unmarked, carrying the presence clause) and the shared diagnostic (author-marked), with the remedy spelling. SQLite always; **PostgreSQL and MySQL in CI's `Temporal Conformance (live PG + MySQL)` job**, which runs this package's whole suite with both URLs set. - `packages/rest/src/aggregation-filter-json-column-refusal.test.ts`: through `POST /api/v1/data/:object/query`, the `where` twin's body equals the builder's message whole, not just the per-aggregation face's (`toBe`, plus the any-of remedy, the presence clause and the withheld sentence), on the null-comparand rows too; three new controls show `$null: true`, `$null: false` and `$empty: true` answer with equal counts on both faces. Flipped, each to the new substance: - `sql-driver-json-column-operator-refusal.test.ts`: `'JSON TEXT column'` becomes the operator-named reason, and the remedy is asserted with the field's own name. - `sql-driver-target-field-provenance.test.ts`: the class fragment that survives redaction, `'JSON TEXT column'`, becomes `'at a multi-value or JSON field'`. - `sql-driver-json-column-refusal-shared-text.test.ts`: the docblock's "did not change by one byte" claim. ADR-0112 `code` plus `status` assertions are untouched everywhere. ### Reverse verification At `fefb6e1f`, `BASE`'s builder was written to disk (tree only, never staged) and its landing checked by grep: old text 1, new 0. Then: - `sql-driver-json-column-refusal-wire-bound.test.ts` (SQLite plus live PostgreSQL 16.14): **90 failed** (45 per cell), 1 skipped (MySQL). The red direction, as expected. - The core pin: **8 failed**, 4 passed. Restored with `git checkout HEAD -- PATH` under an `EXIT INT TERM` trap. The blob is `2f17d7f3`, equal to HEAD's, and `git diff HEAD` is empty. ## Verification (on `d352319a`, the final head: the branch, one merge of `origin/main` `0d421041`, which touches none of these packages, and the presence-clause commit) The targeted files below ran on `d352319a`, with `OS_TEST_POSTGRES_URL` set to a private PostgreSQL 16.14: - core: 12 passed - driver-sql, the five JSON-column files: 360 passed, 2 skipped (the MySQL cells) - rest `aggregation-filter-json-column-refusal`: 136 passed, 68 skipped (MySQL) - driver-memory `memory-20444-*` and `memory-21066-*`: 125 passed - objectql `engine-aggregate-filter-json-column-refusal`, `engine-aggregate-filter`, `engine-aggregate-filter-array-membership` and `engine-cascade-delete-multivalue-probe`: 263 passed Full suites, on the pre-merge commits (before the presence clause; that commit touches only the builder, its unit pin and the two wire pins above, which were re-run): - `@objectstack/core` local: 74 files, 2107 passed - `@objectstack/driver-sql`, SQLite: 208 files passed, 11 skipped; 3469 passed, 192 skipped - `@objectstack/rest` local: 255 files, 4834 passed, 301 skipped Typecheck: `core`, `driver-sql` and `rest` all pass, and `tsc --listFiles` confirms the four touched driver-sql test files are in its program. **Gates** (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, re-derived and re-run on `d352319a`): 65 derived, 65 exit 0, 0 NOT MEASURED. `--ran` with exit codes reconciles to 65 accounted, 0 unrun. - `check:dual-build-cjs-loads` measured this time: 105 require entries across 66 packages load. The first run on `8450f66b` was exit 3 `PREREQUISITE NOT MET`, since a whole-repo build was missing then. - `check:driver-conformance`, before the first edit and after the last commit: 50 covered, 0 DEBT, 0 exempt; dialect axis 8 suites, 0 in the DIALECT ledger. The ledger did not move. **Lint**, narrowed: 1. The population comes from eslint's own config: `--format json` reports every listed file with 0 warnings, so none was ignored. 2. On `d352319a`, the 8 `.ts` files of this diff were linted, with 0 errors and 0 warnings. 3. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's to run. **Patch round 1 (`89209290`).** `Lint & Repo Gates` was red at `d352319a` on `pnpm check:live-db-isolation`: `packages/rest/src/aggregation-filter-json-column-refusal.test.ts:258 use names the literal "$null"`. The fix is test-only: the presence clause is asserted in two pieces. On `89209290`: - `pnpm check:live-db-isolation`: exit 0, 34 live-server files scanned. - That test file: 136 passed, 68 skipped, with SQLite and live PostgreSQL 16.14 running and MySQL a named skip. - The 65 derived families re-run: 65 exit 0. `check:dual-build-cjs-loads` gave exit 3 PREREQUISITE NOT MET until another family built the missing `dist/`, then exit 0 on re-run. `--ran` reconciles to 65 run, 0 NOT MEASURED. - `check:live-db-isolation` is a declared WIDE-population family, so per-card derivation never names it. ## Siblings that print this sentence (both landed before this PR; seat edit) Both siblings this PR named as later landers merged first, so they are earlier landers now: - objectstack-ai#21178 → `862f12c0b` (PR objectstack-ai#21208): the `driver-turso` remote face. It prints the builder's output and pins it by equality with the builder, so this rewrite does not flip it. - objectstack-ai#20822 group 3b → `e18fea6dc` (PR objectstack-ai#21196): new `having-filter.ts` callers of the builder. This branch is behind both. The merge queue rebuilds it onto `main`, so their pins run against this text in the merge group. ## Acceptance notes - **The diagnostic's boundary.** It names the field four times, so its length grows with the name. It is whole on the wire up to 26-character field names (measured over `$in`, `$startsWith` and bare equality). From 27 characters the cut takes the presence clause first, and from 37 the any-of example; the one-member remedy and both names come before both. Field names declare no maximum length, so no text that repeats the name can be bounded. The withheld message, the card's subject, is constant and bounded. - **A gate false positive, on the test's spelling.** `check:live-db-isolation`'s statement-head needle matches the verb USE followed by a quoted operand (`STATEMENT` in `scripts/check-live-db-isolation.mjs`). It read the assertion string `'For no value, use "$null" or "$empty".'` at `packages/rest/src/aggregation-filter-json-column-refusal.test.ts:258` as a MySQL USE naming a database `"$null"`. Fixed in `89209290` by asserting the clause in two pieces; the gate and the product sentence are unchanged. - `packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts` keeps a test double whose refusal paraphrases the old wording ("is stored as a JSON TEXT column"). It asserts only `code` and `status`, so it is not a pin of this text. Left as is. - `rest`'s own PostgreSQL and MySQL cells of `aggregation-filter-json-column-refusal.test.ts` are still provisioned by no CI job (its header says so). The PostgreSQL wire pin that CI does run is the new `driver-sql` file. - The local PostgreSQL leg ran against a private PostgreSQL 16.14 started for this run on a random port (UTC server, so `driver-sql`'s temporal files were not run against it). It was stopped afterwards. --- _Generated by [Claude Code](https://claude.ai/code/session_017xfMoEjKUuSh2xYB8sCozp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #20822
Clause-②: no
Group 3b of #20822 (#5930 step 4, the engine lane), under claim 5930666311. It carries F8, the three #20987 engine faces from triage pointer 5922592744, the stale F1 pointers, and the docs sentence. F7 (
lteBound) is untouched: its card #21109 was ruled A at 14:16Z and remains open, and the deletion waits until that card's PR is onmain.What changes
havingandaggregations[i].filterwalker (having-filter.ts)wholeDayUpperBound, its two arms, and thenextUtcCalendarDay/UNBOUNDED_ABOVEimportsdriver-mongodbtranslateFieldOperators$contains/$notContainsask membership on a declared JSON-stored field (array-only$elemMatchoverjsonMembershipCandidatesfrom@objectstack/core)formulamatchesFilterCondition$contains/$notContainsask membership by the column's declaration whenoptions.fieldsnames it, else by the stored value (seat answer Q2 = C, 5926601042)engine.tsdelete-probe docblockfilter.zod.tsSqlDriver.calendarDay*RewritenamelowerFilterCondition; the$containsimplementation-status list gainsdriver-mongodbandformularead-scope-shared-lowering-seam.test.tsusingbound as writtenquery-syntax.mdxInMemoryDriver,MongoDBDriverandapplyInMemoryAggregationbeside the SqlDriver family, and the aggregate positions the engine lowersF8: measured first (one grep, one probe)
applyHavingandmatchesHavingare not exported from either objectql entry.matchesAggregationFilteris reached throughapplyInMemoryAggregation, which both entries export. In-repo callers areengine.aggregate(seam-fed) andpackages/verify/src/date-bucket-parity.ts, whose ASTs carry no per-aggregation filter.engine.aggregateresolves then lowersaggregations[i].filterwithdeclaredDatetimeLowering(schema), andhavingwithaggregatedRowColumnTypes(...) === 'datetime'. The copy's set wasclassOfDeclaredType(type) === 'datetime', andINSTANT_TYPESis{ datetime }, so the two sets are equal. With no field map, the seam is type-blind on the per-aggregation filter and passes no column onhaving; the copy passed none on either. Nothing composes intohavingor an aggregation filter after the seam (predicate-guard.tsonly reads them).datetimefield,{ opened_at: { $lte: '2026-02-01' } }over 6 rows. Throughengine.aggregate, before and after: 3 (the whole day). ThroughapplyInMemoryAggregation(rows, ast, undefined, fields)called directly: 3 before, 2 after (that day's midnight, as written). That is item 5. Group 3a graded the same move on F6 asno.The
$containsfacesEach face is pinned on
u1against a stored["u10"], with a scalar control:{ owners: { $contains: 'u1' } }on amultiple: truelookup emits{ owners: { $elemMatch: { $in: ['u1'], $not: { $type: 'array' } } } }. It used to emit$regex: 'u1', which MongoDB applies per element. Atextfield keeps$regex. A field the driver holds no declaration for keeps$regex, as driver-sql does for a table it was never told about.matchesFilterCondition({ owners: ['u10'] }, { owners: { $contains: 'u1' } })is false,['u1', 'u2']is true, and{ title: 'u10' }is true (substring).The declaration H2 asked about:
MongoDBDriverreads it throughValueShapeResolveroncesyncSchemahas run, and a directtranslateFiltercall gets none.jsonMembershipCandidates(core, PR #21117) supplies the candidates, parsed from JSON text into values.driver-mongodbalready depended on core. Formula depends on spec alone, so it carries a value-level copy of the same candidate rule, as objectqlhavinganddriver-memorydo (see the acceptance notes).The emitted mongo documents were also read through mingo 7.2.4 (driver-memory's evaluator) in a scratch probe. It agreed with the server-free reader on every new case. A real
mongodwas NOT MEASURED: there is no binary here, and the live block in the new test file is skipped. That is the card's recorded gap.RLS effect of the formula face (H3)
The write check evaluates
checkwithmatchesFilterCondition, handed the object's declared columns. The probe ran through ObjectQL, SecurityPlugin and SqlDriver (better-sqlite3 and sqlite-wasm, identical). Policy:record.tags.contains('x')on atagsfield. The "before" column is formula's pre-change arm, ablated indist/.tagsusingcheckbeforecheckafter['x']['x']['a', 'x']['a', 'x']['xy']['xy']'xy'['xy']'x'['x']nullnull'xy'.'x'. The check judges the raw post-image, before the write door wraps a scalar into a list. This is the class [Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109's ruling A addresses for temporal columns ("the RLS write check judges the row as it will be stored"). The multi-value wrap is not in that ruling's fold, so it is reported to [Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109's family rather than worked around here. [Decision] #20822 F7: retiring formula's whole-day copy — the RLS write check judges the raw post-image, so deleting the copy refuses writes the same policy's read shows #21109 remains open.Clause-②(H5)no, as claimed:driver-mongodb,$containsnarrows on declared JSON-stored fields (exact member instead of a per-element substring). Onformula, it widens on arrays and narrows on a scalar stored in a declared JSON-stored column. On objectql, a direct call compares as written.Levels:
@objectstack/objectql,@objectstack/driver-mongodb,@objectstack/formulaand@objectstack/specarepatch. The spec entry is docblock-only:filter.zod.tsships in the spec tarball (filesincludessrc/**/*.zod.ts), so its edited docblocks publish (patch round 1, 9a797d0, after review 5935291820). The docs and test edits do not publish.Ablations (on committed heads; every restore proven blob == HEAD and
git diff HEADempty)$ltewhole-day arm (nested WRAP: import, then arm; objectql tests importsrc). 3 red of 798, exactly the direct-call$ltecells (per-aggregation$lte,applyInMemoryAggregation,havingonmin(datetime)). Every seam-fed cell stays green: the card's rows 3 and 4, thehavingrows, the temporal kit, andengine-shared-filter-lowering-seam.$betweenarm. 1 red of 798, exactly the direct$betweencell.$regexarms inmongodb-filter.ts. 10 red of 690, all membership cells in the new file. The scalar controls, the no-declaration cell and all pre-existing suites stay green.containsAsksMembershipto false in formula, then rebuilds formula, which plugin-security consumes throughdist/.ablation-dist-preflightreported the marker absent fromdist/(exit 1).usingread cells green.--absentpassed and the tree was clean.Tests (final head a62f5ff,
vitest run --maxWorkers=2, under the verify lock)--project local)read-scope-shared-lowering-seamtypecheck(tsc pluscheck:test-typecheck) exits 0 for objectql, driver-mongodb, formula, plugin-security and service-analytics.speccheck:generated: 15 of 15 up to date.check:driver-conformancereads the same before (BASE) and after (head): OK, 50 covered cells, 0 DEBT, 0 exempt.eslint --no-inline-config --format jsonover the 11 changed.tsfiles at a62f5ff: 11 files, 0 errors, 0 warnings, none ignored. The.md/.mdxfiles are outside eslint's configured population ("no matching configuration").eslint.config.mjsenables no type-aware linting, so no untouched file's verdict can move. The fullpnpm lintis CI's.dispatch-gates --commandsat a62f5ff derived 115 families from 13 paths. All 115 were run with exit codes recorded and all exited 0.--ran: 115 derived, 115 run, 0 NOT-MEASURED, 0 UNRUN.check:skill-examples,check:dual-build-cjs-loadsandcheck:i18n. They passed after a full turbo build.check:where-matchercaught the new mongodb test double, whose control probe threw on implicit equality; 565a47a fixes it.Acceptance notes
formula(this PR), objectqlhaving-filter.tsstoredArrayHasMember, anddriver-memorycontainsMemberCandidates. Each follows core'sjsonMembershipCandidates. The home they could all import is@objectstack/spec/data(formula depends on spec alone), as thehavingdocblock already says. Not filed: it is a duplication, not a wrong answer.multiple: trueslot holding a bare scalar (out-of-band data; the write door wraps scalars) is not matched by the delete probe's$containspushdown. ThestoredReferenceIncludesscalar arm therefore never sees it. That was already true on driver-sql and driver-memory, and this PR extends it to mongodb. Noted in the docblock; not filed (no in-repo producer of such a slot was measured).query-syntax.mdx's$containsbullet. It still describes only the substring reading. That item is [finding]$contains/$notContainson a declared multi-valued or JSON-stored field still answer SUBSTRING on five faces, the analytics RLS read scope among them (u1admits a row storingu10) #20987's (its comment 5922379046), and [finding]$contains/$notContainson a declared multi-valued or JSON-stored field still answer SUBSTRING on five faces, the analytics RLS read scope among them (u1admits a row storingu10) #20987 remains open; this PR edits only the direct-call sentence its claim names.compileScopedFilterToSqlwith no declarations handed in reads no column asdatetimeand compiles the bound as written. The RLS compile seam reads a guard without types type-blind since group 2. The seam test header now says so; the divergence is noted, not filed.Generated by Claude Code