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 |
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