You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
ADR-0058 D7 expression ledger: the ratchet key is file:field, so two DIFFERENT surfaces declaring the same key in one file collapse into one row — field.zod.ts:requiredWhen classifies a server-enforced gate and a "nothing reads it" grid cell together #15500
Recorded by the os-dev seat on #15027 (session session_01D47qPfEWVPmhguWgBZCi5N, branch claude/issue-15027-expression-ledger-cron-template), measured while deriving why widening the roster by 12 declaring POSITIONS moved the ratchet by only 10. Unassigned, bare, for triage. Out of #15027's scope: that card is about which schema NAMES are discovered; this is about the KEY GRANULARITY of what discovery returns, and fixing it would re-shape every existing row's covers.
What
packages/qa/dogfood/test/expression-conformance.test.tsdiscoverSurfaces() keys every hit as `${rel}:${m[1]}` — relative file path plus the matched field name — into a Set. So two distinct declarations of the same key name in the same file are one ratchet key, and one ledger row classifies both. The ledger cannot represent that they differ, and the ratchet cannot notice that it never asked.
Measured at origin/main61821e54cf5, replicating the test's own regex verbatim:
current 2-name roster: 32 declaring positions collapse to 24 distinct keys (+2 hard-added RLS = the 26 discoverSurfaces() returns)
8 keys already collapse two positions each, today, on the cel population
ratchet key
positions
are they the same surface?
data/field.zod.ts:requiredWhen
845, 1513
NO
data/field.zod.ts:readonlyWhen
844, 1512
NO
data/field.zod.ts:visibleWhen
347, 1511
NO
data/validation.zod.ts:condition
180, 294
different schemas (ScriptValidation, CrossFieldValidation)
data/object.zod.ts:visibleWhen
1194, 1387
unmeasured
system/settings-manifest.zod.ts:visible
424, 686
unmeasured
ui/view.zod.ts:visibleWhen
2468, 2675
unmeasured
ui/view.zod.ts:visibleOn
2470, 2677
unmeasured
Why it matters — the three measured ones are not near-duplicates, they are opposite
data/field.zod.ts:requiredWhen is one ledger key covering two declarations whose own describe text contradicts each other on enforcement:
:1513FieldSchema.requiredWhen — "A TRANSITION GATE ... the write is refused ... Enforced by evaluateValidationRules" — server-enforced.
:845InlineGridColumnSchema.requiredWhen — "PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it ... declaring the requirement here alone enforces nothing."
Both are covered by the single row cel-field-rule, whose enforcement describes only the field-level story. A declared-but-unwired predicate that says so in its own describe string is invisible to the ledger built to surface exactly that class.
data/field.zod.ts:visibleWhen collapses SelectOptionSchema.visibleWhen (:347, the per-OPTION predicate, which its describe calls "The one VISIBILITY predicate the SERVER also enforces") with FieldSchema.visibleWhen (:1511, UI gating). ⭐ The ledger already knows these are different surfaces — it split the action-param analogue into its own row, cel-action-param-option-visible, with the reason stated in the row: "It is a SEPARATE row from cel-ui because the evaluator differs." The FIELD-side per-option predicate never got that split, because discovery could not hand the ratchet a second key to be unclassified about.
data/field.zod.ts:readonlyWhen collapses InlineGridColumnSchema.readonlyWhen (:844, per-row record + parent scope in the grid) with FieldSchema.readonlyWhen (:1512).
So the failure mode is not "a row is slightly imprecise". It is: classifying one declaration silently classifies every same-named sibling in that file, and the ratchet reports the file green. That is the same defect class the ledger header claims to prevent — "A NEW expression surface that nobody classified ... breaks the build" — reached through granularity rather than through roster membership.
This is the discovery contract's behaviour since the ledger was written; it is not introduced by widening the roster. It is only visible now because the widened roster made the position count and the key count disagree out loud (12 new positions, 10 new keys: api/export.zod.ts:cronExpression at :576/:706 and system/disaster-recovery.zod.ts:schedule at :57/:238 each collapse). #15027 documents that arithmetic in the test's docblock and classifies the 10 keys; it deliberately does not change the key shape, because doing so re-points every covers entry in the ledger and is its own decision.
Likely resolution, not a decision
Probably file:field becomes file:Schema.field or file:field@line-free equivalent (a line number is not an identity). ⚠️ Either way it is a breaking change to every covers entry and wants deciding, not guessing — and the three measured pairs above should each get their own honest row as part of it, since two of them are the declared-but-unwired shape.
Positive control for the scan: replicating the regex with the current 2-name roster reproduces the 24 keys / 26 surfaces the ledger covers exactly, with zero UNCLASSIFIED and zero STALE.
Related: #15027 is not addressed by this card and remains open on its own scope. Refs: ADR-0058 D7 · ADR-0060 · ADR-0049.
Recorded by the os-dev seat on #15027 (session
session_01D47qPfEWVPmhguWgBZCi5N, branchclaude/issue-15027-expression-ledger-cron-template), measured while deriving why widening the roster by 12 declaring POSITIONS moved the ratchet by only 10. Unassigned, bare, for triage. Out of #15027's scope: that card is about which schema NAMES are discovered; this is about the KEY GRANULARITY of what discovery returns, and fixing it would re-shape every existing row'scovers.What
packages/qa/dogfood/test/expression-conformance.test.tsdiscoverSurfaces()keys every hit as`${rel}:${m[1]}`— relative file path plus the matched field name — into aSet. So two distinct declarations of the same key name in the same file are one ratchet key, and one ledger row classifies both. The ledger cannot represent that they differ, and the ratchet cannot notice that it never asked.Measured at
origin/main61821e54cf5, replicating the test's own regex verbatim:discoverSurfaces()returns)celpopulationdata/field.zod.ts:requiredWhendata/field.zod.ts:readonlyWhendata/field.zod.ts:visibleWhendata/validation.zod.ts:conditionScriptValidation,CrossFieldValidation)data/object.zod.ts:visibleWhensystem/settings-manifest.zod.ts:visibleui/view.zod.ts:visibleWhenui/view.zod.ts:visibleOnWhy it matters — the three measured ones are not near-duplicates, they are opposite
data/field.zod.ts:requiredWhenis one ledger key covering two declarations whose owndescribetext contradicts each other on enforcement::1513FieldSchema.requiredWhen— "A TRANSITION GATE ... the write is refused ... Enforced byevaluateValidationRules" — server-enforced.:845InlineGridColumnSchema.requiredWhen— "PRESENTATION ONLY: this flags the cell inline-invalid in the grid; nothing on the write path reads it ... declaring the requirement here alone enforces nothing."Both are covered by the single row
cel-field-rule, whoseenforcementdescribes only the field-level story. A declared-but-unwired predicate that says so in its own describe string is invisible to the ledger built to surface exactly that class.data/field.zod.ts:visibleWhencollapsesSelectOptionSchema.visibleWhen(:347, the per-OPTION predicate, which its describe calls "The one VISIBILITY predicate the SERVER also enforces") withFieldSchema.visibleWhen(:1511, UI gating). ⭐ The ledger already knows these are different surfaces — it split the action-param analogue into its own row,cel-action-param-option-visible, with the reason stated in the row: "It is a SEPARATE row fromcel-uibecause the evaluator differs." The FIELD-side per-option predicate never got that split, because discovery could not hand the ratchet a second key to be unclassified about.data/field.zod.ts:readonlyWhencollapsesInlineGridColumnSchema.readonlyWhen(:844, per-rowrecord+parentscope in the grid) withFieldSchema.readonlyWhen(:1512).So the failure mode is not "a row is slightly imprecise". It is: classifying one declaration silently classifies every same-named sibling in that file, and the ratchet reports the file green. That is the same defect class the ledger header claims to prevent — "A NEW expression surface that nobody classified ... breaks the build" — reached through granularity rather than through roster membership.
Not a regression, and not what #15027 changes
This is the discovery contract's behaviour since the ledger was written; it is not introduced by widening the roster. It is only visible now because the widened roster made the position count and the key count disagree out loud (12 new positions, 10 new keys:
api/export.zod.ts:cronExpressionat:576/:706andsystem/disaster-recovery.zod.ts:scheduleat:57/:238each collapse). #15027 documents that arithmetic in the test's docblock and classifies the 10 keys; it deliberately does not change the key shape, because doing so re-points everycoversentry in the ledger and is its own decision.Likely resolution, not a decision
Probably⚠️ Either way it is a breaking change to every
file:fieldbecomesfile:Schema.fieldorfile:field@line-free equivalent (a line number is not an identity).coversentry and wants deciding, not guessing — and the three measured pairs above should each get their own honest row as part of it, since two of them are the declared-but-unwired shape.Verified
search_issues, control satisfied in-session: an earlier query in the same session returned ADR-0058 D7 expression conformance ledger discovers onlyExpressionInputSchema/SettingsVisibilityInputSchemapositions — the 8CronExpressionInputSchemaand 3TemplateExpressionInputSchemasites sit outside the ratchet, unclassified #15027 and [finding]CronExpressionInputSchema/TemplateExpressionInputSchemafix the dialect only on the bare-string arm — the envelope arm accepts any declared dialect, so a cron-typed slot parses{ dialect: 'cel', source }green #15028 as known-certain hits, so the empty result here is a reading):total_count0 for the key-granularity query. Nearest live neighbours are ADR-0058 D7 expression conformance ledger discovers onlyExpressionInputSchema/SettingsVisibilityInputSchemapositions — the 8CronExpressionInputSchemaand 3TemplateExpressionInputSchemasites sit outside the ratchet, unclassified #15027 (roster membership, in flight) and [finding]CronExpressionInputSchema/TemplateExpressionInputSchemafix the dialect only on the bare-string arm — the envelope arm accepts any declared dialect, so a cron-typed slot parses{ dialect: 'cel', source }green #15028 (the dialect union's envelope arm) — neither covers key granularity.Related: #15027 is not addressed by this card and remains open on its own scope. Refs: ADR-0058 D7 · ADR-0060 · ADR-0049.