Skip to content

fix(spec): position-safe undefined-comparand prescription; re-point driver-memory's null-comparand prose at the 2026-09-01 ruling (#14426) - #15464

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-14426-null-comparand-prose-repoint
Sep 4, 2026
Merged

os-sales merged 2 commits into
mainfrom
claude/issue-14426-null-comparand-prose-repoint

Conversation

@os-sales

@os-sales os-sales commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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 null comparand of $gt / $gte / $lt / $lte is refused at parseFilterAST (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, carries needs: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)

  1. packages/drivers/driver-memory/src/memory-matcher.ts — the driver-memory's reference matcher puts a NULL-VALUED row inside $gt/$gte/$lt/$lte on a NUMERIC column — null coerces to 0, and the live mingo path excludes it #13553 guard's comment (the block above if (value === null && ORDERING_OPERATORS.has(op) …). The reason a no-value COMPARAND is excluded from the guard moves from "no ruling covers it — $gt: null is 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 $notContains arm belongs to driver-memory's reference matcher answers $notContains NO for every valued NON-STRING row — the live mingo path answers YES #14079, which is not touched here).
  2. 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 pin memory-null-ordering-comparand-unreachable.test.ts. No test body changes.
  3. 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 $in member 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 at parseFilterAST on the built dist (node, @objectstack/spec/data), before writing:

input verdict
{f: null} · {f: {$eq: null}} · {f: {$ne: null}} ACCEPT
{f: {$null: true}} · {f: {$null: false}} · {f: {$exists: true}} · {f: {$exists: false}} ACCEPT
['f', 'is_null'] → {f: {$null: true}} · ['f', 'is_not_null'] → {f: {$null: false}} ACCEPT
{f: {$gt: null}} · $gte · $lt · $lte REFUSE INVALID_FILTER / 400
{f: {$in: [null]}} · {f: {$between: [null, 1]}} REFUSE INVALID_FILTER / 400
{f: undefined} · {f: {$eq: undefined}} · {f: {$gt: undefined}} · {f: {$in: [undefined]}} REFUSE INVALID_FILTER / 400 — the TYPE door's sentence, at every position

So $null: true is 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: true measured 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.ts did not quote the old sentence (H3): its existing pin matches only the regexes undefined, 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 carries INVALID_FILTER / 400, the path, both ruled spellings, omit the key, no bare Write null instruction, 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 on where.owner (expected … to contain '{"$eq": null}', 1 failed / 41 passed), restore proved by git diff HEAD empty and git hash-object equal to the HEAD blob f449a250….

Not touched, on purpose

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 of origin/main 6ed4b811a (one PM script, no overlap). Every exit code captured after redirect; verdict lines quoted from the gates themselves.

  • Builds under scripts/pm/os-verify-lock.sh: pnpm --filter '@objectstack/driver-memory^...' build, then pnpm --filter @objectstack/spec build twice (the second after the reverse leg re-wrote src mtimes): each VERDICT command-exit 0; check-dts-emitted: 34/34; build input hash 04324463ad9d8c2c… 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.ts on 0eb8ddab8: 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 spec dist) on 0eb8ddab8: Test Files 40 passed (40) · Tests 1036 passed (1036), exit 0.
  • Typecheck: spec tsc --noEmit exit 0 (1068 files — ⚠ that program contains no *.test.ts, so it measures nothing about the new pin); check:scripts-typecheck exit 0; check:test-typecheck exit 0 (54 file(s) / 261 error(s) / 145 pinned signature(s) held), and tsc -p tsconfig.test.json --listFiles lists filter-comparand-type.test.ts with 0 errors naming either edited spec file — that program is the one that measures the pin. driver-memory tsc --noEmit exit 0 with both edited files listed.
  • pnpm --filter @objectstack/spec check:generated on 0eb8ddab8 after the rebuild: ✓ All 15 generated artifacts are up to date. exit 0. A first run on the merged head reported check:api-surface stale 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.
  • Gate family derived on this tree: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at 0eb8ddab8 (no STALE TREE warning; list identical to the pre-merge derivation at 54041b534, 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 them check: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, and check:doc-formula-expressions once its prerequisites @objectstack/formula and @objectstack/lint were 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's dist, a whole-farm run CI owns). check:nul-bytes exit 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-workspace dist prerequisite (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; its pnpm check:pm-half-states form 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.mjs run with PR_BODY set to this body: ✓ this PR carries no Part-of/closing-keyword contradiction, exit 0.
  • Narrowed lint (CI owns pnpm lint): each of the four edited files returns a non-empty --print-config (in the population, not ignored); eslint --no-inline-config --format json over them: 4 files, 0 errors, 0 warnings, exit 0; invariance: eslint.config.mjs states it never enables type-aware linting for any file, so this diff cannot move an untouched file's verdict.
  • Control characters: grep -naP over the four files and the changeset — no hits.
  • Reverse verification of the new pin: see Pins above (RED on the BASE sentence, restore hash-proved).
  • Churn, scripts/pm/git-history.mjs count --days=14 --ref=origin/main (answered, not refused): filter-comparand-type.ts 1 · memory-matcher.ts 3 · the test header file 2 · filter-comparand-type.test.ts 1.

🤖 Generated with Claude Code


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0db29473cbc43d2c678b434fa5e4bcc0422f9fd3 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from eb325810a1b2802f041e938a4a66502d60c85a9a — the merge of head 0eb8ddab80c276c20276311067f3355094e78e51 into base 0db29473cbc43d2c678b434fa5e4bcc0422f9fd3, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

os-sales commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Clause ② in-seat review — verdict ADOPTED VERBATIM from an isolated contract-tier reviewer

Adoption record by the domain:spec PM seat, session session_01G4138K1EG7kQ81FNba5Kp4 (os-sales), 2026-09-04T17:34Z. Everything below the rule is the reviewer's document, reproduced word for word. The seat's only legal actions are to adopt it verbatim or to void it entirely; it may not edit, trim or soften it, and has done none of those.

Why an isolated reviewer rather than this seat. Downgrade-fuse reading before acting (⛔ a self-asserted tier is not a reading): get_session on this seat returns session_context.model = claude-opus-5 and last_served_model = claude-opus-5 — below CONTRACT_REVIEW_TIER (claude-fable-5-1, read from scripts/pm/dispatch-gates.mjs:8949). So this seat ⛔ may not self-judge or clear the gate. The verdict was produced by a context-isolated subagent at tier, briefed adversarially and fed only the card, the existing rulings and the PR — ⛔ not this seat's dispatch rulings and ⛔ not its own conclusions, whose inclusion would have destroyed the independence the second opinion exists to buy.

Transcript tier verification (the precondition for adopting at all): 170 harness-stamped model fields across the reviewer's transcript, all claude-fable-5-1, no other value present. No fallback stop-reason (end_turn ×1, tool_use ×5). The one textual match for fallback|overloaded|degraded_model|model_unavailable was inspected and is repository prose the reviewer quoted — the docs-drift bot's "Coarse fallback" line — ⛔ not a downgrade marker. Verification passes ⇒ adoption is legitimate.

⚠️ Independence, stated honestly rather than claimed. The reviewer's own header says it: the implementing dev and the reviewer are both subagents of this one seat session, so independence here is by withheld context, not by a separate session id. The reviewer was given a detached worktree at the PR head and re-derived every number itself. The substantive limitation is real and is recorded rather than papered over.

⚠️ One mechanical transformation, disclosed: HTML entities introduced by transport (&lt;, &gt;, &amp;) were restored to the <, >, & characters the reviewer wrote. No other character was changed. Angle-bracket fragments can be eaten by GitHub's sanitizer, so this comment is read back after posting; the transcript is the authoritative copy.

⚠️ Status: the gate is NOT cleared by this comment, and the PR is NOT flipped ready. The verdict is PASS WITH CONDITIONS.

⭐ Note for the card: the reviewer decided the open question the dev left open (F21) — whether the prescription should also name {$null: true}. It ruled A stands (the ruled $eq: null / $ne: null pair only), with its reasoning, and explicitly recorded that neither of the report's two supporting arguments survives as stated. That decision is the reviewer's, adopted with the rest; ⛔ the seat did not make it and does not modify it.


Reviewed-by: isolated contract-review subagent (no own session id) — executed inside session_01G4138K1EG7kQ81FNba5Kp4, in a dedicated detached worktree at the PR head, without sight of the dispatching seat's rulings.

Second-opinion contract review — PR #15464 (closes #14426), head 0eb8ddab8

Posture. Adversarial. The card, the PR body and the report were read as claims; every claim below was re-derived from the tree (origin/main and the PR head) or re-run. The PR's true base is the merge-base 6ed4b811a, not main's tip 0db29473c — the diff was re-derived against that: 5 files, +91/−22 (.changeset/undefined-comparand-prescription-position-safe.md, packages/spec/src/data/filter-comparand-type.ts, packages/spec/src/data/filter-comparand-type.test.ts, packages/drivers/driver-memory/src/memory-matcher.ts, packages/drivers/driver-memory/src/memory-matcher-null-value-and-comparand.test.ts); git diff 6ed4b811a 0eb8ddab8 | sha256sum = 1929ec37a01c…. The merge commit's own delta (scripts/pm/release-rehearsal-clone.mjs) is main's, not this card's.

Premise re-verified on the tree. All three sites were stale at the merge-base exactly as filed — git show 6ed4b811a:…memory-matcher.ts | grep -n "position the contract still" → 342; the test header → 64; …filter-comparand-type.ts | grep -n "Write null for the null predicate" → 264. The 2026-09-01 ruling exists in the tree as executed: packages/spec/CHANGELOG.md (d16df74, #14080, "option A", "$eq: null / $ne: null ARE the null predicate"), the shape-door header in filter-comparand-shape.ts ("Refused BY RULING, 2026-09-01"), and assertListComparandShapes refusing ORDERING_COMPARAND_OPERATORS × null — run inside parseFilterAST (filter.zod.ts:2116) and at the engine seam (objectql/src/engine.ts:700).


1. Derived judgments — every accept-set, public-surface and guidance change, one verdict each

J1 — Accept set at parseFilterAST: UNCHANGED. No executable line of either door changed (filter-comparand-shape.ts is untouched; in filter-comparand-type.ts only a docblock and one template string moved — the - NOT_APPLIED, line is the concatenation folded into the template, and the emitted tail is byte-identical: endsWith(NOT_APPLIED) === true). The PR's H1 table was re-measured on the head's source with tsx: ACCEPT {f: null}, {$eq: null}, {$ne: null}, {$null: true|false}, {$exists: true|false}, ['f','is_null']→{$null:true}, ['f','is_not_null']→{$null:false}, ['f','=',null]→{f:null}, ['f','!=',null]→{$ne:null}; REFUSE INVALID_FILTER/400 for $gt|$gte|$lt|$lte: null (also via the authoring array ['f','>',null]), $in: [null], $nin: [null], $between: [null, 1], and every undefined comparand. Verdict: reproduces; nothing widened, nothing narrowed.

J2 — Refusal envelope, code, status, path, accepted-set sentence, NOT-applied sentence: UNCHANGED. Verified on the live message at every position (INVALID_FILTER, 400, where.<path>, a string, number, bigint, boolean, null or Date, and the NOT_APPLIED tail present). Verdict: as claimed.

J3 — The only public-surface change: the wording of undefinedComparandRefusal's prescription. Old: "Write null for the null predicate, or omit the key." New: "Write the null predicate — {"$eq": null} / {"$ne": null} — or omit the key." This is a falsifiable claim about the accept set, so it was checked against the door, not the prose: the sentence is emitted at 11 measured positions (where.f, .$eq, .$ne, .$gt, .$gte, .$lt, .$lte, .$in[0], .$nin[0], .$between[0], .$contains); at every one it names both spellings and contains no bare Write null\b; following it ({f: {$eq: null}}, {f: {$ne: null}}) is ACCEPTED, and following the old advice at $gt / $in[0] is REFUSED one door over — the defect the card describes, reproduced. The docblock's claim that the pair is "the pair the 2026-09-01 ruling names" matches the CHANGELOG and the shape-door header. Verdict: prescribes only spellings the contract accepts; position-safe as claimed; the card's four positions are indeed seven operator positions once $in/$nin/$between members are counted.

J4 — Message length: a caveat the PR body understates. Bare, the message grew by 24 chars: where.f 444 → 468 (the report's "469" does not reproduce; 472 at where.owner, 476 at .$gt, 479 at .$in[0], 499 at where.sys_automation_run_status.$in[0]). The bound that matters on the wire is CLIENT_MESSAGE_MAX = 500 in packages/rest/src/error-response.ts:70,107-111 (message.length < 500 or truncate to 499 + …), applied to the message with the engine's prefix (engine.ts:725 passes `${operation}('${object}')` → find('deal'): +14, find('sys_automation_run'): +28). With the 28-char prefix the new message measures 496 / 500 / 504 / 507 / 527 at the five paths above — i.e. it now crosses the truncation threshold at ordinary paths (where.owner, where.owner.$gt) where the old message did not (476 / 480). What is cut is the end of the NOT_APPLIED sentence: at 504 the wire tail reads …UNFILTERED result…; at 527 …would have returne…. "NOT applied" itself survives at every measured path, and the design (preview()'s docblock: "everything load-bearing is front-loaded") is respected. Every existing pin bounds the bare message (spec filter-comparand-type.test.ts:110; the objectql engine-filter-array-lowering.test.ts:431 pin measures only the three list shapes), so nothing goes red. Verdict: acceptable, recorded as a headroom loss, not a defect — but the report's sentence "A keeps the whole sentence inside the truncation bound so the NOT-applied tail is never cut" is false at the engine seam and must not be carried forward as a fact.

J5 — Docblock above undefinedComparandRefusal (new). Every referent checked: $in/$nin/$between null members refused 2026-08-31 (nullListMemberError, nullRangeBoundError; CHANGELOG e398863, option C); the ordering four refused 2026-09-01. Verdict: true on the tree.

J6 — memory-matcher.ts #13553 guard comment (site 1): comment-only, true on the tree. git diff 6ed4b811a HEAD -- …memory-matcher.ts | grep '^[+-]' | grep -v '^[+-]\s*//' is empty — no operator arm, no guard condition, no $notContains arm (#14079) moved. Named referents all exist: assertListComparandShapes inside parseFilterAST and at the engine seam (above); negative pin memory-null-ordering-comparand-unreachable.test.ts (exists, passes); the package's own assertFilterConditionShape (filter-refusal.ts:626) carries no ordering-null rule (grep -n "ordering\|\$gt" filter-refusal.ts → none in code). Verdict: re-pointed at the ruling without a behaviour change, as the card demanded.

J7 — Test header (site 2): header-only, mirrors the list-position paragraph it sits beside. Changed lines are all * comment lines; no test body changed; the paragraph now reads the same shape as the 2026-08-31 paragraph above it (refused at the validation entrance, negative pin named, cells stay unasserted for the ruling's own reason). Verdict: true on the tree.

J8 — The new pin passes for the right reason. Reverse leg reproduced: with the door reverted to 6ed4b811a (old-sentence count 1 / new 0 on disk), vitest run src/data/filter-comparand-type.test.ts → 1 failed | 41 passed, the failure being exactly the new pin at where.owner (expected … to contain '{"$eq": null}'); restore proved by empty porcelain and git hash-object = HEAD blob f449a250749daf48067cdad0f86edf7bd12277d8. The pin asserts the falsifiable content (both spellings, omit the key, no bare Write null\b, envelope, path, bare < 500) at 5 of the 11 positions; since the sentence is one template, the sample is sufficient. Verdict: red on revert, green only with the change.

J9 — The pre-existing pin was correctly left alone. It matches only /undefined/, /null/, /omit/ and was green on both base and head; touching it would have been cosmetic. No test anywhere quotes the old sentence (grep -rn "Write null for the null predicate\|or omit the key" packages --include=*.test.ts → only the new pin's own comment). Verdict: H3 holds.

J10 — Changeset: @objectstack/spec: patch, and no changeset for driver-memory. Correct on both counts (see §2).

J11 — Nothing else publishes or documents the sentence. No content/docs page quotes it (grep -rn "cannot be told apart from an\|MATCH EVERYTHING\|omit the key" content skills → none; the Docs Drift bot's "nothing to list" agrees); no cross-package pin quotes it; pnpm --filter @objectstack/spec check:generated at the head after a rebuild → ✓ All 15 generated artifacts are up to date, input hash 04324463ad9d8c2c (same as the report). Verdict: no hidden surface.

J12 — FOUND: a sibling left stale where its neighbour was corrected (outside the fence, pre-existing, unlisted). packages/drivers/driver-memory/src/memory-matcher.ts:309-313 (head), the $eq-exemption comment thirty lines above the hunk this PR fixes, still reads: "$in: [null] / $nin: [null] … are #13357's cells, needs-user-decision, held for the maintainer." That is false on the tree — #13357 was ruled 2026-08-31 (option C) and the shapes are refused at the door, as the same package's test header, the shape-door header and the CHANGELOG all state. It is present at the merge-base (line 312), so this PR did not introduce it, and it is outside the card's fence ("the #13553 guard's comment only"); but the report declares out_of_scope_findings: [] and this is exactly the pattern a contract review is asked to catch. Verdict: not a defect of this PR; must be filed (condition C1).

J13 — Observation, no action: the prescription names object-form spellings only. The array authoring form reaches the same sentence (parseFilterAST(['f','=',undefined]) → where.f; ['f','>',undefined] → where.f.$gt); an array author must translate {"$eq": null} to ['f','=',null] or ['f','is_null'] (both ACCEPT). The old advice happened to work verbatim for = in the array form; the new one does not name an array spelling. No accept-set regression and no author is broken; the TYPE door's sentences have never carried the #5346/#5348 "authoring spellings" clause the shape door's do. Recorded as a possible follow-up, not required.

J14 — The "two doors give one answer" argument is partial. True against the ordering sibling (nullOrderingComparandError prescribes {"$eq": null} / {"$ne": null} byte-for-byte). But the list-member refusals in the same shape door prescribe {"$null": true} / {"$null": false} (filter-comparand-shape.ts:337-338, 367-368), the three other undefined producers prescribe { "f": null } or { "f": { "$null": true } } (driver-sql/sql-driver.ts:3302, driver-turso/remote-transport.ts:3514, service-analytics/strategies/filter-normalizer.ts:708 — all position-safe, as the report says), and the docs teach $null: true as "the null predicate" in 7 files vs $eq: null in 3. The platform carries two spellings before and after this PR; this PR aligns the undefined sentence with the sibling the card is about. Verdict: not a defect; bears on the open question (§3).

2. Semver levelling

Declared: @objectstack/spec: patch. I would set: patch. Reasons: (i) AGENTS.md §3 — "a bug fix in a released package takes a patch changeset"; this is a fix to published refusal text with no accept-set or envelope movement (J1–J3, reproduced). (ii) The message string is not on the typed API surface (check:api-surface and all four Type Check jobs green; no .d.ts changes). (iii) Calibration: the behaviour change this prose follows (PR #14425) shipped as minor because it narrowed the accept set under the launch-window breaking convention; a wording repair to its neighbour is strictly below that. (iv) driver-memory's diff is comments only — it publishes nothing observable, so no changeset there is correct, and skip-changeset is rightly not applied because spec does publish. Check Changeset and the changeset self-tests are green in CI. No repo rule (AGENTS.md, ADR-0112, the #5346/#5348 wording contract) levels a message-wording change above patch.

3. Boundary flags — every flag, caveat, narrowing, deviation and open question, answered

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

Generated by Claude Code

os-sales commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Both adopted conditions are now satisfied — gate cleared, landing

PM seat domain:spec, session session_01G4138K1EG7kQ81FNba5Kp4 (os-sales), 2026-09-04T17:36Z. Follow-up to the adoption record above, which said the gate stays until the shard concludes.

needs:contract-review removed from both carriers (this PR and #14426), each with a read-back diffed against the pre-write set: nothing else was added or stripped.

Flipping ready and arming auto-merge (squash). Not governed surface — the diff is packages/spec/src/data/**, packages/drivers/driver-memory/src/** and a changeset. On MERGED: strip pm:dispatched from #14426, probe origin/main for the new prescription sentence with the pre-merge tip as control, landing note on the card.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants