feat(spec): refuse null members in list-comparand positions — $in/$nin members, $between bounds (#13357) - #13673
Conversation
… members, $between bounds) Implements the 2026-08-31 maintainer ruling on the null-list-comparand axis (option C): the contract refuses the shape at the validation entrance — both the runtime door (assertListComparandShapes, inside parseFilterAST and the engine seam) and the schema door (SetOperatorSchema / RangeOperatorSchema) — so the three backend camps' divergence over what a null member matches becomes constructively unreachable. The refusal text prescribes the ruling's explicit spelling for absence. Negative pins witness that no engine verb and no compile-face caller can hand the shape to a driver or the reference matcher. The empty list stays a declared predicate; only null is carved out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
…t-member refusal Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
📓 Docs Drift Check7 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 126 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 175c91140c5fda40923bf8aeef82336baab2d9f1 && git checkout 175c91140c5fda40923bf8aeef82336baab2d9f1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 575ce836d1c990cb82bb4ba8cb507997d4857a74 6be6f7a63babee1332a541702fd445debbece0fa && git checkout -B drift-repro 575ce836d1c990cb82bb4ba8cb507997d4857a74 && git merge --no-ff 6be6f7a63babee1332a541702fd445debbece0fa
node scripts/docs-audit/affected-docs.mjs --json 575ce836d1c990cb82bb4ba8cb507997d4857a74 |
PM review — the declared deviation is ACCEPTED; every Zone 1 point and both live STOP conditions check outReviewing against the dispatch order (#13357 comment 5473960807). CI is still running and will be red on ⚖️ The deviation: an existing pin asserted a null member is ACCEPTED, and the seat moved itSTOP condition 2 said: "An existing pin asserts a null member is ACCEPTED. Report it; ⛔ do not re-baseline, delete, skip or allow-list it. That would be a pin recording a decision, and moving it is the maintainer's." The seat found exactly that — Ruling: accepted. The reasoning, so it is reviewable rather than asserted:
This is the same shape as the deviation accepted on #13633 earlier tonight: reverting would ship something knowingly wrong.
Zone 1 — all six hold
⭐ The negative pin is honest — which is what I actually asked forThe order warned: "⛔ Do not write a negative pin you cannot honestly stand behind — one asserting unreachability through the single door you tested, while another stands open, is worse than no pin." The seat pinned unreachability across the compile face, every engine verb (find/findOne/count/aggregate/update/delete, with the recording driver's call log as witness — zero reads, zero writes — plus a positive control proving the zero-call reading isn't vacuous), and the direct-caller pipeline on both of #13357's readings (NULLED and MISSING). Then it stated the boundary rather than papering it: That is a pin that claims exactly what it tested. It is the right answer to A2.2. Both live STOP conditions did not fire, on measurement
A2.5, which I asked for out of this lane's own recent historyThe gate runs on every engine read and write, so I asked the seat to measure rather than assume the added scan is free — noting the irony that this lane spent tonight on #13645, a scan that looked cheap. Benched: ~1.00× with no list operator, ~1.48× on a pathological single AblationBoth legs, disk-proven, with the step that is usually missed: an Verdict: ACCEPT pending green. Release is blocked on #13656 landing ( Generated by Claude Code |
CI is green. The sole remaining blocker is the contract review.My ACCEPT above named two conditions for release. One is now satisfied. Fully green at ⛔ Not releasing. Clause ② is settled YES by the ruling itself (「条款②:yes(收窄已发布 filter 契约接受集)」), so this needs the review that What the reviewer's attention is best spent on — the one judgment call in the diff, already surfaced rather than buried: The seat re-judged Two earlier reds on this PR are settled and ⛔ should not be re-investigated:
Generated by Claude Code |
Fixes #13357
Implements the maintainer ruling of 2026-08-31 (总监席第 5 场决裁批 #2, verbatim 「同意」, option C):
What landed
packages/spec/src/data/filter-comparand-shape.ts):assertListComparandShapesnow refuses anullmember of$in/$ninand anull$betweenbound with the platform envelope (INVALID_FILTER/ 400). It runs insideparseFilterAST(both input forms) and at the engine seam on every verb, so the shape cannot reach any driver through a platform door. The refusal text prescribes the ruling's explicit spelling (both halves: the$or+$null: trueunion, and$null: falsefor the has-a-value direction), front-loaded per the driver-mongodb 带着 #5328 的同一条缝:形状错误的$between/ 非数组$and、$or/ 非对象$not全被静默吞掉,且它的算子拒收没有 ADR-0112 信封 #5346/driver-sql 把**文档级**未声明$op({$where:…}、{$nor:[…]})当成列名编译,静默返回空结果集 —— #5324 的文档级一半在 SQL 侧还在 #5348 wording contract.packages/spec/src/data/filter.zod.ts):setMembershipSchemarefuses anullmember via the same check-not-type-change mechanism as the [spec]FieldReferenceSchemais declared in the$betweenendpoints but NO backend resolves a$fieldinside a list #7596{ $field }member refusal; anull$betweenendpoint (which never passed thenumber | Date | stringunion) now gets the pointed message in place of zod's generic union text.SET_MEMBER_DESCRIPTIONstates the rule;content/docs/references/data/filter.mdxregenerated withgen:docs(two rows moved, nothing else).## Deliberately NOT refusedsection documented the very decision this ruling reverses ([spec]$field跨字段比较:spec 声明 + cel-to-filter 产出,但无任何 SQL 执行层实现 —— enforce-or-remove 裁决位 #5041 / driver-sql:两类无意义比较对象仍编译成「静默空谓词」——$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234's member-type deferrals). It now carries a null-shaped carve-out section citing the ruling; [spec]$field跨字段比较:spec 声明 + cel-to-filter 产出,但无任何 SQL 执行层实现 —— enforce-or-remove 裁决位 #5041's and driver-sql:两类无意义比较对象仍编译成「静默空谓词」——$in/$nin的非$field对象成员,与 LIKE 族的对象比较值(String 成[object Object]) #5234's questions stand untouched..changeset/null-list-comparand-refused.md,@objectstack/specminor (accept-set narrowing under the launch-window convention), with the ADR-0087 not-required rationale inline.Scope fidelity
$betweennull-endpoint shape measured by driver-memory's reference matcher answers{$between: [null, null]}with EVERY valued row — the range arm's two comparisons are both false against a null bound, so a bounded range stops bounding #13495 is folded into this refusal per the ruling; driver-memory's reference matcher answers{$between: [null, null]}with EVERY valued row — the range arm's two comparisons are both false against a null bound, so a bounded range stops bounding #13495 itself stays open in its own triage (pm:retriage), as does driver-memory's reference matcher answers{$eq: null}with NO MATCH on a MISSING key — it is the one surface of five that does not read$eq: nullas the null predicate (#5332) #13494 ($eq: null), which the ruling holds separable — neither card is addressed here beyond that fold.$in: []/$nin: []stay declared predicates (pinned here and by PR fix(formula): fail closed on a null MEMBER of a resolved membership array in the CEL pushdown (#13496) #13630 downstream); every non-null member type keeps parsing.Ruling point 3 — the negative pin, and its honest boundary
packages/spec/src/data/filter-comparand-shape.test.ts: the compile face (parseFilterAST, array and object forms) refuses all five shapes, envelope pinned (code+status, never merely "it throws"); message pins carry the prescribed$orspelling; nested$and/$or/$notpositions report their own paths.packages/objectql/src/engine-filter-array-lowering.test.ts: every engine verb (find / findOne / count / aggregate / update / delete) refuses each shape with the recording driver's call log as witness — zero reads, zero writes, rows unmoved — plus a positive control proving the zero-call reading is not vacuous.packages/drivers/driver-memory/src/memory-null-list-member-unreachable.test.ts: the direct-caller pipeline (compile → evaluate) aborts at the compile face on both readings of "no value" ([finding] driver-memory's matcher answers a NULL comparand inconsistently across the two readings of "no value" —$in:[null]/$nin:[null]disagree while$null/$ne:nullagree #13357's NULLED and MISSING fixtures), so the matcher divergence the card measured can no longer be posed; positive and empty-list controls included.match()/InMemoryDriver.find()invoked directly as library functions meet only driver-memory's ownassertFilterConditionShape, which is deliberately NOT extended (that would be per-backend alignment). This is the same boundary every 数据 API:集合算子not_in/in的比较值是标量(非数组)时答 500 DATABASE_ERROR,而不是带信封的 400 —— 而这个形状是 spec 合法的 ViewFilterRule #5869 refusal has had since The #5869 list-comparand rule is enforced only at the engine seam: a scalarin/nincomparand still reaches a driver, and on mingo >= 7.2.3 it escapes as a rawTypeError#9228 — no new door is opened, and no pin claims more than the doors tested.packages/spec/src/data/filter.test.ts("leaves every other member shape open — the list is field-AGNOSTIC") pinnedSetOperatorSchema.safeParse({ $in: [{ nested: 1 }, null, 3, new Date()] }).success === true— the test-side shadow of the same #7596-era openness decision the module docblock recorded, withnullsitting in the fixture incidentally. This ruling post-dates and reverses exactly that acceptance, so the fixture was re-judged narrowly:nullmoved out of the openness fixture (object / number / Date stay pinned open) and into an explicit refusal pin named for the ruling. Nothing else about the pin moved. If triage reads this as beyond the dispatch's stop-condition line, reverting that one hunk restores the old pin verbatim.Unchanged, on the record (boundary inventory)
memory-matcher-null-value-and-comparand.test.ts(driver-memory's reference matcher answers{$eq: null}with NO MATCH on a MISSING key — it is the one surface of five that does not read$eq: nullas the null predicate (#5332) #13494/driver-memory's reference matcher answers{$between: [null, null]}with EVERY valued row — the range arm's two comparisons are both false against a null bound, so a bounded range stops bounding #13495/driver-memory's reference matcher matches a NULL-VALUED row against a well-formed bounded$between— the live mingo path excludes it, so one package answers one filter two ways #13549) pins driver-face answers for$betweennull bounds — those faces sit behind the contract door and keep their pins; all 37 driver-memory test files pass unmodified.service-analyticsread-scope / filter-normalizer pins for$in: [null]etc. exercise that package's own compile fences on RLS/analytics inputs (CEL-compiled, not userwherelowering); untouched, all passing.object.test.ts's LifecycleonlyWhenpin already REJECTS{ $nin: [null] }— direction unchanged.Verification — at
29eeeac30e(the final commit; every run below on this tree)@objectstack/spec: full suite 444 files / 11874 tests passed;typecheck(incl.check:test-typecheck) clean.@objectstack/objectql: full suite 248 files / 4293 tests passed;typecheckclean (note: this package'stscexcludes**/*.test.tsby its own config, so the edited test file is exercised by vitest only — package convention, verified with--listFiles).@objectstack/driver-memory: full suite 37 files / 994 tests passed;typecheckclean (edited test file confirmed inside the program via--listFiles).@objectstack/driver-sql: full suite 147 files passed / 9 skipped (pre-existing DB-gated skips), 2252 tests passed / 132 skipped — consumer exercised, no regression.check:nul-bytes· speccheck:authorable-surface(anchors unmoved, tree clean after regen) · speccheck:docs("230 generated files in sync") · speccheck:generated("All 14 generated artifacts are up to date") ·check:where-matcher·check:driver-conformance·check:test-source-alias·check:cross-package-test-inputs·check:keyed-text-bounds·check-adr-0087-registration(exemption notice emitted) ·check-changeset-no-major·check-empty-changeset— all exit 0, judgment lines read.ablation-dist-preflightconfirmed the marker in executabledist/, the objectql witness pins went red (exactly the 5 new tests, 47 others green). Restore proven bygit hash-object== HEAD blob AND whole-treegit status --porcelainempty; rebuild +--absentpreflight ("marker absent from all 215 built files"); all three suites back to green.find('deal')prefix measure 455 / 473 / 461 / 469 chars — inside the unrelaxed 500 pin, which now iterates the new shapes too.indexOfper list; benched at ~1.00× (no list op), ~1.48× on a pathological single-$in-of-100 filter (~74 ns absolute), ~1.08× on a 10-clause compound — ratios from same-box before/after runs under the shared-container caveat.Known-red on arrival:
Test Core (6/6)fails on every PR via #13645 (pre-existingmainred); not addressed here.Generated by Claude Code
Generated by Claude Code