Uh oh!
There was an error while loading. Please reload this page.
feat(spec): refuse a null comparand in the ordering positions — $gt / $gte / $lt / $lte (#14080) - #14425
Conversation
…te/$lt/$lte)
Implements the 2026-09-01 maintainer ruling (option A) on the last
null-comparand position the filter contract neither ruled on nor refused:
`{ f: { $gt: null } }` and its three siblings are refused at the same
validation entrance as the 2026-08-31 list-position refusal — the runtime
door (`assertListComparandShapes`, inside `parseFilterAST` and at the engine
seam) with the `INVALID_FILTER` / 400 envelope, and the schema door
(`ComparisonOperatorSchema` / `FieldOperatorsSchema`, now one shared slot
factory) with the pointed message in place of zod's generic union text.
The refusal prescribes the ruled null predicates, `$eq: null` / `$ne: null`.
driver-memory's two-face divergence on the shape becomes constructively
unreachable; the matcher is not repaired and no ordering-vs-null semantics
is defined anywhere. Negative pins on the compile face, the engine seam
(every verb, zero driver calls) and the matcher pipeline; the carve-out is
strictly null — every value, `{ $field }`, `$eq` / `$ne` and `undefined`
keep the answers they had.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68…dering-null-comparand-refusal
…ArrayQuery in the #14080 pin The query-options-erasure ratchet counts a bare `as any` on a query-options position; the file's own helper names the contract being bypassed and is the spelling the ratchet asks for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 Docs Drift Check12 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 — 128 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 19c90d2ee02310d5ed1f9d5609c835362e39a5b5 && git checkout 19c90d2ee02310d5ed1f9d5609c835362e39a5b5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 793065de2c03936d4dd88f7026a1530d4c52c462 16379773843fbd97f1d3dc1e3b2c21a78bba1b82 && git checkout -B drift-repro 793065de2c03936d4dd88f7026a1530d4c52c462 && git merge --no-ff 16379773843fbd97f1d3dc1e3b2c21a78bba1b82
node scripts/docs-audit/affected-docs.mjs --json 793065de2c03936d4dd88f7026a1530d4c52c462 |
os-musk
commented
Sep 2, 2026
Contract review PASS at tier — Generated by Claude Code |
os-musk
commented
Sep 2, 2026
Provenance ( Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14080
Executes the director seat's ruling A on #14080 (maintainer batch #24, 2026-09-01, 「同意」): a
nullcomparand of$gt/$gte/$lt/$lte— the last null-comparand position the filter contract neither ruled on ($eq: null/$ne: nullARE the null predicate, #5332) nor refused (the 2026-08-31 list-position refusal, #13357 / PR #13673) — is now refused at the same validation entrance as PR #13673, with the same envelope (INVALID_FILTER/ 400) and a message that prescribes the ruled spellings. driver-memory's two-face divergence on the shape becomes constructively unreachable; ⛔ the matcher is not repaired (ruling point 4), ⛔ no ordering-vs-null semantics is defined anywhere (ruling point 3), ⛔ nothing under #14079 is touched (ruling point 6). Template:git show e398863ce(PR #13673) — same door, message shape, envelope, pin layout and changeset convention.Clause-②: yes — the PR carries
needs:contract-reviewat open and stays a draft until the contract review at tier passes (ruling point 5). Self-reading from the diff, below.Deliverable 1 — the census (gated everything else)
Method: my own — neither PR #13673's body/comments nor the #13357 thread records the 2026-08-31 census method (both were read from the public pages; the only mention is triage's instruction that the executor run one). Script: a Python walker over
examples/**,apps/**,packages/**,content/docs/**,skills/**(excludingnode_modules,dist,.turbo,.cache,coverage,build), three authoring forms, every spelling the contract accepts:{ f: { $gt: null } }— regex over TS/TSX/JS/MJS/CJS/MDX/MD, parsed walk over JSON and YAML (so YAML~and an empty value count as null);['f', OP, null]— all 20AST_OPERATOR_MAPspellings that lower to the four operators (the greater-than sign,gt,greater_than,greaterthan,after; the greater-or-equal sign,gte,greater_than_or_equal,greaterthanorequal,greaterorequal; the less-than sign,lt,less_than,lessthan,before; the less-or-equal sign,lte,less_than_or_equal,lessthanorequal,lessorequal— the four sign spellings are written in words here because GitHub's body sanitizer eats angle-bracket-shaped fragments), regex + parsed walk;{ field, operator: OP, value: null }— everyVIEW_FILTER_OPERATORSmember andVIEW_FILTER_OPERATOR_ALIASESkey that lowers to the four, regex over flat object literals + parsed walk.Run on the worktree at
8d3f0939d(before any edit); 6,105 text + 361 JSON + 2 YAML files scanned.$gt$gte$lt$ltePositive control (fires): four scratch files planted under
packages/spec/src/data/__census_control__/(control.ts,control.json,control.yaml,control.mdx) carrying every form and every operator → the same run counted 28 control hits on top of the same 11 ($gt4/3/2 ·$gte2/2/1 ·$lt3/3/2 ·$lte2/2/2 as object/array/viewrule, across all four file types); removed → back to 11,git statusclean.Every one of the 11 raw hits, classified (none is an authored filter in
examples/**,apps/**,content/docs/**,skills/**or any non-test source):packages/services/service-analytics/CHANGELOG.mdlines 2249, 2405, 6510, 6606{amount: {$gt: null}}and #5332's "positions no ruling covers" sentenceservice-analytics/src/strategies/filter-normalizer.ts:198driver-memory/src/memory-matcher.ts:342driver-memory/src/memory-matcher-null-value-and-comparand.test.ts:63service-analytics/src/__tests__/filter-normalizer-undefined-comparand.test.ts:241-242{ d: { $gt: null } }(binds NULL, #5526)service-analytics/src/__tests__/filter-value-type-fidelity.test.ts:470{ code: { $gt: null } }throughNativeSQLStrategypackages/formula/src/matches-filter.test.ts:184matchesFilterfail-closed on{ $lte: null } as neverReading: 0 authored occurrences ⇒ proceed to deliverable 2 (ruling point 1).⚠️ Declared judgement, not a mechanical number: hits 8-11 are test fixtures that exercise the degenerate shape's HANDLING in two consumers that sit outside the refused door — the analytics normalizer takes the object-form
wherewithoutparseFilterAST(lowerAnalyticsWherelowers only the array form through it) andformula'smatchesFilternever calls it — and they stay green on this branch (measured: the three files are untouched and their consumers' inputs do not pass the door). They are pins of a degenerate input, not authors reaching for the shape (the ruling's p1 trigger is 「真实 authored occurrence」/「那时它不再是退化输入」). If the seat reads a test fixture as an authored occurrence, the census is 4 and the card returns to the decision box per ruling point 1 — this PR is a draft and can be closed at no cost. Re-run after implementation with the new pins excluded: the same 11 plus 3 comment lines in the edited door's own docblock; supplementary scan ofdocs/**,.claude/**,.changeset/**,scripts/**: 3 hits, all prose (two in changesets, one of them this PR's own).Boundary: the census is of LITERAL
null; dynamic producers were not censused beyond the three first-party producers the schema docblock names (lifecycle-service,plugin-emailoutbox sweep,plugin-auth's adapter) — all three emit.toISOString()strings or the producer's own typed value, never a literal null.Layer measurement — where the ACCEPT lived (before editing)
packages/specat8d3f0939d, calling each layer directly on{ n: { OP: null } }:$gt$gte$lt$lte$eq$neparseFilterAST(object form)parseFilterAST(array form, all 10 probed spellings)assertListComparandShapes(runtime shape door)$in/$nin/$betweennormalizeFilterComparandTypes(runtime type door)nullis an accepted comparand TYPEFieldOperatorsSchema/ComparisonOperatorSchema(schema door)invalid_union"Invalid input"FilterConditionSchemaSo the accept was served by the RUNTIME door: the shape door did not cover the ordering positions and the type door admits
nullas a type;parseFilterASTnever runs the zod schema. The refusal therefore goes where #13673's went —assertListComparandShapesinfilter-comparand-shape.ts, whichparseFilterASTand the engine seam (@objectstack/objectql's delegating wrapper) both run — and the schema door gets the pointed message by the same replace-onlyerrormechanism as the$betweenendpoint.Deliverable 2 — per position, before → after
8d3f0939d){n: {$gt: null}}INVALID_FILTER/ 400{n: {$gte: null}}INVALID_FILTER/ 400{n: {$lt: null}}INVALID_FILTER/ 400{n: {$lte: null}}INVALID_FILTER/ 400['n', OP, null]for all 20 spellingsINVALID_FILTER/ 400, message names the spelling{n: {$eq: null}}/{n: {$ne: null}}/{n: null}{n: {$gt: 0}},{n: {$gte: ''}},{at: {$lt: '2026-07-01'}},{at: {$gt: Date}},{a: {$lte: {$field: 'b'}}}{n: {$gt: undefined}}nullhere){n: {$in: [null]}},$betweennull bounds{$gt: null}$eq: null/$ne: nullRuntime message (engine-prefixed length pinned under the 500-char client bound; 475 with
find('deal'):andclose_date):Files:
packages/spec/src/data/filter-comparand-shape.ts—ORDERING_COMPARAND_OPERATORS(four operators, 20 spellings),nullOrderingComparandError, the strict-nullbranch inassertFieldListComparands, module note section "Refused BY RULING, 2026-09-01".packages/spec/src/data/filter.zod.ts—nullOrderingComparandMessage, one sharedorderingComparandSchema(op, label)factory now building BOTH the documentation copy (ComparisonOperatorSchema) and the enforced copy (FieldOperatorsSchema) so they cannot drift (the pairingsetMembershipSchemagives the set slots);ORDERING_COMPARAND_DESCRIPTIONgains the null sentence.filter-comparand-shape.test.ts(refusal on both input forms, message contract, nested paths, strict-null controls, the 500-char bound, a reconciliation pin that every ordering spelling inVALID_AST_OPERATORSis refused AND named),filter.test.ts(schema door, both copies, controls),packages/drivers/driver-memory/src/memory-null-ordering-comparand-unreachable.test.ts(mirrorsmemory-null-list-member-unreachable.test.ts: compile-then-match aborts at the compile face on BOTH readings of "no value" over the card's numeric fixture; positive control;$eq: nullcontrol),packages/objectql/src/engine-filter-array-lowering.test.ts(the [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 block's shape: every verb refuses before any driver call, zero reads/writes, positive control reaches the driver,$ne: nullreaches the driver, nested path with the engine prefix). The objectql extension was added because feat(spec): refuse null members in list-comparand positions — $in/$nin members, $between bounds (#13357) #13673's shape included the engine half — declared here as required by the template..changeset/null-ordering-comparand-refused.md—@objectstack/spec: minor(precedent feat(spec): refuse null members in list-comparand positions — $in/$nin members, $between bounds (#13357) #13673; BREAKING accept-set narrowing under the launch-window convention; adr-0087 not-required note).Controls that stayed green
$eq: null/$ne: null/ implicit{ n: null }passparseFilterAST, both schema copies andNormalizedFilterSchema; on driver-memorycompileThenMatchanswers['3']/['4']for the nulled / missing reading (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).0, string incl.'',Date,{ $field }), the array form with a value, andundefined(TYPE door's own sentence) — unchanged.#13673's pins (list positions) — unchanged and green;memory-null-list-member-unreachable.test.ts9/9.Ablation — predicted before the run: red on the new pins, green on every control
Committed first (
33bfec606), then mutated by script withtrap '…restore…' EXIT INT TERM, absolute paths fromgit rev-parse --show-toplevel:if (ORDERING_COMPARAND_OPERATORS.has(op)) {1 → 0; zod anchorissue.input === null ? nullOrderingComparandMessage(op) : undefined1 → 0;git diff --stat:filter-comparand-shape.ts | 6 ------,filter.zod.ts | 4 +---../filter.zod/./filter-comparand-shaperelatively; driver-memory'svitest.config.tsaliases@objectstack/spec/DATA-NAMESPACEtopackages/spec/src/…/index.ts(the packages/runtime's vitest alias list omits@objectstack/spec/cloud, so any runtime test that loads a compiled artifact dies with ENOTDIR #9457 anchored rule), so both read the mutated SOURCE directly — no build leg exists for these two, and none was needed. The objectql pin resolves@objectstack/spec/datathroughexports(dist, ledgered inKNOWN_UNALIASED_TEST_IMPORTS) and was NOT ablated at dist level — declared, not hidden.it.eachrefusals, message contract, nested paths, the length bound now including the four ordering shapes, the ordering reconciliation pin, and both schema-door message pins), zero controls; driver-memory 6 failed / 9 passed — exactly the six refusal cases, the positive control, the$eq: nullcontrol and the [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 file green.git checkout HEAD -- ABS-PATH ABS-PATH;git hash-objectof both files equals theHEAD:blob (9c274d23…,5ee9861d…);git diff HEADempty; re-run: spec 265/265 (4 files), driver-memory 15/15.Gate union on the final HEAD
163797738Derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandson the merged tree (origin/main8eeca27dbmerged in as00803ce85; 57 commands, family identical before and after the merge). Exit codes captured after redirect, never through a pipe; verdict lines quoted from each gate.pnpm --filter @objectstack/spec test(whole suite as two vitest shards,--maxWorkers=2)pnpm --filter @objectstack/spec typecheck(tsc + scripts project + test-typecheck)pnpm --filter @objectstack/spec check:generatedgen:api-surfaceagainst the fresh dist — no artifact changed)pnpm --filter @objectstack/spec check:authorable-surface/check:api-surface/check:docs/ 12 more speccheck:*pnpm --filter @objectstack/driver-memory testtypecheck✓pnpm --filter @objectstack/objectql exec vitest run src/engine-filter-array-lowering.test.ts(dist closure rebuilt on the merged HEAD)pnpm --filter @objectstack/objectql typecheck✓check:*/scripts/check-*.mjscommands of the familycheck-dev-prereqs.mjsexit 1 = its own "The workspace is not built — 1 unmet precondition, not a list of problems" (53 packages have no dist; only the two closures were built here) ·check-test-completeness.mjsexit 3 andpm/check-half-states.mjsexit 3 = NOT MEASURED by their own text (need a turbo test log / GitHub access)check:query-options-erasure00803ce85was red — test surface 236 → 238 from twoas anyin the new engine block; respelled with the file's ownasFilterArrayQueryin163797738)check:driver-conformance,check:type-check-coverage,check:type-source-resolutioncheck:dual-build-cjs-loads,check:type-check-debtpnpm lint(eslint . --no-inline-config, whole repo, foreground on163797738)--format json: 6 files linted, 0 errors, 0 warnings (the config is not type-aware — noparserOptions.project— so the narrowing excluded nothing this diff could move)Docs — a deviation, declared
The dispatch's docs item ("one line in
content/docs/references/data/filter.mdxas #13673 did") is not producible mechanically: that generated page rendersSetOperator(where #13673'sSET_MEMBER_DESCRIPTIONchange surfaced) but noComparisonOperator/FieldOperatorssection, so the new sentence inORDERING_COMPARAND_DESCRIPTIONreaches only the JSON schemas and thedescribe()text;check:docsis green with zero diff undercontent/docs/**, and no generated artifact was hand-edited. Hand-written candidates for one sentence, if the seat wants one: the operator tables incontent/docs/protocol/objectql/query-syntax.mdx(line ~249) andcontent/docs/data-modeling/queries.mdx(line ~39); #13673 touched neither, so this PR follows the precedent and leaves them.Clause ② — self-reading from the diff
Yes. Path limb:
packages/spec/src/data/filter.zod.tsandfilter-comparand-shape.ts(contract schema + the validation door). Content limb: a narrowing of the published filter accept set —{ f: { $gt|$gte|$lt|$lte: null } }, in the object form and in every array/authoring spelling, moves from ACCEPTED to REFUSED atparseFilterASTand at every engine verb; nothing widens, no key is added or removed,$eq: null/$ne: nulland every non-null comparand keep their verdicts, and the schema door's verdict fornullis unchanged (it never parsed) — only its message changes. Semver:@objectstack/spec: minorper the #13673 precedent (BREAKING narrowing under the launch-window convention). Derived judgements for the reviewer: (1) the refusal is strictlynull—undefineddeliberately keeps the TYPE door's sentence; (2) the enforced and documentation copies are now one factory, which ADDSdescribe()text toFieldOperatorsSchema's four ordering slots (description only; the union is byte-identical); (3) the door keeps its exported nameassertListComparandShapesalthough it now also refuses two null-shaped carve-outs — renaming would touch the engine's delegating wrapper, which is fenced.Honest boundaries (not widened, stated)
service-analyticslowers only the ARRAY form ofwherethroughparseFilterAST; an object-form{ $gt: null }still reaches its normalizer (binds NULL, analytics 的string[]值往返对字符串比较数也有损:{code: {$eq: '007'}}绑成数字7、'null'绑成真 NULL、'true'绑成1—— 文本列静默取到错行 #5526) — the same "fifth door compiles where itself" boundary analytics 是 #5158 拍板 C 漏掉的第五道门:where为数组(FilterArray 糖)时被normalizeAnalyticsFilterTree静默丢弃,图表画全表 #5334 records, unchanged here.formula'smatchesFilterand a directmatch()/InMemoryDriver.find()caller that skips the compile face meet no door — the 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 (same sentence as the feat(spec): refuse null members in list-comparand positions — $in/$nin members, $between bounds (#13357) #13673 pin's module note).Serial constraints, re-checked at open
All 21 open PRs' heads fetched (
refs/pull/N/head) and diffed against their merge base: none touches any file in this branch's list (filter.zod.ts,filter-comparand-shape.ts, the four test files, the changeset); #14404 and #14381 touchpackages/spec/api-surface/*.json, which this branch does not change (gen:api-surfacereproduced the committed surface byte-for-byte).Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Generated by Claude Code