fix(spec): position-safe undefined-comparand prescription; re-point driver-memory's null-comparand prose at the 2026-09-01 ruling (#14426) - #15464
Conversation
…26-09-01 ruling; position-safe undefined prescription The 2026-09-01 ruling (option A, PR #14425 under #14080) refused a null comparand of $gt / $gte / $lt / $lte at parseFilterAST. Three prose sites still described it as the one null-comparand position the contract accepts: - memory-matcher.ts: the #13553 guard's comment — the reason a no-value COMPARAND is excluded moves from "no ruling covers it" to "refused at the door; ruled unreachable". The exclusion and every operator arm stay as they are. - memory-matcher-null-value-and-comparand.test.ts: the header — the absence stays deliberate; its reason moves to "refused, ruled 2026-09-01", the wording the file already uses for the list positions. - filter-comparand-type.ts: undefinedComparandRefusal's prescription read "Write null for the null predicate, or omit the key" — position-agnostic advice that at an ordering position produced the newly refused shape. It now names the null predicate by its complete spellings, {"$eq": null} / {"$ne": null} (measured accepted at parseFilterAST; the pair the ruling names), or omit the key. No accept/refuse behaviour moves; same envelope. The spec pin that covered the sentence matched only /undefined/, /null/, /omit/ and quoted nothing, so it stays; a second pin holds the position-safe prescription at five positions and the 500-char bound. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
…ose-repoint One commit, one PM script (scripts/pm/release-rehearsal-clone.mjs); no overlap with this branch's files. Taken so the gate family is derived from a tree that carries the current copy of that script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G4138K1EG7kQ81FNba5Kp4
📓 Docs Drift Check2 anchor(s) derived from 2 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 — 131 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 eb325810a1b2802f041e938a4a66502d60c85a9a && git checkout eb325810a1b2802f041e938a4a66502d60c85a9a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0db29473cbc43d2c678b434fa5e4bcc0422f9fd3 0eb8ddab80c276c20276311067f3355094e78e51 && git checkout -B drift-repro 0db29473cbc43d2c678b434fa5e4bcc0422f9fd3 && git merge --no-ff 0eb8ddab80c276c20276311067f3355094e78e51
node scripts/docs-audit/affected-docs.mjs --json 0db29473cbc43d2c678b434fa5e4bcc0422f9fd3 |
Clause ② in-seat review — verdict ADOPTED VERBATIM from an isolated contract-tier reviewerAdoption record by the Why an isolated reviewer rather than this seat. Downgrade-fuse reading before acting (⛔ a self-asserted tier is not a reading): Transcript tier verification (the precondition for adopting at all): 170 harness-stamped
⭐ Note for the card: the reviewer decided the open question the dev left open (F21) — whether the prescription should also name Reviewed-by: isolated contract-review subagent (no own session id) — executed inside Second-opinion contract review — PR #15464 (closes #14426), head
|
| # | Raised where | Flag | Answer |
|---|---|---|---|
| F1 | Triage | "Check for a stale quoted pin rather than assuming" | Checked: none quotes the sentence at head or merge-base; the regex pin stays green and is untouched (J9). |
| F2 | Triage / card | "⛔ 不单独修 matcher — re-pointing must not become a behaviour change" | Proven comment-only by the non-comment-line diff filter (J6). |
| F3 | Triage | "The door is not to be changed" | The door's logic is unchanged (J1); only the message text — which is the card's site 3 — moved. |
| F4 | Card / triage | service-analytics normalizer and formula fixtures out of scope |
Untouched. I also read filter-normalizer.ts:185-202 (the $gt: null → amount > '' note): it is a historical record of why an encoder was deleted, not a statement that the contract accepts the shape — not stale. |
| F5 | Unlock comment | Sites 1–2 to be re-measured at dispatch, not assumed | Re-measured here at the merge-base: both stale as filed (lines 342 / 64). |
| F6 | PM claim | File-surface fence: four files (+ changeset), stop on breach | Exactly those five paths; nothing else in the diff. |
| F7 | PM claim | filter.zod.ts reserved (#15059); $notContains arm belongs to #14079 |
Both untouched. |
| F8 | PM claim + self-correction | The stated tier reason was wrong; dispatch stands | Not a review item; no effect on the diff. Noted only that the Clause-②: no content declaration is consistent with my derivation. |
| F9 | PR body | "Clause-②: no on content; the path limb fires; PR is draft, not ready/enqueued/armed" | Consistent: no accept-set or typed-surface change; needs:contract-review present on both PR and card; PR is draft. |
| F10 | Dev claim / report H1 | The replacement spelling was measured before it was prescribed | Reproduced at source (J1, J3). |
| F11 | Report H2 | Card line numbers were stale; sites located by text | Confirmed (342/64/264 at merge-base); immaterial. |
| F12 | Report H3 | Other producers' undefined prescriptions are position-safe |
Confirmed for all three (J14); my first grep missed the analytics one because its wording is "if the null predicate was meant" — it is there at :708. |
| F13 | Report H4 / tests | 53-gate family, builds, suites, typechecks | Not re-derived wholesale. Re-run here: spec door suites (2 files / 88 pass), the three driver-memory null pins (3 files / 38 pass), the two objectql engine-seam door suites engine-comparand-type-door.test.ts + engine-filter-array-lowering.test.ts against a built dependency closure (2 files / 72 pass — these pin the find('deal'): prefix and "NOT applied" on { amount: { $gt: undefined } }, which the report did not run), reverse leg (J8), check:generated (15/15). CI at review time: 36 of 37 jobs complete — Build Core, Lint & Repo Gates, all four Type Check jobs, Check Changeset, Governed Surface Queue Guard, Dogfood gates, Temporal Conformance, Test Core 2–6 green; Test Core (1/6) still in progress (condition C2). |
| F14 | Report deviation 1 | Merged origin/main 6ed4b811a into the branch |
Verified: the PR delta against that merge-base is the five files; the merge brought one PM script. |
| F15 | Report deviations 2–4 | Second spec rebuild; first reverse attempt a no-op then redone; REST label writes | Process notes; the reverse leg and labels were independently reproduced/verified. |
| F16 | Report deviation 6 | "Dispatch said three comments; the card carries four" | Housekeeping; no action. |
| F17 | Report tests | "driver-memory's unreachability pins read the rebuilt spec dist" | Not true on the tree: driver-memory/vitest.config.ts aliases @objectstack/spec/<ns> to spec/src/<ns>/index.ts; those suites read source. Harmless (dist was built from the same source, same input hash), corrected for the record. |
| F18 | PR body / report | "469 bare"; "every wording naming $null: true measured 497–505 bare" |
468 at where.f (472 at where.owner); the 497–505 figure is not reproducible as stated (the wordings are not given) — a minimal $null: true variant measures 486/490 bare. The bound is a gradient, not a wall (J4). |
| F19 | Report recommendation | "A keeps the whole sentence inside the truncation bound so the NOT-applied tail is never cut" | False at the engine seam for ordinary paths (500/504/507 with a 28-char prefix); the tail is cut there, and "NOT applied" survives (J4). Does not change the decision in F21. |
| F20 | Report | out_of_scope_findings: [] |
One exists (J12) — condition C1. |
| F21 | Report open question | Should the sentence also name {$null: true}? (A = ruled pair only, as landed; B = add it) |
Decided: A stands. This is mine to decide: both spellings are measured ACCEPT, so it is a wording choice inside a refusal message — the spec seat's surface per triage — not a fork over the accept set; nothing to escalate. Reasons: (1) the ruling's own text (CHANGELOG d16df74; shape-door header; #5332) names $eq: null / $ne: null as the null predicate, and the author the card describes — two hops — meets nullOrderingComparandError, which prescribes exactly that pair, so the two refusals that author can see now agree; (2) neither of the report's supporting reasons survives as stated — the cross-door consistency is partial (J14) and the length argument is a gradient (J4, F18) — but neither is needed: at all 11 positions a third spelling adds nothing an author cannot already do with the two named, and it would cost the only headroom left under the engine-seam bound; (3) if the platform wants one spelling for "the null predicate" across refusal texts and docs ($eq: null vs $null: true), that is a separate card, not a rider on this one. |
| F22 | Card | Blocked-by: #14080 left as provenance |
Fine: #14080 is closed (completed) via merged PR #14425 (d16df7418). |
| F23 | Docs Drift bot | "nothing to list — not a clean bill of health" | Agrees with my grep: no hand-written page names the message (J11). |
4. Conditions
C1 — File the stale sibling before this PR is flipped ready. memory-matcher.ts:309-313 ("#13357's cells, needs-user-decision, held for the maintainer") must be recorded as a finding — a new card, or a comment on #14426 that triage routes — before flipping ready. Default is not to widen this PR (the fence is explicit). If the dispatching seat instead chooses to widen the fence to that one comment, it is a comment-only edit in a file this PR already touches and would not change this verdict, but the widening must be declared on the card first.
C2 — Test Core (1/6) must be green before ready/arm. It was still in progress at review time; every other required job was green. (Standing rule; recorded so nothing hangs on a partial read.)
No other condition. J4, J13, F17–F19 are corrections of record, not actions.
5. Verdict
PASS WITH CONDITIONS — C1 and C2 above.
Appendix — how to re-run this review
git fetch origin main claude/issue-14426-null-comparand-prose-repoint
git worktree add --detach /tmp/review-15464 0eb8ddab80c276c20276311067f3355094e78e51
cd /tmp/review-15464 && git merge-base HEAD origin/main # 6ed4b811a…
git diff --stat 6ed4b811a HEAD # 5 files, +91/-22
git diff 6ed4b811a HEAD -- packages/drivers/driver-memory/src/memory-matcher.ts | grep '^[+-]' | grep -v '^+++\|^---' | grep -v '^[+-]\s*//' # empty ⇒ comment-only
git show 6ed4b811a:packages/drivers/driver-memory/src/memory-matcher.ts | grep -n "needs-user-decision" # 312 ⇒ J12 pre-exists
pnpm install --frozen-lockfile
# accept set, prescription at 11 positions, lengths bare / +find('deal'): / +find('sys_automation_run'):
node_modules/.bin/tsx - <<'EOF'
import { parseFilterAST } from './packages/spec/src/data/filter.zod.ts';
import { normalizeFilterComparandTypes } from './packages/spec/src/data/filter-comparand-type.ts';
const v=(f)=>{try{return 'ACCEPT '+JSON.stringify(f())}catch(e){return `REFUSE ${e.code}/${e.status}`}};
for (const w of [{f:{$eq:null}},{f:{$ne:null}},{f:{$null:true}},{f:{$gt:null}},{f:{$in:[null]}},{f:{$gt:undefined}}]) console.log(JSON.stringify(w), v(()=>parseFilterAST(w)));
for (const [p,w] of [['where.f',{f:undefined}],['where.owner',{owner:undefined}],['where.owner.$gt',{owner:{$gt:undefined}}]]) {
const m=(c)=>{try{normalizeFilterComparandTypes(w,c)}catch(e){return e.message}};
console.log(p, m().length, m("find('deal')").length, m("find('sys_automation_run')").length, /Write null\b/.test(m()), m().includes('{"$eq": null}'));
}
EOF
pnpm --filter @objectstack/spec exec vitest run src/data/filter-comparand-type.test.ts src/data/filter-comparand-shape.test.ts # 88 pass
pnpm --filter @objectstack/driver-memory exec vitest run src/memory-null-ordering-comparand-unreachable.test.ts src/memory-matcher-null-value-and-comparand.test.ts src/memory-null-list-member-unreachable.test.ts # 38 pass
# reverse leg
F=packages/spec/src/data/filter-comparand-type.ts; git show 6ed4b811a:$F > $F
pnpm --filter @objectstack/spec exec vitest run src/data/filter-comparand-type.test.ts # 1 failed (the #14426 pin) | 41 passed
git checkout HEAD -- $F && git status --porcelain && git hash-object $F # clean; f449a250…
pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated # 15/15 up to date
pnpm --filter '@objectstack/objectql^...' build && pnpm --filter @objectstack/objectql exec vitest run src/engine-comparand-type-door.test.ts src/engine-filter-array-lowering.test.ts # 72 passGenerated by Claude Code
Both adopted conditions are now satisfied — gate cleared, landingPM seat
Flipping ready and arming auto-merge (squash). Not governed surface — the diff is Generated by Claude Code |
Fixes #14426
What this is
A prose and message correction with no behaviour change. PR #14425 executed the 2026-09-01 ruling on #14080 (option A): a
nullcomparand of$gt/$gte/$lt/$lteis refused atparseFilterAST(INVALID_FILTER/ 400). Three sites still described the pre-ruling state, or prescribed advice that now leads into that refusal. Each is re-pointed at the ruling; nothing that accepts or refuses moves.Clause-②: no — on content. The diff re-points two comments and rewords one prescription sentence; the accept/refuse set and the public surface are unchanged. The path limb fires anyway (
packages/spec/src/**), which is why this PR is draft, carriesneeds:contract-review, and is not flipped ready, enqueued, or armed by this seat.The three sites (re-located by text on
origin/main, not by the card's line numbers)packages/drivers/driver-memory/src/memory-matcher.ts— the driver-memory's reference matcher puts a NULL-VALUED row inside$gt/$gte/$lt/$lteon a NUMERIC column —nullcoerces to0, and the live mingo path excludes it #13553 guard's comment (the block aboveif (value === null && ORDERING_OPERATORS.has(op) …). The reason a no-value COMPARAND is excluded from the guard moves from "no ruling covers it —$gt: nullis the one null-comparand position the contract still ACCEPTS" to "refused at the door, ruled 2026-09-01; constructively unreachable through the compile face; ⛔「不单独修 matcher(死代码)」". The exclusion itself stays, and no operator arm is touched (the$notContainsarm belongs to driver-memory's reference matcher answers$notContainsNO for every valued NON-STRING row — the live mingo path answers YES #14079, which is not touched here).packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts— the header paragraph on the ordering position. The absence stays deliberate; its reason moves to "refused, ruled 2026-09-01 (option A)", the same wording the header already uses for the list positions, naming the negative pinmemory-null-ordering-comparand-unreachable.test.ts. No test body changes.packages/spec/src/data/filter-comparand-type.ts—undefinedComparandRefusal. Its prescription read "Write null for the null predicate, or omit the key". That sentence is emitted at every comparand position, and "write null" is position-agnostic: followed at{ $gt: undefined }it produced{ $gt: null }, refused one door over since 2026-09-01; followed at an$inmember it produced$in: [null], refused since 2026-08-31. The card counted four wrong positions; the list-member positions make it seven. It now reads "Write the null predicate — {"$eq": null} / {"$ne": null} — or omit the key." Same envelope, same path, same accepted-set and NOT-applied sentences.H1 — the replacement spelling was measured before it was prescribed
The card suggested prescribing
$eq: null/$null: true. Measured atparseFilterASTon the builtdist(node,@objectstack/spec/data), before writing:{f: null}·{f: {$eq: null}}·{f: {$ne: null}}{f: {$null: true}}·{f: {$null: false}}·{f: {$exists: true}}·{f: {$exists: false}}['f', 'is_null']→{f: {$null: true}}·['f', 'is_not_null']→{f: {$null: false}}{f: {$gt: null}}·$gte·$lt·$lteINVALID_FILTER/ 400{f: {$in: [null]}}·{f: {$between: [null, 1]}}INVALID_FILTER/ 400{f: undefined}·{f: {$eq: undefined}}·{f: {$gt: undefined}}·{f: {$in: [undefined]}}INVALID_FILTER/ 400 — the TYPE door's sentence, at every positionSo
$null: trueis a real spelling. The sentence nevertheless names only the ruled pair,{"$eq": null}/{"$ne": null}, for two measured reasons: (a) that pair is what the sibling ordering refusal (nullOrderingComparandError,filter-comparand-shape.ts) prescribes, byte-for-byte the same spelling, so the two doors give one answer; (b) the message sits near the 500-char client bound (#5423) — bare it was 444 chars; every wording that also names$null: truemeasured 497–505 bare, i.e. over or within 3 chars of the bound once a caller prepends a context. The chosen wording is 469 bare.Pins
filter-comparand-type.test.tsdid not quote the old sentence (H3): its existing pin matches only the regexesundefined,null,omit, so it stays green as-is and is untouched. One pin is added beside it: at five positions (where.owner,.$eq,.$gt,.$lte,.$in[0]) the refusal carriesINVALID_FILTER/ 400, the path, both ruled spellings,omit the key, no bareWrite nullinstruction, and stays under 500 chars. Reverse-verified against the BASE sentence: mutation proved on disk (old-sentence count 1 / new 0), the pin went RED onwhere.owner(expected … to contain '{"$eq": null}', 1 failed / 41 passed), restore proved bygit diff HEADempty andgit hash-objectequal to the HEAD blobf449a250….Not touched, on purpose
packages/spec/src/data/filter.zod.ts— reserved for [finding]filter.zod.ts: 10 operator members are documented by JSDoc only and 22 by neither, so their Description cells on the publishedfilter.mdxrender empty — the file-wide sweep #14048 deferred, now measured #15059.memory-matcher.ts;assertFilterConditionShapein driver-memory.service-analytics's normalizer andformula'smatchesFilter— PR feat(spec): refuse a null comparand in the ordering positions — $gt / $gte / $lt / $lte (#14080) #14425's body records them as the honest boundary the refusal does not widen. Their ownundefinedprescriptions (driver-sql,driver-turso,service-analytics) were read and are already position-safe: each names complete shapes ({ "FIELD": null }or{ "FIELD": { "$null": true } }), never "write null" into the position. No finding there.Changeset
@objectstack/spec: patch— a published refusal message changes wording; nothing else publishes (driver-memory's diff is comments only).Verification (final head cited per run)
Final head
0eb8ddab8=54041b534(the change) + a merge oforigin/main6ed4b811a(one PM script, no overlap). Every exit code captured after redirect; verdict lines quoted from the gates themselves.scripts/pm/os-verify-lock.sh:pnpm --filter '@objectstack/driver-memory^...' build, thenpnpm --filter @objectstack/spec buildtwice (the second after the reverse leg re-wrotesrcmtimes): eachVERDICT command-exit 0;check-dts-emitted: 34/34; build input hash04324463ad9d8c2c…both times (same sources).pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/data/filter-comparand-type.test.ts src/data/filter-comparand-shape.test.tson0eb8ddab8:Test Files 2 passed (2) · Tests 88 passed (88), exit 0.pnpm --filter @objectstack/driver-memory exec vitest run --maxWorkers=2(whole package; its unreachability pins read the rebuilt specdist) on0eb8ddab8:Test Files 40 passed (40) · Tests 1036 passed (1036), exit 0.tsc --noEmitexit 0 (1068 files — ⚠ that program contains no*.test.ts, so it measures nothing about the new pin);check:scripts-typecheckexit 0;check:test-typecheckexit 0 (54 file(s) / 261 error(s) / 145 pinned signature(s) held), andtsc -p tsconfig.test.json --listFileslistsfilter-comparand-type.test.tswith 0 errors naming either edited spec file — that program is the one that measures the pin. driver-memorytsc --noEmitexit 0 with both edited files listed.pnpm --filter @objectstack/spec check:generatedon0eb8ddab8after the rebuild:✓ All 15 generated artifacts are up to date.exit 0. A first run on the merged head reportedcheck:api-surfacestale in the gate's own words —dist/**/*.d.ts is OLDER than src— an mtime effect of the reverse leg's two checkouts (content identical by hash); rebuilt and re-run rather than touched.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackat0eb8ddab8(no STALE TREE warning; list identical to the pre-merge derivation at54041b534, and to a derivation on the stale container checkout it replaced). 52 of the 53 commands under "Local gates for this card" exit 0 — among themcheck:api-surface,check:authorable-surface,check:docs,check:liveness,check:exported-any,check:dual-source-exports,check:where-matcher,check:driver-conformance,check:cross-package-test-inputs,check:test-source-alias,check:type-source-resolution,check:keyed-text-bounds,check:changeset-gate-self-tests,check:objectui-changeset, the ADR-0087 / empty-changeset / no-major self-tests, andcheck:doc-formula-expressionsonce its prerequisites@objectstack/formulaand@objectstack/lintwere built (9 @example(s) judged clean across 1159 packages/spec/src files;14 predicate(s) … judged clean). The 53rd,check:dual-build-cjs-loads, is NOT MEASURED here by its own text (Run pnpm build first. ⛔ This is NOT a pass: nothing was measured— it needs every workspace package'sdist, a whole-farm run CI owns).check:nul-bytesexit 0. A further 70 commands from the derivation's other sections were run opportunistically; every non-zero one is NOT MEASURED by its own text — CI-only wiring ($RUNNER_TEMP,${{ matrix.shard }},PR_NUMBER), a whole-workspacedistprerequisite (check:published-readme-exports),check:react-declaration-parity(needs objectui's manifest, by design),check-half-states.mjs --format=markdown(network-bound, hit this run's 300s cap; itspnpm check:pm-half-statesform exit 0), and 17 workflow step NAMES from the derivation's Residue section that are not commands. None is a red.scripts/check-partof-closing-keyword.mjsrun withPR_BODYset to this body:✓ this PR carries no Part-of/closing-keyword contradiction, exit 0.pnpm lint): each of the four edited files returns a non-empty--print-config(in the population, not ignored);eslint --no-inline-config --format jsonover them: 4 files, 0 errors, 0 warnings, exit 0; invariance:eslint.config.mjsstates it never enables type-aware linting for any file, so this diff cannot move an untouched file's verdict.grep -naPover the four files and the changeset — no hits.scripts/pm/git-history.mjs count --days=14 --ref=origin/main(answered, not refused):filter-comparand-type.ts1 ·memory-matcher.ts3 · the test header file 2 ·filter-comparand-type.test.ts1.🤖 Generated with Claude Code
Generated by Claude Code