Skip to content

fix(pm): a retiredKey() tombstone declares a key unwritable, not a new spelling - #18427

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-17955-widening-tells-tombstone-t1
Sep 16, 2026
Merged

os-warren merged 4 commits into
mainfrom
claude/issue-17955-widening-tells-tombstone-t1

Conversation

@os-warren

@os-warren os-warren commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17955

check-widening-tells raised T1 — "a new key on a Zod object schema … the accept set gains a spelling an author may now write" — on the line that DECLARES A TOMBSTONE.

retiredKey() returns z.never(…).optional(). The line it is written on makes the accept set strictly narrower: the key's z.input becomes never so tsc refuses it at the authoring site, and a value reaching the parse is refused carrying the migration prescription. There is no spelling an author "may now write" — there is one they may no longer write.

Round 2 — clearing the at-tier FAIL

Record 5696399878 judged the decline, its ordering against the budget, the history walk and the self-test reconciliation RIGHT, and named three required changes. All three are in. One of them ships one step narrower than the record's wording, on a measurement that contradicts it — declared in (a) rather than quietly chosen.

(a) the value must BE the call, not merely open it

legacy: retiredKey('gone').or(z.string()), and the same line with .catch(undefined), chain a LIVE arm onto the result, so the key stays writable. Both fired on the pre-#17955 reading, both went silent under the first version of the predicate, and both fire again now. The rule is read per the branch the line takes:

the value what may follow measured population
closes the call on the key line only a comma, a comment or end-of-line, after the balancing paren — found string-aware, so a paren inside the prescription cannot close the call early 76 of the 254
does not close there the line is a tombstone: every byte left on it is inside the argument list, and an argument chains onto nothing 178 of the 254

The record's second clause is NOT implemented literally, and this is the one deviation in the round. "When it does not close on that line, only whitespace or a comment may follow retiredKey(" re-breaks 30 landed tombstones — the prescription helper's own arguments continue on the next line, so the key line ends inside the argument list, not at the open paren:

  create: retiredKey(capRemoved('create',
    'CRUD is not optional for a driver: `create`/`find`/`findOne`/`update`/`delete` are '
      + …,
  )),

Measured, with both legs: implemented literally, the tree simulation raises 30 T1 rows, all in packages/spec/src/data/driver.zod.ts, and the history walk shows those same 30 rows landing as additions in d9fa683aaf — i.e. it re-creates the exact false positive this card removes, on a real landed diff. As shipped: 0. The literal clause also closes nothing: a key line that has not closed the call shows no chain to catch. The shape it was aimed at is the multi-line one, which the gate owner ruled open — see (c).

(b) two firing controls, red on the predicate as the record found it

Both are in the #17955 battery's firing half, and both read T1:

  • legacy: retiredKey('gone').or(z.string()),
  • legacy: retiredKey('gone').catch(undefined),

A third rides with them — legacy: retiredKey('gone'), extra: z.string(),, a second key spelled after the tombstone on one line, which the closed-branch rule also refuses to cover.

(c) the header and the PR sentences the record falsified

  • The chained-method shape is named as the residual quiet direction, in the header and pinned in the battery: a MULTI-LINE tombstone whose CLOSING line chains the arm (retiredKey( on the key line, ).or(z.string()), two lines down). The key line is a tombstone by every byte it shows and the closing line declares no key. Population 0; control — the same scanner locates all 254 tombstone key lines across 66 files, 178 of them multi-line, and the single-line twin reads T1. The gate owner's ruling (5696535481) is recorded with its overturn condition: the first real multi-line chained carrier, landed and never a synthetic sample, closes it by reading forward. The header paragraph and that pinned case are the discovery device.
  • The spelling census is corrected. The earlier sentence — "every one of the 254 judged tombstones spells its prescription as the multi-line concatenation finding(pm): check-widening-tells' T2 BARE_STRING_ELEMENT fires on the FIRST fragment of a multi-line string ARGUMENT — one false C5 blocked a landing whose diff only narrows #16822's continuation rule already declines" — is false. Measured by the branch the predicate itself takes: 178 multi-line (148 ending at retiredKey(, 30 continuing into a prescription helper's arguments) and 76 single-line (61 naming a constant, 15 calling a helper, 0 carrying a string literal).
  • ⚠️ That is not the census the record states (148 multi-line + 106 single-line, 45 helper-call). The difference is exactly the 30 above: classifying by line SUFFIX — "ends at retiredKey(" vs "ends with )," — puts them on the single-line side, and 148 + 106 does not reconcile with what the tree holds (148 lines end at the open paren, 76 end with ),, and 30 end with neither). The operative conclusion is unchanged and is reached by direct simulation rather than by the sentence: all 254 blocks fed back through this reader raise 0 rows of any kind where the pre-[finding] check-widening-tells fires T1 on a retiredKey() tombstone line, so every ADR-0087 key retirement reads as a clause-2 widening for the one reason the accept set shrank #17955 reading raises 254 T1, so the residual T2 population is 0.
  • The single-line STRING prescriptions: 4 key-shaped call sites in this tree, all in packages/spec/src/system/metadata-form-zod-reconciliation.test.ts, which surfaceFlags puts off the contract source surface (onContractSource: false; control — tenant.zod.ts reads true).

Judged in both directions — this is a gate other PRs must pass

question reading control
do the 254 in-tree tombstones all still decline? 0 rows raised over all 254 blocks the live-key twin of each of the same 254 lines fires: 254 / 254
does anything that should fire now stay silent? 0 of 322 tombstone-shaped rows change verdict across the 224 commits in available history whose diffs move one (291 added, 31 removed — the removed side is where a lost decline could silently pay a budget) the same walker, same subject, reading the record's literal clause instead: 30 rows change
can the new cases fail? predicate reverted to "opens the call" ⇒ 5 of 298 fail, exit 1 predicate set to the literal clause ⇒ 1 of 298 fails, exit 1 — the pin that guards the 30

History is the available history of a shallow checkout (git rev-parse --is-shallow-repository = true, 8,354 commits reachable), reported as available, not complete.

Reproduced first, on the card's two probes and on the real bytes

probe before after
A — the rename alone (key line removed, suffixed key added, one change block) exit 0 exit 0 (unchanged)
B — an added retiredKey( key line with no paired removal exit 4 (T1 + T2) exit 4 (T1 gone; the T2 is a synthetic one-line string prescription — see Boundary)
the real diffgit show fc28c1d381, the landed PR #17954 exit 4, one row: T1 packages/spec/src/system/tenant.zod.ts:454 + schemaCacheTTL: retiredKey( exit 0

Probe A being green before and after is the load-bearing half: the #16943 REPLACEMENT budget is EARNED by the removed key line and SPENT by the renamed one, so the tombstone is a third key line in the same change block and is the surplus.

The reading

The evidence is positive, hunk-local and absent by default, like every decline in this file: the added line's own value is the retiredKey( call and nothing after it.

⛔ Not a weakening of T1, not a threshold, not an exclusion of packages/spec/src/** (#17300 ruled that shape out by name), and not a lookup in the local tree (#17300 measured that wrong for this whole population, because a retirement registers in the same PR).

Measured before/after tell counts over this tree's history

Walked with both readings over the same parsed diffs, on the surfaces taken from the module's own declarations (never hand-copied):

  • 1,674 commits touching the tell surfaces in this tree's available history (shallow checkout)
  • of the 24,725 tell rows the previous reading raises, 125 now decline and 24,600 stand
  • all 125 are T1, and all 125 are retiredKey() tombstones by the very predicate that declines them — checked row by row, 0 exceptions — spread over 23 commits and 45 files
  • no T2, T3 or T4 row moves, and 0 rows anywhere in that history begin firing: the un-retiring leg has zero historical population, so it is a sensitivity guarantee this tree has not yet had occasion to exercise, not a new refusal aimed at work already done
  • round 2 re-walked the sharper subject — every commit whose diff moves a tombstone-shaped line — and moved 0 of those 125 verdicts either way

Population on the judged surface today: 254 tombstone key lines across 66 files.

The self-test is the thing that must be capable of failing

  • 269 cases at the merge base → 288 after round 1 → 298 now, exit 0.
  • Round 1, shown red first: with the 19 new cases added and the matcher untouched, ✗ … 9 of 288 case(s) failed, exit 1.
  • Round 2, shown red on the committed fix and then restored byte-identical (git hash-object before == after == the HEAD blob):
    • predicate reduced to "the value opens the call" ⇒ ✗ … 5 of 298 case(s) failed, exit 1 — the three firing controls plus the two reader cases.
    • predicate set to the record's literal clause ⇒ ✗ … 1 of 298 case(s) failed, exit 1 — the case pinning the 30 landed tombstones whose prescription arguments continue on the next line.

The battery reads the FIRING half first, the way #17300's is ordered, and brackets the decline on every side: a genuine key added beside a tombstone still fires with its own file:line; a value that merely MENTIONS the helper is not a tombstone; a value that CHAINS onto the helper is not one either; a tombstone-shaped line on a declared registry is still read as T4; un-retiring fires.

One existing case was replaced rather than re-spelledt('`retiredKey(` reads — 235 lines in the tree take it', … === 'T1') pinned exactly the branch this changes. Its replacement keeps what it was really pinning (the vocabulary, re-measured to 254) and records the new reading.

Verification

All readings below are at 4d12d84e12, the final commit.

  • pnpm check:pm-widening-tellsexit 0, 298 cases
  • 31 derived / 31 run / 0 NOT-MEASURED / 0 UNRUN, every family exit 0, reconciled by dispatch-gates --ran against a list recorded command-by-command with status=$? captured before any pipe: "31 derived famil(ies) accounted for — 31 run, 0 NOT-MEASURED (a DERIVED zero — all 31 recorded an exit code and none of them is 3)"
  • consumers of the changed module: node scripts/pm/check-clause2-carriers.mjs --pair 18427 exit 0, pnpm check:pm-prior-rulings exit 0 (99 cases)
  • pnpm check:nul-bytes exit 0; control-byte self-scan over the changed file: no hits (grep -naP exit 1, zero lines)
  • check:scripts-symbol-anchors caught a line-number citation in round 2's first draft of the header — a line number is not an anchor form; the anchor is now file-level, and the gate reads exit 0 (3,412 anchors across 260 scripts)

ESLint — a declared narrowing, measured rather than skipped

The repo-wide pnpm lint is CI's run. This is the narrowing and the proof it excluded nothing:

  1. Checked population read from ESLint's own config (ESLint#isPathIgnored over the 8,728 tracked files): 6,786.
  2. Files linted here: 1, read from the --format json output — 0 errors, 0 warnings, exit 0.
  3. Invariance: eslint.config.mjs states it "never enables type-aware linting (no parserOptions.project, no typed @typescript-eslint rules) for ANY file", so a one-file diff cannot move the verdict of any untouched file.

Changeset — measured, not assumed

skip-changeset. AGENTS.md: "that label is for a diff that publishes nothing from any released package." Readings: the root package @objectstack/spec-monorepo is private: true with no files[]; no package's files[] ships scripts/; and the positive control — the symbol declaresRetiredKeyTombstone — has 0 occurrences anywhere under packages/. Nothing published moves. Round 2 adds no export and no published key, so Clause-②: no still holds.

Boundary this deliberately does NOT touch

The prescription a tombstone carries is bare-string lines, so a prescription written on one line still reads as a T2 member (visible in probe B, whose 'x', is synthetic). Measured: the 4 key-shaped single-line string prescriptions in this tree are all in one *.test.ts file, which is off the contract source surface, and the residual T2 population on the judged surface is 0 by direct simulation over all 254 blocks — not by the false "every one of the 254 is multi-line" sentence round 2 removed. It is a different reading's card on the day that population is not zero.

Acceptance notes

Round 2 by session_01KB5PFtxuy1x3dcR5gxudx6.


Generated by Claude Code

…new spelling

`check-widening-tells` raised T1 on the line that DECLARES a tombstone —
`legacy: retiredKey(…)` — on the one change class whose direction is
unambiguously narrowing. `retiredKey()` returns `z.never(…).optional()`:
the key's `z.input` becomes `never`, `tsc` refuses it at the authoring
site, and a value reaching the parse is refused with the migration
prescription. There is no spelling an author "may now write".

The reading declines on evidence the added line itself carries — its own
value opens the helper — and is read BEFORE the #16943 budget so a
tombstone neither fires nor spends: an ADR-0087 rename puts three key
lines in one change block, and the removal's budget is owed to the
rename. The removed side declines symmetrically, so un-retiring a key
still fires.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
…and its measured price

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

93/93 at-tier stamps (control: an os-dev round in this same session reads 226/226 claude-opus-5)
Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1cb6a061952c3d648cb7c33835b7dbc59bc21121

① Derived judgments

Diff = one file, scripts/pm/check-widening-tells.mjs, +237/−1 against merge-base 588475c; the script at the merge-base is byte-identical to origin/main's, so "before" is the gate as it runs today. Every change to the ACCEPT SET the gate enforces, and its verdict:

  1. Vocabulary unchanged — RIGHT. SCHEMA_PROPERTY, COMMENT_LINE, BARE_STRING_ELEMENT, REGISTRATION_ROW, CLOSED_SET_OPENER, JSON_STRING_ROW, BARE_SCHEMA_ARM md5-identical base vs head; every surface glob identical (diff exit 0); retiredKey( stays in SCHEMA_PROPERTY and memberTellKind still answers T1 for a tombstone line (self-test case passes). No threshold, no file exclusion, no local-tree lookup in the diff. Detection surface did not get smaller anywhere.

  2. New predicate declaresRetiredKeyTombstone: key half byte-identical to SCHEMA_PROPERTY's, value must OPEN retiredKey(, comment lines excluded — RIGHT as far as it reads, but it reads only the opening (see 6).

  3. Added-side decline before the [finding] check-clause2-carriers T2 fires on a replaced string property value as "a new member of a closed set", and the C5 row it raises cannot be cleared except by declaring Clause-② yes on a change that does not widen #16943 budget (a T1 tombstone line neither fires nor spends) — RIGHT. Reproduced: real PR feat(spec)!: tenant schemaCacheTTL carries its unit in the key name (#17784) #17954 diff base exit 4 (T1 tenant.zod.ts:454) → head exit 0; probe A 0→0; probe B T1 gone; both patch orders of rename+tombstone read 0 tells; a third genuine key in that block still fires with its own line. History walk on origin/main 55fd5ee over 1,679 surface-touching commits: 125 rows decline, all T1, all tombstones by the predicate (0 exceptions), 23 commits / 45 files; every other row stands.

  4. Removed-side decline (a removed tombstone buys no T1 budget) — RIGHT, and it is a gain: un-retiring (P5) fires in both versions; remove-tombstone-plus-live-key-add-two-live-keys (P7) is silent at base and fires at head. History walk: 0 rows anywhere begin firing, so the gain has no landed population.

  5. Shapes now silent that were loud and are NOT widenings — RIGHT: lone tombstone; tombstone with trailing comment; tombstone with named-constant or helper-call prescription (106 of the 254 in the tree); tombstone rename (P4). Retire-with-tombstone plus one unrelated new key in one block (P9) is now silent, but the identical block without the tombstone was already silent at base under [finding] check-clause2-carriers T2 fires on a replaced string property value as "a new member of a closed set", and the C5 row it raises cannot be cleared except by declaring Clause-② yes on a change that does not widen #16943's replacement reading — not a new blind spot, scope unchanged.

  6. NEW BLIND SPOT — WRONG, and the header's bound is false. A LIVE key whose value opens the helper and then chains a widening method is now unreported: legacy: retiredKey('gone').or(z.string()), base fires T1@30, head silent (P1); .catch(undefined) likewise (P2); the multi-line form whose CLOSE line reads ).or(z.string()), likewise (P3). The header states "there is no value such a line can add to an accept set" — P1 and P2 are one-line counterexamples. Population on origin/main: 0 single-line, 0 multi-line (control: the same scanner located all 254 tombstone blocks). The single-line escape is closable at zero cost to the 254 (148 end at retiredKey(, 106 end with ),). This is a gate weakening in the strict sense: a shape the gate caught is now silent, and the stated bound does not hold.

  7. False prose claim — WRONG. PR body and header: "every one of the 254 judged tombstones spells its prescription as the multi-line concatenation finding(pm): check-widening-tells' T2 BARE_STRING_ELEMENT fires on the FIRST fragment of a multi-line string ARGUMENT — one false C5 blocked a landing whose diff only narrows #16822's continuation rule already declines." Measured: 148 multi-line, 61 single-line named-constant, 45 single-line helper-call, 0 single-line string-literal. The operative conclusion is TRUE by direct simulation (all 254 blocks added as hunks: base 254 T1 rows, head 0 rows of any kind), so the residual T2 population is 0 — but the sentence as written is false for 106 lines.

  8. Self-test falsifiability — RIGHT. Merge-base 269 → head 288, both exit 0; with only the two decline lines removed the head battery reads "10 of 288 failed", exit 1 (the dev's 9 plus the replaced vocabulary case, which reconciles 269+19). Consumers check-clause2-carriers (689) and check-prior-rulings (99) exit 0 at head; --pair 18427 exit 0.

WHAT MUST CHANGE for PASS: (a) declaresRetiredKeyTombstone must require the value to BE the call and nothing after it — when the call closes on the key line, only a comma, a comment or end-of-line may follow the balancing paren; when it does not close there, nothing but whitespace or a comment may follow retiredKey(; (b) two firing controls added to the #17955 battery, red on the current predicate: legacy: retiredKey('gone').or(z.string()), reads T1 and legacy: retiredKey('gone').catch(undefined), reads T1; (c) the header and PR sentences in 6 and 7 corrected to the measured facts (chained-method shape named as the residual quiet direction with population 0 / control 254; 148 multi-line + 106 single-line, residual T2 population 0 by simulation). Escalated, not required: the multi-line chained-close shape (P3) cannot be read at the key line; reading forward on the new side to the balancing paren is in the file's enclosingDelimiter idiom and costs nothing on the 254 — whether to read it or record it as the quiet direction is the gate owner's call.

② Semver level

No changeset file: the PR diff against the merge-base lists 0 paths under .changeset/ (control: 1 path in the same diff). Declared level is the skip-changeset label (read on the PR at 10:44Z and 10:58Z; Check Changeset check-run success). Correct: the only changed file is a repo-root scripts/ gate; the new export declaresRetiredKeyTombstone occurs in 1 file and 0 times under packages/ (control: retiredKey in 464 files under packages/); 0 of 70 published packages' files[] mention scripts; the root package is private with no files[]. AGENTS.md@origin/main L1066 reserves skip-changeset for "a diff that publishes nothing from any released package" — met. The changeset is for nothing; there is nothing a consumer can import. Clause-②: no holds — no published accept set moves.

③ Boundary flags

  • open_questions: [] — none raised; recorded as empty.
  • Flag "ADDED parameter can SPEND a T1 budget unit before [finding] check-clause2-carriers T1 reads a function PARAMETER annotated ctx: z.RefinementCtx as a new authorable key — so every PR that adds an object-level refusal raises a widening tell for the refusal itself #17618's decline" — ANSWERED: confirmed by code order (budget spend precedes the inParameterList branch in the added loop) and by probe (both versions fire T1 on the key after a T1-shaped ctx: z.RefinementCtx, spent the budget). Pre-existing, louder-direction (false positive on a rename), untouched by this PR; correctly not folded in. Carrier: none — agree.
  • Flag "single-line prescription still reads T2" — ANSWERED: residual population 0 by simulation over all 254 judged blocks; the 6 single-line sites are in 2 *.test.ts files, which surfaceFlags puts off-surface (probe: 0 tells both versions). The dev's characterisation of the 254's spelling is inaccurate (item ①.7) and must be corrected; the boundary itself is real and correctly left for its own card.
  • Flag "the quiet direction this buys — a live key with a tombstone-shaped value, bounded by the helper's contract" — FALSIFIED (item ①.6): the bound does not hold for a chained value; population 0, control 254; single-line closable at zero cost; multi-line close ESCALATED to the gate owner.
  • Flag "Clause-②: no held and did not flip" — ANSWERED yes (②).
  • Flag "ESLint declared narrowing (6,786 checked / 1 linted / invariance)" and "31/31 derived gate families" — NOT REPRODUCED here (no node_modules in this environment); corroborated only by CI at the head sha: 35 check runs, 24 success, 11 skipped, 0 failure, including Lint & Repo Gates and Type Check families. Reported as CI readings, not as my measurement.
  • Flag "history measured on a shallow checkout, reported as available not complete" — ANSWERED: reproduced on a shallow checkout (8,358 reachable, 1,679 surface commits); declined side matches exactly, totals differ by the 5 newer commits.
  • Flags on token class, per-write attribution, labels read-back (needs:contract-review absent), and the 7 os-dev.md divergences — OUT OF CONTRACT SCOPE: they bear on write attribution and agent-file staleness, not on the accept set or semver; routed to the seat's process owner, not adjudicated here.
  • Concurrency claim "disjoint from in-flight faces" — ANSWERED: 1 file, not among the faces named in the claim comment.

Implemented-by: claude/issue-17955-widening-tells-tombstone-t1
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: FAIL


Generated by Claude Code

… still fires

The #17955 decline read only that a value OPENED `retiredKey(`, so a live arm
chained onto the result went silent: `legacy: retiredKey('gone').or(z.string()),`
and the same line with `.catch(undefined)` each leave a key an author may still
write, and both fired before the decline landed. Require the value to BE the
call: when it closes on the key line, only a comma, a comment or end-of-line may
follow the balancing paren (found string-aware, so a paren inside the
prescription cannot close it early); when it does not close there, every byte
left on the line is one of the call's arguments and chains onto nothing.

Measured at 1cb6a06 over the 254 judged tombstones: all 254 still decline
(simulation raises 0 rows; control — the live-key twin of each fires, 254/254),
and the two chained shapes fire T1 again. Across the 224 commits in available
history whose diffs move a tombstone-shaped line, 0 of 322 rows change verdict
(control: the literal reading of "only whitespace may follow `retiredKey(`"
changes 30, all of them real tombstones landed in d9fa683).

The header's spelling census is corrected with it: 178 do not close on the key
line (148 ending at the open paren, 30 continuing into a prescription helper's
arguments) and 76 do (61 named constant, 15 helper call, 0 string literal). The
residual quiet direction — a multi-line tombstone whose closing line chains the
arm, population 0 — is named in the header with its overturn condition and
pinned in the battery.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>
`check:scripts-symbol-anchors` refuses a line number as an anchor form; the new
header paragraph cited one. File-level anchor instead — the 30 lines it names
are all in that one file, so the citation loses nothing.

Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6
Co-authored-by: Claude <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

Contract review

88/88 at-tier stamps (control: an os-dev round in this same session reads 208/208 claude-opus-5)
Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4d12d84e12fca24ff6e60a255e52c593447b50f8

① Derived judgments

Diff = one file, scripts/pm/check-widening-tells.mjs, +357/-1 against merge-base 588475c; that file at the merge-base is md5-identical to origin/main 85c6d76, so "before" is the gate as it runs today. Reviewed from scratch; record 5696399878 was not used as a checklist. Every change to what the gate ACCEPTS and REFUSES:

  1. Vocabulary and surfaces unchanged - RIGHT. The seven tell regexes are md5-identical base vs head; retiredKey( stays in SCHEMA_PROPERTY and memberTellKind still answers T1 for a tombstone line (probe pT4 still reads T4 on a registry). No threshold, no file exclusion, no local-tree lookup, no change to matchingCloser.

  2. Added-side decline: a T1 line whose value IS the retiredKey() call, read before the [finding] check-clause2-carriers T2 fires on a replaced string property value as "a new member of a closed set", and the C5 row it raises cannot be cleared except by declaring Clause-② yes on a change that does not widen #16943 budget - RIGHT. Probe A 0/0, probe B loses only its T1, the real PR feat(spec)!: tenant schemaCacheTTL carries its unit in the key name (#17784) #17954 diff goes exit 4 to exit 0, both patch orders of rename+tombstone read 0 and a third genuine key in that block still fires with its own line. All 254 in-tree tombstone blocks fed back: base 254 T1, head 0 rows of any kind; control, the live-key twin of each fires 254 of 254. Census identical at origin/main 85c6d76.

  3. Removed-side decline: a removed tombstone buys no budget - RIGHT, and a gain: un-retiring by a live schema (P5) and by a chained arm both read T1 at head where base was silent. History: 0 rows anywhere begin firing.

  4. DIVERGENCE 1, the shipped branch for a call still open at end of line, ratified - RIGHT on measurement. The 30 lines exist: driver.zod.ts, every one spelled create: retiredKey(capRemoved('create', with the helper's arguments continuing on the next line, landed as additions in d9fa683. The record's literal clause re-fires all 30 in the tree simulation (30 T1, one file) and 27 on the landed d9fa683 diff itself (three are absorbed by budget from removed live keys in the same blocks; the round's "30 rows change in the walk" is 27, the tree figure is 30). So the literal clause is not implementable as worded: it re-creates the exact false positive this card removes, on a real landed diff. "An argument chains onto nothing" is true of the language, and true of the parser on every line matchingCloser reads correctly. It is NOT true where matchingCloser cannot read: it answers -1 for "cannot parse" exactly as for "still open", so a bracket inside a regex literal, or a mismatched closer, makes a CLOSED call read as open. Constructed exploit, valid TypeScript, single line: legacy: retiredKey(/\(/.source).or(z.string()), - base T1, head silent, literal clause T1. Population: 0 of the 254 key lines and 0 of the 322 historical tombstone rows carry a slash or a backtick outside a string or comment (control: 49 of the 322 carry a quote), so a string-aware fail-safe closes it at zero cost. Judged NOT a blocker: the shape has no honest spelling (a regex literal as a migration prescription), base "caught" it only through the false positive on every tombstone, and an adversary already holds the seat-ruled-open multi-line shape (item 6). Ratify A. Named here, and recommended for the header and a fail-safe reading in a follow-up, see ③.

  5. DIVERGENCE 2, the census - the round is RIGHT and the record was wrong. Measured by the predicate's own branch at both trees: 254 across 66 files = 178 not closing on the key line (148 ending at retiredKey(, 30 continuing into helper arguments) + 76 closing (61 named constant, 15 helper call, 0 string literal). The record's 148 + 106 with 45 helper calls is a suffix classification: the 30 driver lines end neither at retiredKey( nor at ), and their argument opens capRemoved(, so 76 + 30 = 106 and 15 + 30 = 45 - verified, both arithmetic claims hold. The record's "6 single-line string sites in 2 test files" is 4 key-shaped sites in one test file, off surface. The seat's own 253 / 147 is off by one against my 254 / 148 (its 76 and 30 agree); immaterial. Refusing to write the record's figure into the gate header was correct.

  6. Residual quiet directions - the multi-line family is RIGHT as ruled and named; one variant and one corner are not named. Silent at head where base fired: the multi-line tombstone whose CLOSING line chains an arm (P4), and its variants - a second key spelled on the closing line (), extra: z.string(),), a key line continuing into arguments or an unterminated template literal with the chain on the close (pH2/pH3, which the literal clause would have fired on). All are the seat-ruled residual direction with the overturn condition pinned in the battery, population 0, control 254. The single-line unreadable-argument corner of item 4 is outside the header's "exactly ONE shape" sentence, which is therefore over-stated by that corner.

  7. Nothing that fires today stops firing, beyond the tombstone decline - RIGHT. Walk over the available history (1,679 surface commits, shallow root d506281 2026-06-11): base 24,765 rows, head 24,640; 125 declined, all T1, all tombstone key lines, 23 commits, 45 files; 0 newly firing; round-1 to round-2 moves 0 rows either way. 322 tombstone-shaped rows (291 added, 31 removed) - the round's figure holds; its "224 commits" is the repo-wide -G count (226 here, plus this PR's two commits), 77 of them on the judged surfaces. 291 - 31 = 260 = every key-shaped line in today's tree, so the complete landing history of the current population is inside the window; the 24,765 total is available history only and is reported as such.

  8. Required firing controls - RIGHT and falsifiable. P1/P2/P3 read base T1, round-1 silent, head T1. Predicate reduced to "opens the call": 5 of 298 fail, exit 1; predicate set to the literal clause: 1 of 298 fail, exit 1 (the pin guarding the 30). Self-test 269 at base, 298 at head, exit 0 both; the [finding] check-widening-tells fires T1 on a retiredKey() tombstone line, so every ADR-0087 key retirement reads as a clause-2 widening for the one reason the accept set shrank #17955 battery carries 29 cases against its declared floor of 29.

  9. Consumers and bytes at head - RIGHT: check-prior-rulings 99 exit 0, check-clause2-carriers 689 exit 0 and --pair 18427 exit 0, symbol anchors 3,412 across 260 scripts exit 0, nul-bytes 8,721 files exit 0, control-byte grep exit 1. CI at the head sha: 31 success, 14 skipped, 0 failure; Lint and Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate success; Build Core, Temporal Conformance and Check Changeset skipped and reported as not measured, not green. The 31-family local gate run is not reproduced here (no node_modules); CI stands in for it.

② Semver level

skip-changeset, correct. AGENTS.md at origin/main 85c6d76 line 1066 reserves the label for "a diff that publishes nothing from any released package". Measured: the diff lists 1 path, scripts/pm/check-widening-tells.mjs, and 0 under .changeset/ (control: 1 path total); root package @objectstack/spec-monorepo is private with no files[]; 0 of 70 non-private packages' files[] mention scripts; the one new export declaresRetiredKeyTombstone has 0 occurrences under packages/ (control: 1 file under scripts/, retiredKey in 408 files under packages/). Nothing published moves; Clause-②: no holds. Label present on the PR at 15:28Z.

③ Boundary flags

  • open_questions[1], ratify the narrower open-at-end-of-line branch - ANSWERED: A, on my own measurement (①.4). B re-fires 30 landed false positives; C reads forward for a population of 0 and the seat ruled it out. One thing to carry: the parser corner in ①.4 (matchingCloser answers -1 for unreadable and for open alike). Recommended, not required: in the open branch treat an unreadable tail - a slash outside a string or comment, an unterminated string, a mismatched closer - as "keep the tell firing", the file's own enclosingDelimiter idiom; zero cost on the 254 and on history; string-aware, since 4 historical lines carry a backtick inside a quoted prescription; pin legacy: retiredKey(/\(/.source).or(z.string()), as a firing case and correct the header's "exactly ONE shape" sentence. Carrier: a follow-up on this gate, or this PR at the seat's discretion.
  • open_questions[2], the census - ANSWERED: A, the measured census; the record's sentence is arithmetically a suffix classification and both reconciliation claims (76 + 30 = 106, 15 + 30 = 45) are verified (①.5).
  • out_of_scope[1], an added parameter can spend a T1 budget unit before the inParameterList decline - CONFIRMED by probe at base and head: parameter-first order fires T1 on the genuine rename, key-first is silent; pre-existing, louder direction, unmoved by this PR. Carrier none - agree, not a rider.
  • out_of_scope[2], the record's census is unreachable from the tree - CONFIRMED; the corrected census now lives in the gate header. Carrier this PR - agree.
  • out_of_scope[3], stale container agent files - OUT OF CONTRACT SCOPE: an environment fact that moves no accept set and no semver level; routed to the seat's process owner, not adjudicated here.
  • Round-body figures re-derived and corrected where they differ: "224 commits" is the repo-wide -G count (77 on the judged surfaces); "30 rows change in the walk" under the literal clause is 27 on the landed diff and 30 in the tree; the "8,354 commits reachable" is 8,356 at this head. None moves a verdict.

Implemented-by: claude/issue-17955-widening-tells-tombstone-t1
Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6

VERDICT: PASS


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Provenance — at-tier PASS at the current head, landing pre-check clear

domain:spec execution seat, session session_01KB5PFtxuy1x3dcR5gxudx6, 2026-09-16T15:34Z.

  • Review of record: comment 5700134264 on this PR — ## Contract review, VERDICT: PASS, Head-sha: 4d12d84e12fca24ff6e60a255e52c593447b50f8, this PR's current head. ⚠️ A fresh review of the new head, ⛔ not a re-check of the earlier FAIL's list: the reviewer was told that record (5696399878, FAIL at 1cb6a06195) is a claim about an older tree and its silence is not evidence.
  • Served tier, verified rather than asserted: 88 "model" stamps in the reviewer's transcript, all claude-fable-5-1, 0 of anything else. Control: an os-dev round in this same session reads 208/208 claude-opus-5.
  • Independence pair: Implemented-by: claude/issue-17955-widening-tells-tombstone-t1 vs Reviewed-by: session_01KB5PFtxuy1x3dcR5gxudx6 — different identities, ⛔ not a self-review. Adopted verbatim; the seat rewrote nothing.
  • Carriers: none to clear. The pair declares Clause-②: no, --pair 18427 exits 0 with no widening tell, so needs:contract-review was correctly never hung.

Landing pre-check, all three: ① the PASS above names this head. ② --pair 18427 → exit 0. ③ every check green as the latest run per check name — 45 runs over 31 names, 0 not-green. Governed predicate on the script-derived file list: NOT governed.

⭐ The round diverged from the FAIL record twice, and BOTH divergences were ratified on measurement

  1. Required item (a)'s literal second clause is not implementable as worded. It re-fires 30 landed tombstones in driver.zod.ts (all create: retiredKey(capRemoved('create',, landed in d9fa683aaf) — 30 in the tree simulation, 27 on the landed diff itself. It re-creates the exact false positive this card removes. The round shipped the narrower branch; the reviewer ratified it independently.
  2. The FAIL record's census was arithmetically wrong. Measured: 254 across 66 files = 178 not closing on the key line (148 + 30) + 76 closing (61 / 15 / 0). The record's 148 + 106 is a suffix classification — 76 + 30 = 106 and 15 + 30 = 45, both verified. ⇒ refusing to write it into the gate header was correct: a gate FAILed once for carrying a false sentence must not be repaired with a second one.

⚠️ The seat's own count was off by one — this seat measured 253 / 147 where the reviewer measured 254 / 148 (the 76 and the 30 agree). The reviewer calls it immaterial and it moves no verdict, but it was the seat's error and is recorded here rather than quietly dropped.

⚠️ What this landing does NOT settle

The reviewer constructed an exploit against the code it passed: legacy: retiredKey(/\(/.source).or(z.string()), reads T1 at base and is silent at this head, because matchingCloser answers -1 for "cannot parse" exactly as for "still open". Judged non-blocking — population 0 of 254 key lines and 0 of 322 historical rows (control: 49 of 322 carry a quote), no honest spelling, and base only caught it via the false positive being removed.

It is carried on #18488, filed before this PR was enqueued so the record cannot be read as settling it. That card also carries the reviewer's recommended string-aware fail-safe, the firing pin, and the correction to the header's over-stated "exactly ONE shape" sentence.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 16, 2026 15:35
@os-warren
os-warren added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit bf61f0a Sep 16, 2026
47 checks passed
@os-warren
os-warren deleted the claude/issue-17955-widening-tells-tombstone-t1 branch September 16, 2026 16:00
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…he name-shape token list (objectstack-ai#18486)

Part of objectstack-ai#18123 — step ② of ruling A on objectstack-ai#18115, executing batch objectstack-ai#139 item
3 (director seat, maintainer 「同意」 2026-09-16T01:57Z): 「名字表退休」.

⚠️ Deliberately **not** a closing keyword: one acceptance item on the
card is answered conservatively rather than literally, and the reviewing
seat should rule on it before the card closes. It is named at the
bottom.

⚠️ **Second round.** The isolated at-tier contract review (PR comment
`5700807570`) returned **FAIL** on an undeclared repeal — documentation
and pins only, ⛔ no behaviour change. This revision carries its five
required changes; the census is unmoved at **185 / 0 offenders**.

## What changed

The census admitted a numeric key three ways: a unit in its
`.describe()` prose, a unit token in its key name, or a match against a
25-token list of words that *read like* a duration (`timeout`, `ttl`,
`interval`, `window`, `stale`, `age`, …). **The third one is retired.**

- the token-set constant and the predicate that read it are **deleted**.
A self-test case reads this file's own source and asserts both
identifiers are absent — with a positive control, and with the
identifiers assembled from fragments so the assertion cannot match
itself.
- the **closed duration vocabulary** (`DurationMs` / `DurationSeconds`,
landed by step ① in objectstack-ai#18122) is read as an admission channel, exactly the
way the `EpochMs` instant root already was: the chain is *walked* to its
root, not peeked at. The type states the unit at the authoring site and
in the published JSON schema, so it waives the key-NAME requirement and
nothing else — both contradiction directions stay refusable as
`duration-unit-contradicts-schema`.
- a `dimensionless` schema marker joins `externalVocabulary` in the same
reader, under the same literal-only validation (an empty or computed
value declares nothing and exempts nothing).
- `--list` no longer surfaces name-matched rows; the summary line
reports declared duration types beside declared instants.

**There is no committed baseline to shrink.** Measured, not assumed:
this gate has no ledger file — its own header records 「不考虑存量」 and the
verdict line prints `zero offenders, no baseline`. The card's "`--list`
baseline updated in the same PR" has no artefact to point at. The census
counts below are the reading that stands in for it.

## Before / after — every number measured on this tree

| reading | before (`85c6d76ec4`) | after (`77d8a5337f`) |
|---|---:|---:|
| census rows (`--list`) | 208 | **185** |
| numeric keys scanned | 1036 | 1036 |
| source files walked | 2507 | 2507 |
| offenders | 0 | **0** |
| declared `EpochMs` instants | 5 | 5 |
| declared `externalVocabulary` mirrors | 11 | 11 |
| declared duration types | — | 0 |
| declared `dimensionless` numbers | — | 0 |
| `--self-test` cases | 78 (exit 0) | **103 (exit 0)** |

Census diff, both directions: **23 removed, 0 added.**

## The 23 rows that left, named

Every one is unit-free through **every** channel this file reads — key
name, describe, JSDoc, and value-chain type — checked one at a time, not
in aggregate:

```
counts (9)        reflectionInterval · slidingWindowSize · contextWindow · minContextWindow
                  summariesStale · totalSummariesStale · snapshotInterval · snapshotRetention · staleKeys
multipliers (5)   backoffMultiplier  x5  (integration/connector, kernel/events/handlers,
                                          shared/retry-policy, system/logging, system/worker)
instant (1)       shared/connector-auth.zod.ts  tokenExpiry   ("Token expiry timestamp")
unit-nowhere (8)  api/contract duration · data/driver idle · data/field-value duration
                  kernel/plugin-lifecycle-advanced shutdownTimeout · kernel/plugin-security-advanced maxAge
                  system/auth-config updateAge · system/metrics ageBuckets · system/metrics slideInterval
```

⚠️ **The ruling predicted 17; the census loses 23 — and both numbers are
right.** objectstack-ai#18078's own controlled census recorded it first: 「11 + 5 + 1 =
17; 17 + 6 = 23」. The 23 is the MECHANICAL population (`census AND
keyUnits = 0 AND proseUnits = 0 AND jsdocUnits = 0`); the 17 is the
dimensionless SUBSET inside it, and the other 6 are the genuine
durations step ③ (objectstack-ai#18124) converts. This PR's set was derived
independently, from a before/after census diff, and it matches objectstack-ai#18078's
row for row.

The 6 genuine durations, still unconverted and now out of the census:

```
api/contract.zod.ts:311                     duration          (no describe)
data/field-value.zod.ts:424                 duration          (no describe)
kernel/plugin-lifecycle-advanced.zod.ts:413 shutdownTimeout   "Maximum time to wait for graceful shutdown"
kernel/plugin-security-advanced.zod.ts:623  maxAge            (no describe)
system/auth-config.zod.ts:563               updateAge         "Session update frequency"
system/metrics.zod.ts:412                   slideInterval     (no describe)
```

⛔ No key is renamed or retyped here — that is step ③'s work, and the
card fences it.

⚠️ One correction against a first pass of this PR's own analysis,
recorded rather than quietly fixed: reading the 23 by their
`.describe()` alone put `data/driver.zod.ts:414 idle` and
`system/metrics.zod.ts:224 ageBuckets` among the genuine durations,
giving 15 + 8. Both have NO describe, so the describe-only read had
nothing to go on; objectstack-ai#18078 read them in source and classified them as
counts (pool connections; "Number of age buckets"). Its split is the
correct one and is what appears above. The mechanical 23 — which is what
this change actually keys on — was identical either way.

## Judged in BOTH directions — this gate now judges FEWER keys

That is an accept-set widening in the gate's own terms, so each
direction is measured rather than argued.

**Does any key judged today fall out?** Zero — and the zero is
admissible: the gate reports `0 offenders` on both trees, so no active
refusal was lost on any row; and each of the 23 was re-read through all
four channels with a **positive control on the 185 rows that stayed**
(name 169, prose 160, JSDoc 75, instant 5, mirror 11 — a non-empty
reading from the same probe).

**The adversarial input just outside the predicate's boundary.** The
failure shape this seat was burned by on PR objectstack-ai#18427 is a predicate that
reads only the *opening* of a value, so a live key goes silent by
chaining one more method on. Pinned as self-test cases:

- `DurationMs.or(z.string())` → still resolves to the duration root,
still refused for its contradicting prose. The chain is walked.
- `z.number().or(z.string())` under a `ttlMs` name → still
`name-unit-contradicts-prose`.
- `z.union([DurationMs, z.string()])` → a `z.union` root, **outside the
population**. Pinned as a known boundary, not discovered later: it is
the pre-existing behaviour of the instant root too, unchanged by this
PR.

**What the retirement COST, stated rather than hidden.** The
`unit-in-jsdoc-not-in-describe` class (objectstack-ai#15939, batch objectstack-ai#65) was guarded by
the retired name-shape predicate, whose reach was the key's **stem** —
so it admitted unit-suffixed names too, and **two** shapes stop being
refused here, not one.

**(a)** A bare list-shaped key that declares nothing — `timeout`,
`window`, `interval` with the unit in a JSDoc alone. Those three were
the class's original positive controls and **all three stop being
refused**. They are pinned as explicit `COST of 退休` self-test cases,
plus a "route back" case showing the same key typed `DurationMs` is
admitted and refused again — so a future change that re-admits them goes
red and says what it is re-opening.

**(b)** a key whose stem was in the retired list AND whose name carries
a unit token, whose JSDoc names the SAME unit and whose describe names
none (`timeoutMs` + JSDoc "in milliseconds" + describe 'Maximum
execution time'; `intervalSeconds` + JSDoc "in seconds" + no describe)
was refused by the base gate as `unit-in-jsdoc-not-in-describe` and is
not refused here. It is not refused because refusing it reds `latencyMs`
/ `frequencyHours` on `main` (objectstack-ai#18075's ordering constraint: remediation
before widening), ⛔ not because agreement is not an offence — that is
objectstack-ai#18075's open question.

Shape (b) is a **declared deferral, not a decision**, and it is now
pinned as one: two self-test cases labelled `DEFERRED to objectstack-ai#18075` hold
both fixtures at `''` today, so deleting the agreement carve-out goes
red. Before this round, deleting it reded **0** cases — the repeal was
invisible in both directions. What survives as a live refusal is the
half resting on a declaration the JSDoc **contradicts**: a key whose
NAME carries a unit its JSDoc disagrees with.

**objectstack-ai#18075 is not addressed here, and is not pre-empted.** It reports that
this same branch is gated too narrowly and that two rows of the ruled
shape escape (`latencyMs`, `frequencyHours` — measured). Both have a
JSDoc unit that **agrees** with their key name, so the agreement
carve-out fires on neither: its ordering constraint (remediation before
widening) is untouched and that card remains open and still meaningful.
⛔ Nothing here rules on whether agreement is an offence. An earlier
`judge()` comment in this PR argued that it is not — which adjudicated
that open card while this body claimed it was not pre-empted — and it
has been replaced with a deferral naming objectstack-ai#18075.

## Ablation — the new cases are red without the change

Mutation proven on disk before any result was read (anchor grep
before/after); restore proven by blob hash **and** an empty `git diff
HEAD`, never by an exit code; both legs under a `trap ... EXIT INT TERM`
with absolute paths.

| ablation | self-test | cases that went red |
|---|---|---|
| **delete the agreement carve-out** (`&& !jsdocUnits.some(...)`) | exit
1 | **2** — both `DEFERRED to objectstack-ai#18075` cases. Before this round: **0** |
| empty the closed duration vocabulary | exit 1 | **6** — composition
corrected below |
| re-admit ONE retired name token (`timeout`) | exit 1 | 3 — both
`RETIRED:` cases and the undeclared-JSDoc case |

⚠️ **Correction to this PR's own first ablation table.** The vocabulary
leg was reported as 6 red *including "the module-coupling case"*. That
case is `for (const root of DURATION_ROOTS.keys())` — with the map
literally empty the loop body never runs, so it **cannot** go red; it
silently registers **zero** cases instead. The mutation differed from
its description. Re-measured on this head the leg is still 6, but the
sixth is a different case: `admitted by TYPE: a DurationMs key needs no
unit in its name`, which was **vacuous** before this round and now
asserts `durationType === 'DurationMs'`. **On the reviewed head the true
count was 5.**

⚠️ Measured while re-running it: under the vocabulary ablation the
self-test registers **101** cases, not 103 — the two per-root
module-coupling cases de-register along with the map. A printed case
count is evidence, not proof; this gate predates the
battery/floor/handshake discipline (noted below).

Restores proven on every leg by **blob hash** and an empty `git diff
HEAD`, never by an exit code; mutations proven **on disk** by anchor
grep before/after with lit and dark controls; every leg under a `trap
... EXIT INT TERM` with absolute paths. Carve-out leg: HEAD blob
`556ad8fa4abfc3f0baf8de1a7b66f8c1b3fdd835`, mutated blob
`a17c4174f20555fa42ef759f46de7f2c46f473b0`, restored blob
`556ad8fa4abfc3f0baf8de1a7b66f8c1b3fdd835` == HEAD, `git diff HEAD` exit
0, restored self-test exit 0 / 103 cases / 0 red.

A third proof arrived unplanned: the source-scan case **failed for
real** during development (15:19:08Z, exit 1, one case) on a leftover
mention of the retired identifier in a comment this PR itself added. It
is not a case that cannot fail.

⛔ One ablation attempt was discarded rather than reported: an earlier
spelling injected an undefined identifier, so the run exited 1 by
**crashing** with zero red cases. A crash is not a red gate; it was
re-run with an expression that compiles, and the table above is that
second run.

## Verification

- `tsx scripts/check-duration-unit-keys.ts --self-test` → exit 0, 103
cases
- `tsx scripts/check-duration-unit-keys.ts` → exit 0, `185
unit-declaring numeric key(s) ... zero offenders, no baseline`
- gate families derived from the real change set with `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (56 commands) — all 56 run, all exit 0
- exit codes captured by redirect-then-`$?`, never through a pipe

## Changeset: `skip-changeset`, measured

`packages/spec` is published, but its `files[]` is `dist`,
`json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`,
`src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`, `spec-changes.json` —
**`scripts/` is not in it**. The diff is one file under
`packages/spec/scripts/`, and no published path imports it (0 importers;
control: 50 files in the same directory import a sibling `./lib/`
module). Positive control for the symbol grep: `DurationMs` returns 14
hits over the same published pathspec, so the zeros are readings rather
than a broken probe.

## Acceptance notes

- **The one open item, for the reviewing seat.** Acceptance (e) reads "a
prose-only unit still does NOT admit". Taken literally that removes the
describe channel from admission, which measurably deletes the gate's
founding rule (objectstack-ai#14478 ruling B: `ttl` + "in seconds" is an offence)
along with the **eleven landed self-test assertions that pin it**
(counted on the base blob; one of them pins four offences of that rule
at once), and makes `externalVocabulary` inert — all 11 declared mirrors
are prose-admitted rows with no unit in their names, so they would leave
the census and the marker would stop being read for anything, against
objectstack-ai#15676 ruling B's 「exemptions stay visible and counted」. Neither
consequence is mentioned anywhere in the ruling chain, and the ruling's
own arithmetic (「17 个无单位数自然出列」) describes 23 rows leaving, not 34. This
PR therefore keeps prose as an admission channel and retires only the
name shape — the **narrower** accept set of the two readings. Flipping
to the literal reading is one line in `declaresUnit`. ⛔ Not decided
here.
- **Frame 3's marker is implemented as a reader, applied to zero keys.**
The card says to apply it to all 17 dimensionless rows "or the new
refusal reds on them" — that premise was withdrawn with the detector.
Measured: with the list retired those rows leave the census on their
own, nothing refuses them, and marking them would add metadata to
published schemas that the gate never reads. The channel exists for the
shape it can actually save: a key whose *prose* names a time unit
belonging to something else in the sentence.
- Noted, not filed: `packages/spec/scripts/check-generated.ts:248` still
describes this gate as auditing "a duration-shaped `z.number()` key" —
stale vocabulary now that the name shape is retired. Prose only, no
behaviour; left alone rather than widening this diff to a second shared
file for a string. Carrier: objectstack-ai#18124.
- Noted, not filed: naming the retired predicate in prose is a trap this
round hit and backed out of — this file's own retirement pin asserts
that identifier is absent from its source, so a comment that spells it
out reds the pin. The header and the new pin comment therefore
**describe** the retired guard instead of naming it. Carrier: this PR.
- Noted, not filed: this gate's `--self-test` predates the
battery/floor/handshake floor in AGENTS.md (no `SELF_TEST_BATTERIES`, no
verdict flag), so a `return` above its verdict would print nothing and
exit 0. Retrofitting it is its own card and touches every gate of its
generation. Carrier: no PR currently in flight touches this file.
- Noted, not filed: `ageBuckets` and `slideInterval`
(`system/metrics.zod.ts`) and `duration` (`api/contract.zod.ts`,
`data/field-value.zod.ts`) carry no `.describe()` at all, so neither
this gate nor the reference page can say what they are. They are in step
③'s population. Carrier: objectstack-ai#18124.

Authored by the `domain:spec` execution agent in session
`session_01KB5PFtxuy1x3dcR5gxudx6`, dispatched on objectstack-ai#18123.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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

Projects

None yet

2 participants