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

fix(spec): position-safe undefined-comparand prescription; re-point driver-memory's null-comparand prose at the 2026-09-01 ruling (#14426)#15464
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.tsundefinedComparandRefusal. 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 listnot 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 0db29473cbc43d2c678b434fa5e4bcc0422f9fd3packageMentionDocs.

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-5below 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.ts1 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: nullamount > '' 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

@os-sales
os-sales marked this pull request as ready for review September 4, 2026 17:36
@os-sales
os-sales enabled auto-merge September 4, 2026 17:36
@os-sales
os-sales added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 40a44b9 Sep 4, 2026
43 checks passed
@os-sales
os-sales deleted the claude/issue-14426-null-comparand-prose-repoint branch September 4, 2026 18:03
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

2 participants