fix(pm): a retiredKey() tombstone declares a key unwritable, not a new spelling - #18427
Conversation
…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>
Contract review93/93 at-tier stamps (control: an ① Derived judgmentsDiff = 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:
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 ② Semver levelNo 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 ③ Boundary flags
Implemented-by: 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>
Contract review88/88 at-tier stamps (control: an ① Derived judgmentsDiff = 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:
② Semver levelskip-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
Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance — at-tier PASS at the current head, landing pre-check clear
Landing pre-check, all three: ① the PASS above names this head. ② ⭐ The round diverged from the FAIL record twice, and BOTH divergences were ratified on measurement
|
…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>
Fixes #17955
check-widening-tellsraised 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()returnsz.never(…).optional(). The line it is written on makes the accept set strictly narrower: the key'sz.inputbecomesneversotscrefuses 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
5696399878judged 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 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: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 ind9fa683aaf— 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
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.check-widening-tells' T2BARE_STRING_ELEMENTfires 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 atretiredKey(, 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).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.packages/spec/src/system/metadata-form-zod-reconciliation.test.ts, whichsurfaceFlagsputs off the contract source surface (onContractSource: false; control —tenant.zod.tsreadstrue).Judged in both directions — this is a gate other PRs must pass
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
retiredKey(key line with no paired removalgit show fc28c1d381, the landed PR #17954T1 packages/spec/src/system/tenant.zod.ts:454 + schemaCacheTTL: retiredKey(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.retiredKey(stays inSCHEMA_PROPERTY's measured vocabulary.memberTellKindstill answersT1for a tombstone line, so both sides of the budget keep reading one question. It is the tell that declines, never the vocabulary that shrinks.check-clause2-carriersT1 reads a function PARAMETER annotatedctx: z.RefinementCtxas a new authorable key — so every PR that adds an object-level refusal raises a widening tell for the refusal itself #17618's parameter does. Un-retiring a key — droppinglegacy: retiredKey(…)and putting a live schema back on that spelling — is a real widening, and a removed tombstone that bought the replacement would trade a loud failure for a silent one on the only diff shape that RE-OPENS a closed accept set. That leg gains diagnostics.⛔ 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):
retiredKey()tombstones by the very predicate that declines them — checked row by row, 0 exceptions — spread over 23 commits and 45 filesPopulation on the judged surface today: 254 tombstone key lines across 66 files.
The self-test is the thing that must be capable of failing
✗ … 9 of 288 case(s) failed, exit 1.git hash-objectbefore == after == theHEADblob):✗ … 5 of 298 case(s) failed, exit 1 — the three firing controls plus the two reader cases.✗ … 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-spelled —
t('`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-tells— exit 0, 298 casesdispatch-gates --ranagainst a list recorded command-by-command withstatus=$?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)"node scripts/pm/check-clause2-carriers.mjs --pair 18427exit 0,pnpm check:pm-prior-rulingsexit 0 (99 cases)pnpm check:nul-bytesexit 0; control-byte self-scan over the changed file: no hits (grep -naPexit 1, zero lines)check:scripts-symbol-anchorscaught 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 lintis CI's run. This is the narrowing and the proof it excluded nothing:ESLint#isPathIgnoredover the 8,728 tracked files): 6,786.--format jsonoutput — 0 errors, 0 warnings, exit 0.eslint.config.mjsstates it "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) 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-monorepoisprivate: truewith nofiles[]; no package'sfiles[]shipsscripts/; and the positive control — the symboldeclaresRetiredKeyTombstone— has 0 occurrences anywhere underpackages/. Nothing published moves. Round 2 adds no export and no published key, soClause-②: nostill 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.tsfile, 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
Clause-②: noholds and did not flip: repairing a false positive moves no published accept set, and the one new export (declaresRetiredKeyTombstone) is a script-local reader in a repo-rootscripts/file that no package publishes.tellsInFilecan SPEND a T1 budget unit before [finding]check-clause2-carriersT1 reads a function PARAMETER annotatedctx: z.RefinementCtxas a new authorable key — so every PR that adds an object-level refusal raises a widening tell for the refusal itself #17618'sinParameterListdecline is reached. Same ordering asymmetry this PR fixes for tombstones, on the parameter reading instead. Carrier: none — no queued card touchesinParameterList.Round 2 by
session_01KB5PFtxuy1x3dcR5gxudx6.Generated by Claude Code