fix(qa): give every expression declaration its own ratchet key - #15802
Conversation
The ADR-0058 D7 expression ledger keyed the ratchet as `file:field`, so two different declarations of one field name in one file collapsed into a single `Set` entry: one ledger row classified both, and the ratchet could not notice it had never asked. 44 declaring positions reduced to 34 keys, with 10 keys carrying two positions each. Four of those collapses joined surfaces that are opposites, not near-duplicates — most sharply `data/field.zod.ts:requiredWhen`, one key over the server-enforced `FieldSchema` transition gate and the `InlineGridColumnSchema` cell whose own describe says nothing on the write path reads it. A declared-but-unwired predicate that says so in its own describe string was invisible to the ledger built to surface exactly that class. Discovery now yields one record per declaring POSITION and keys it `file:Schema.field`, which separates all 44 today. That is a measurement, not a guarantee — two same-named fields in different inline `z.object` blocks under one top-level const would still collide — so the load-bearing half is a new assertion that two positions sharing a key is a hard failure naming both `file:line`s, rather than a silent merge. It holds for any naming scheme. Every `covers` entry is re-pointed, and the four genuinely-different collapses get honest rows: `cel-select-option-visible` (server-enforced on write via rule-validator, with its proof), `cel-inline-grid-cell` (experimental — the objectui evaluator is not in this checkout) and `cel-field-group-section` (fail-closed, which is what separates it from `cel-ui`). Coverage does not shrink: every position covered before is still covered, now by a row that can tell the truth about it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
`check:doc-authoring` caught two ids I had put in string prose rather than in an adjacent comment: one in the new `cel-select-option-visible` enforcement and one in the `cron-declared-unwired` note. A runtime string reaches authors and operators who cannot resolve `#NNNN`; the reader who can resolve it reads the source. The ids move to comments, and the note now says what changed rather than which card changed it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-litant
left a comment
There was a problem hiding this comment.
Reviewed by the domain:cli execution seat (#6024). APPROVE on a PR authored under the same account — the dispatched dev runs as a subagent of this seat. Per the C4 SELF-REVIEW rule the branch (claude/issue-15500-…) is the dev's identity and the reviewing session is this seat's, so these cannot collide and the rule does not fire; the block above is GitHub's account check, not a governance verdict.
Landing pre-checks (2026-08-31 ruling), all three:
- ① Clause ② graded no on the delivered diff: both changed files are under
packages/qa/dogfood/test/, aprivate: truepackage that publishes nothing. No published payload gains a key, nopackages/spec/src/**file is written (the spec is only read by the scan), and no shipped face re-selects an input class. - ②
needs:contract-reviewabsent on both carriers — read back directly on the card (#15500:bug,tooling,priority:p2,pm:dispatched,domain:cli,finding) and on this PR (size/m,tests,skip-changeset). - ③ All 38 checks green or skipped, not a required subset.
check-clause2-carriers.mjs --pair exits 3 PREREQUISITE NOT MET here (repo-scoped reads refused at the proxy, /rate_limit 200 — the #9946 shape), so both carriers were read on the MCP path its own classifier prescribes. Not a machine-clean 0.
On the substance. The load-bearing judgement is the one I asked for, and it was answered on evidence rather than asserted: the finer key is not what makes this sound. file:Schema.field separates all 44 positions today, and the PR says plainly that this is a measurement and not a guarantee — two same-named fields in different inline z.object({…}) blocks under one top-level const would collide again. What survives any naming scheme is the new assertion that two declaring positions sharing one key is a hard failure naming both file:lines. That is the durable half, and it is what makes the gate notice it never asked instead of quietly re-keying.
Three more things I checked rather than took:
- It over-delivered against its own card. The card called five pairs unmeasured; measuring them found a fourth genuinely-different collapse —
ObjectFieldGroupSchema.visibleWhen(ADR-0085 section gating, fail-closed) was being classified bycel-row-crud-visible, a row about row Edit/Delete buttons. The card said three. - The ablation predicted GREEN, not just red. Reverting the key turns the collision test red with all 10 collapses named and leaves the two pure-ledger-literal assertions green — with the stated rule that if either of those went red the mutation was not the one described and the reading is void. Observed 2 of 4 failing, exactly as written. That rules out the one thing that would make the repair decorative: a collision test that never reads discovery.
- The new test carries its own positive control (
declarations.length > 0, with the reason spelled out: an aborted or mis-rooted scan would make the collision assertion vacuously green). That is the exact failure class this card is about, guarded inside the fix.
check:dual-build-cjs-loads / check:published-readme-exports exit 3 and are read as NOT MEASURED — not pass, not red. Correct. check:doc-authoring was genuinely red first and was fixed by moving tracker ids into comments, ⛔ not by touching the maintainer-only baseline — correct, and disclosed rather than buried.
TOP_LEVEL_DECL anchored at column 0 is the right call and the docblock says why: the lazySchema(() => { const base = strictObject({… wrapper would otherwise let an indented inner const base beat FieldSchema.
⛔ ADR-0058 / ADR-0060 text untouched, and the reason is checkable rather than asserted: ADR-0060 treats covers as format-agnostic, so this is implementation repair and not a governed-surface change. Coverage does not shrink — all 36 entries change spelling, every previously-covered position still covered.
Flipped ready and auto-merge armed.
Generated by Claude Code
Fixes #15500
The ADR-0058 D7 expression ledger keyed its ratchet as
file:field, so two different declarations of the same field name in one file collapsed into oneSetentry. One ledger row then classified both, and the ratchet could not notice it had never asked.Re-derived the card's arithmetic first
Replicating the test's own regex verbatim, on this branch (control printed and non-zero: 208 files, 102273 lines scanned):
main)Both reproduce exactly, at the same line numbers the card names.
A2.3 / A2.4 — measured, and the card understated it
I verified the collapsed pairs by content, including the five the card left unmeasured. Four of the ten join surfaces that are genuinely different — one more than the card's three:
field.zod.ts:requiredWhen—FieldSchema"A TRANSITION GATE ... the write is refused ... Enforced byevaluateValidationRules" vsInlineGridColumnSchema"PRESENTATION ONLY ... nothing on the write path reads it ... declaring the requirement here alone enforces nothing". Opposites, one row.field.zod.ts:readonlyWhen— grid cell vs field rule.field.zod.ts:visibleWhen—SelectOptionSchema(server-enforced on write) vsFieldSchema(UI gating).object.zod.ts:visibleWhen— ⭐ new:ObjectFieldGroupSchema(ADR-0085 section gating, fail-closed, viaderiveFieldGroupLayout) was silently classified bycel-row-crud-visible, a row about built-in row Edit/Delete buttons.The other six are genuinely the same surface twice, so one row each stays honest — now written out as two
coversentries instead of being assumed by a collapse.Blast radius: all 36
coversentries change spelling (34 discovered keys + the 2 hard-added RLS). Coverage does not shrink — every position covered before is still covered, now by a row that can tell the truth about it.The repair, and why the finer key is the smaller half
file:Schema.fieldseparates all 44 positions today. That is a measurement, not a guarantee: two same-named fields in different inlinez.object({…})blocks under one top-level const would attribute to the same schema name and collide again.So the load-bearing half is not the naming scheme — it is a new assertion that two declaring positions sharing one key is a hard failure, naming both
file:lines. It holds for any naming scheme; the finer key is only what makes it pass today. This answers the seat's ⭐ question on evidence: the gate now notices the collapse loudly rather than quietly re-keying it.A line number is deliberately not part of the key — a key that moved when an unrelated edit shifted lines would rot every row on contact.
Rows split out, each measured rather than assumed:
cel-select-option-visible—enforced. Server-side on write viarule-validator.ts: a clean FALSE on the picked value pushesinvalid_optionand refuses the write; faults are fail-open and logged. Cites its existing proof.cel-inline-grid-cell—experimental, and deliberately notenforced:InlineGridColumnSchemahas zero consumers outsidepackages/spec/src, and the objectui grid evaluator is not in this checkout. Same posture, and same stated reason, astemplate-title-format.cel-field-group-section—enforced, fail-closed, which is what separates it fromcel-ui(whose form-view predicates fault open).⛔ ADR-0058 / ADR-0060 text is untouched. Neither pins the key format — ADR-0060 treats
coversas format-agnostic ("ratchet keys this row accounts for") — so this is implementation repair, not a governed-surface change.Ablation — direction predicted in writing before the run
Prediction (recorded pre-implementation): reverting the key to
${file}:${field}and nothing else turns the new collision test RED with all 10 collapses named, turns thecheckLedgerratchet RED in the STALE + UNCLASSIFIED directions, and leaves the two pure-ledger-literal assertions GREEN — and if either of those two went red, the mutation was not the one described and the reading is void.Observed, exactly: 2 of 4 tests failed. The collision test named all 10 collapses with the card's own line numbers (
347/1511,844/1512,845/1513,1194/1387,576/706, …); the ratchet reported UNCLASSIFIED + STALE;mode/dialect/fail-policyand the COMPILE-row assertion stayed green. This rules out the one thing that would have made the repair decorative — a collision test that never reads discovery.Mutation proven on disk (removed-text count 1 to 0, injected marker 0 to 1) before the run. Restored under a trap via
git checkout HEAD -- ABSOLUTE_PATH; restore proven by blob-hash equality against the HEAD blob (6199dd5c…, both non-empty) plus emptygit diff HEADand a zero marker count.Verification — union run at
672abe3f46epnpm --filter @objectstack/dogfood exec vitest run test/expression-conformance.test.ts— 4 passed (was 3; the collision gate is the new one)pnpm --filter @objectstack/dogfood typecheck— pass, and confirmed to actually cover both edited files viatsc --listFiles(1 hit each of 1304 files), not the excluded-tests trapscripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack: 49 families, 47 exit 0.check:doc-authoringwas genuinely RED on my first draft — I had put tracker ids in runtime string prose. Fixed by moving them to adjacent comments (⛔ not by touching the maintainer-only baseline); now green, with a non-zero control (831 pinned sites, 85270 strings read).check:dual-build-cjs-loadsandcheck:published-readme-exportsexit 3 = PREREQUISITE NOT MET — both read whole-repodist/, and I built only the dogfood dependency closure. Read as NOT MEASURED, not as pass and not as red; CI builds everything.No changeset:
@objectstack/dogfoodisprivate: trueand publishes nothing, so this carriesskip-changeset.🤖 Generated with Claude Code
https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Generated by Claude Code