feat(spec): a VALUE-level retirement mechanism beside retiredKey() - #19215
Conversation
`retiredKey()` retires a KEY. A ruled enum-member retirement had nothing to land on: two enums (`HookBodyCapability.crypto.hash`, `object.managedBy: 'system'`) had each hand-rolled the same error-map judgement in a local comment, while `spec-changes.json` twice recorded the opposite conclusion — "a removed ENUM MEMBER cannot carry a retiredKey() fix-it error" — and those retirements shipped with no prescription at all. `enumWithRetiredValues(values, retired)` builds the enum with a named refusal per retired member, carrying its migration prescription. Live members and unrelated typos keep zod's own message. Both authoring channels match a key tombstone: the member is absent from `z.input` (tsc) and the parse raises the prescription rather than an anonymous `invalid_value`. It refuses at construction a retirement that could never fire — a member declared retired but still listed, an empty map, a blank prescription — so the mechanism cannot ship as a declaration with no enforcement. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…'s leniency The first spelling of this case asked zod for the message and asserted it was a string listing the legal tokens. Measured by ablation: dropping the `hasOwnProperty` guard left all 15 cases green, because zod 4.4.3 ignores a non-string error-map return and falls back to its own message. So the case was pinning zod's leniency, not the helper's lookup, and could not fail. It now asks the helper's own error map directly — `string` for a retired member, `undefined` for an inherited name or a non-string input — which is the contract the guard actually keeps. The end-to-end assertion stays beside it. The schema comment no longer claims an observable consequence the measurement refutes. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…asures The docblock said a value prescription is judged "wherever in the tree it is declared". Measured by ablation: planting the withdrawn automatic-rewrite spelling in this helper's own test fixture left the pin green, because its corpus walk yields non-test `.ts` only. That boundary is correct — a fixture is not a shipped prescription — but the sentence overstated it, and the fixture is therefore not evidence the pin fires. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 48528b3739e0052aa9c17d348f35b65214166b3a && git checkout 48528b3739e0052aa9c17d348f35b65214166b3a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0046a41b43da8e7d2db9d7f926cc0518a357c379 6c594c4288df62ef44098f7303e80c2f3d5e4695 && git checkout -B drift-repro 0046a41b43da8e7d2db9d7f926cc0518a357c379 && git merge --no-ff 6c594c4288df62ef44098f7303e80c2f3d5e4695
node scripts/docs-audit/affected-docs.mjs --json 0046a41b43da8e7d2db9d7f926cc0518a357c379
|
`check:adr-anchors` scans `*.test.ts`, so the synthetic prescriptions' `(ADR-0000)` was a citation promising a record that does not exist — and a squat on a number whoever writes it would retroactively falsify. The gate read exit 1 on this branch while exit 0 on main, naming both fixture lines. The reference is dropped rather than repointed: an ADR id is the one element of a prescription this repo RESOLVES, so a fictional retirement cannot carry one honestly, and a number that does resolve would let a fake retirement claim governance by a decision that never ruled it. The rest of the house shape is unchanged, and a note beside the constants says why the element is absent so it is not added back. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgmentsReviewed in a detached worktree of the head sha against merge-base
② Semver levelNone owed — The ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Provenance — at-tier contract review adopted, carriers cleared, landing three checks passedWritten by the Record of record: comment 5746455074 on this PR — Tier evidence, read off the reviewer's own transcript (per-request harness ⛔ ② adopted as the ruling on this PR's declaration. The review resolves The three landing pre-checks, each read rather than recalled:
The red this PR actually carried, and how it was cleared — recorded because the fix is a shape that will recur. Head Follow-up this seat takes, from the review's ② note: Landing now: ready → auto-merge → the merge queue. ⛔ No out-of-queue merge. Generated by Claude Code |
Correction to my own provenance comment 5746465515 — one of the two tells is NOT falseSame seat, 2026-09-20T00:42Z. ⛔ The earlier comment is not rewritten; this supersedes its last paragraph only. I wrote there that
Three-leg probe on a synthetic diff through the gate itself, so the classification is not an opinion:
Leg B is what rules out "the concatenation-fragment guard is broken" — that guard (#16822, landed in PR #16944) is demonstrably still working on the identical shape. Filed as #19221, covering the ⛔ Nothing here changes this PR's disposition: the record of record is still 5746455074 (PASS), and the landing is already armed. Generated by Claude Code |
Fixes #17109
Clause-②: yes
Ruled deliverable (triage comment 5621764253, maintainer decision 2026-09-09): the generic helper, ⛔ not a one-off refinement on the single enum that exposed the gap. Nothing in this diff names a member of any live vocabulary — the only member names present are in a synthetic test fixture, which is what "general to any
z.enum" means here.The premise, re-derived before building
The card and the triage both ask for this, and name the firing control. Swept
origin/mainat24d622b94b:retiredKeyresolvespackages/spec/srcThe premise holds. What the tree does have is the same judgement hand-rolled twice, each site re-deriving it in its own comment:
packages/spec/src/data/hook-body.zod.ts—HookBodyCapability, forcrypto.hashpackages/spec/src/data/object.zod.ts—managedBy, for'system'and, pulling the other way, two ledger entries in
spec-changes.jsonrecording the opposite conclusion as settled — "a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can" (data.field.changed, and the sharing-rulefullretirement). Both of those retirements shipped with no prescription at all. So the tree holds both "it cannot be done" and "it was done twice by hand", which is exactly the state one mechanism settles.The
spec-property-retirementskill agrees from the third side: its §2 route table has three removal routes, all key-addressed, and says in as many words that none of them applies when the def survives and only a value leaves.What landed
enumWithRetiredValues(values, retired), besideretiredKey()inpackages/spec/src/shared/retired-key.ts. It builds the enum from its live vocabulary and attaches a prescription per retired member.Both authoring channels match a key tombstone:
tsc— the retired member is absent fromvalues, soz.inputno longer admits it. This falls out of the shape rather than being bolted on: a member is declared inretired, never invalues.invalid_value. Every other member, and every unrelated typo, keeps zod's own message, which already lists the legal tokens. Telling the author ofvariant: 'headng'that their value "was removed" would misinform.It refuses at construction a retirement that could never fire: a member declared retired but still listed in
values(the enum would accept it and the prescription would be dead declaration), an empty map, a blank prescription. A mechanism whose misuse is a silent no-op is the shape this repo refuses, so the misuse is loud at module load.Posture matches its sibling: internal to
packages/spec, not added to any barrel, exactly asretiredKey()is. Nothing outside the package is needed for it to be enforced — the refusal IS the enforcement, at parse.The card's three design questions, answered by measurement
Does the diagnosis need to name a replacement? It carries whatever the ruling gives it — free text, same as
retiredKey()'sguidance, written to the same five house conventions. The one ruled instance wants a replacement plus an escape ("or pick the level you mean"), which prose carries and a mapping does not.Is the hint free text or a structured record? Free text, deliberately. The machine-readable channel already exists one layer out (the ADR-0087 conversion registry and the generated upgrade guide); a second vocabulary for the same fact is how two vocabularies drift apart. The house sentence is pinned class-wide by
retired-key-migrate-sentence.test.ts, a plain text scan with no dependency onretiredKey(), so a value prescription is judged by the same pin with no change to the pin. Measured boundary: its walk yields non-test.tsonly, so the fixture in this PR is deliberately not judged by it — recorded in the docblock rather than left to be discovered.Where does a retirement get registered? Nowhere new, and the docblock says so rather than inventing a second ledger:
RETIRED_KEYS_BY_MAJORis keyed by a KEY spelling and the liveness ledger is walked per schema PROPERTY, neither of which a member is; per the skill's own table an enum-value narrowing is byte-invisible to all four generated ratchets. The channels a narrowing owes are its ADR-0087 conversion and its changeset — which is the first actual retirement's work, and that is off the back of this card by its own appetite.A fourth the card raised: what a defaulted enum does. Measured — an omitted key still materializes the default untouched (absence never meets the refusal), and an explicitly authored retired member still refuses. The one population this helper does not cover is a retired member that WAS the default; that judgement already exists as
acceptRetiredDefaultResidue, and the docblock points at it instead of half-answering it.Verification
All on final head
9f44e9b51dunless stated.pnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typecheckcheck:test-typecheck)pnpm --filter @objectstack/spec check:generatedcheck:api-surface,-declarations,dual-source-exports,entry-nameability,exported-any,browser-reachable-entries)dispatch-gates --ranreconciliationThe three NOT MEASURED are
check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debt— each exited 3, the code a gate uses for "prerequisite not met, nothing was measured". All three want a full repo build, which is CI's run, not this PR's local half.Zero generated artifacts moved, which is the reading the skill's §2 table predicts for this shape: no def, export or key changed, and the new symbol reaches no published entry point.
Ablations — the pins can fail, and one of them could not at first
Every leg used
scripts/ablation-replace.mjs(anchor must hit, on-disk write proved, restore verified against the HEAD blob); all four restored byte-identical withgit diff HEADempty.hasOwnPropertyguard, first attempthasOwnPropertyguard, after the pin was rewrittenThe third row is the honest one. The prototype-inheritance case originally asked zod for the message and asserted it was a string listing the legal tokens — and zod 4.4.3 ignores a non-string error-map return and falls back to its own message, so the naive lookup is indistinguishable at the message level. The case was pinning zod's leniency, not this helper's lookup, and could not fail. It now asks the error map directly (
stringfor a retired member,undefinedfor an inherited name or a non-string input), which is the contract the guard actually keeps, and the schema comment no longer claims an observable consequence the measurement refutes. Reported rather than quietly re-run, because a vacuous pin that gets retried until something goes red is the same defect one layer up.A fifth leg tested the claim that the class-wide migrate-sentence pin covers a value prescription: planting the withdrawn automatic-rewrite spelling in the fixture left the pin green, because its walk excludes
*.test.ts. That is the right boundary — a fixture is not a shipped prescription — so the docblock was narrowed to what was measured rather than the claim being dropped or left standing.Changeset — measured, and it needs one label this dispatch may not write
This diff publishes nothing from any released package, measured against
@objectstack/spec's ownfiles[]after a real build, with positive controls:enumWithRetiredValuesRetiredValueGuidanceacceptRetiredDefaultResidueretiredKeyZero hits with both controls hitting, so
skip-changesetis the correct declaration. This dispatch is scoped to no label writes, so the seat owns applying it; until it lands,Check Changesetis expected red, and that red is not a finding about the code.Acceptance notes
Observed and deliberately not acted on, none of them a filing class:
HookBodyCapability,object.managedBy) are exactly what this helper generalizes, and converting them is a pure mechanical simplification. Left alone: the declared file surface for this card is the helper and its sibling test, the tree shows this change lands without touching them, and converting a live vocabulary's error map is a behaviour-preserving edit that still wants its own review. Whoever lands the first real value retirement is the natural carrier.spec-changes.jsoncarries two rationales asserting a retired enum member "cannot carry a retiredKey() fix-it error". That prose is now out of date as a general claim. It is release-ledger prose about a past retirement, not a live contract, so correcting it belongs to whoever next edits those entries — not to a code PR.packages/spec/src/shared/index.tsexports neitherretired-keynor this helper. That is the existing posture, kept on purpose, not an omission to repair here.🤖 Generated with Claude Code
Generated by Claude Code
Generated by Claude Code