Skip to content

fix(qa): give every expression declaration its own ratchet key - #15802

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-15500-expression-ledger-key-granularity
Sep 5, 2026
Merged

fix(qa): give every expression declaration its own ratchet key#15802
os-litant merged 2 commits into
mainfrom
claude/issue-15500-expression-ledger-key-granularity

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

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 one Set entry. 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):

roster positions keys collapsed keys
2-name (the card's pre-#15027 control) 32 24 (+2 RLS = 26) 8
4-name (current main) 44 34 (+2 RLS = 36) 10

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:requiredWhenFieldSchema "A TRANSITION GATE ... the write is refused ... Enforced by evaluateValidationRules" vs InlineGridColumnSchema "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:visibleWhenSelectOptionSchema (server-enforced on write) vs FieldSchema (UI gating).
  • object.zod.ts:visibleWhen — ⭐ new: ObjectFieldGroupSchema (ADR-0085 section gating, fail-closed, via deriveFieldGroupLayout) was silently classified by cel-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 covers entries instead of being assumed by a collapse.

Blast radius: all 36 covers entries 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.field separates all 44 positions today. That is a measurement, not a guarantee: two same-named fields in different inline z.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-visibleenforced. Server-side on write via rule-validator.ts: a clean FALSE on the picked value pushes invalid_option and refuses the write; faults are fail-open and logged. Cites its existing proof.
  • cel-inline-grid-cellexperimental, and deliberately not enforced: InlineGridColumnSchema has zero consumers outside packages/spec/src, and the objectui grid evaluator is not in this checkout. Same posture, and same stated reason, as template-title-format.
  • cel-field-group-sectionenforced, fail-closed, which is what separates it from cel-ui (whose form-view predicates fault open).

ADR-0058 / ADR-0060 text is untouched. Neither pins the key format — ADR-0060 treats covers as 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 the checkLedger ratchet 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-policy and 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 empty git diff HEAD and a zero marker count.

Verification — union run at 672abe3f46e

  • pnpm --filter @objectstack/dogfood exec vitest run test/expression-conformance.test.ts4 passed (was 3; the collision gate is the new one)
  • pnpm --filter @objectstack/dogfood typecheck — pass, and confirmed to actually cover both edited files via tsc --listFiles (1 hit each of 1304 files), not the excluded-tests trap
  • Gate union re-derived after the final change set with scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack: 49 families, 47 exit 0.
    • check:doc-authoring was 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-loads and check:published-readme-exports exit 3 = PREREQUISITE NOT MET — both read whole-repo dist/, 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/dogfood is private: true and publishes nothing, so this carries skip-changeset.


🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

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
@github-actions github-actions Bot added the size/m label Sep 5, 2026
@os-litant os-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 5, 2026 — with Claude
@github-actions github-actions Bot added the tests label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • 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 — 0 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 fa85759963fcf25a55cd9a159c8e9c44bb9dde13packageMentionDocs.

@os-litant os-litant left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed by the domain:cli execution seat (#6024). ⚠️ Recorded as a COMMENT rather than an approving review because GitHub refuses 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/, a private: true package that publishes nothing. No published payload gains a key, no packages/spec/src/** file is written (the spec is only read by the scan), and no shipped face re-selects an input class.
  • needs:contract-review absent 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.

⚠️ Same instrument caveat as #15797: 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 by cel-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

Merged via the queue into main with commit 450e030 Sep 5, 2026
40 checks passed
@os-litant
os-litant deleted the claude/issue-15500-expression-ledger-key-granularity branch September 5, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants