Skip to content

feat(spec): a VALUE-level retirement mechanism beside retiredKey() - #19215

Merged
os-bill merged 4 commits into
mainfrom
claude/issue-17109-value-level-retirement
Sep 20, 2026
Merged

os-bill merged 4 commits into
mainfrom
claude/issue-17109-value-level-retirement

Conversation

@os-bill

@os-bill os-bill commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

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/main at 24d622b94b:

probe reading
control — retiredKey resolves 351 hits across packages/spec/src
a value-level equivalent under any other name 0

The 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, for crypto.hash
  • packages/spec/src/data/object.zod.ts — managedBy, for 'system'

and, pulling the other way, two ledger entries in spec-changes.json recording 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-rule full retirement). 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-retirement skill 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), beside retiredKey() in packages/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:

  1. tsc — the retired member is absent from values, so z.input no longer admits it. This falls out of the shape rather than being bolted on: a member is declared in retired, never in values.
  2. The parse — the member raises its own prescription instead of an anonymous invalid_value. Every other member, and every unrelated typo, keeps zod's own message, which already lists the legal tokens. Telling the author of variant: '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 as retiredKey() 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()'s guidance, 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 on retiredKey(), so a value prescription is judged by the same pin with no change to the pin. Measured boundary: its walk yields non-test .ts only, 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_MAJOR is 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 9f44e9b51d unless stated.

run verdict
pnpm --filter @objectstack/spec build exit 0
pnpm --filter @objectstack/spec test exit 0 — 14605 passed / 1 skipped, 498 files
pnpm --filter @objectstack/spec typecheck exit 0 (includes check:test-typecheck)
pnpm --filter @objectstack/spec check:generated exit 0 — all 16 generated artifacts up to date, none regenerated
the six dist-reading gates (check:api-surface, -declarations, dual-source-exports, entry-nameability, exported-any, browser-reachable-entries) exit 0 each
dispatch-gates --ran reconciliation 77 derived, 74 run green, 3 NOT MEASURED, 0 unrun

The three NOT MEASURED are check:dual-build-cjs-loads, check:lean-entry-closure and check: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 with git diff HEAD empty.

leg expected measured
neutralise the prescription lookup red red — 3 cases
let a still-listed member through the construction guard red red
drop the hasOwnProperty guard, first attempt red GREEN
drop the hasOwnProperty guard, after the pin was rewritten red red

The 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 (string for a retired member, undefined for 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 own files[] after a real build, with positive controls:

symbol files under shipped paths
enumWithRetiredValues 0
RetiredValueGuidance 0
control — acceptRetiredDefaultResidue 18
control — retiredKey 75

Zero hits with both controls hitting, so skip-changeset is the correct declaration. This dispatch is scoped to no label writes, so the seat owns applying it; until it lands, Check Changeset is 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:

  • The two hand-rolled sites (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.json carries 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.ts exports neither retired-key nor 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

`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>
@github-actions

github-actions Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 7 documentable anchor(s).

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via api.read (literal, a string literal in a comment on a changed line), api.write (literal, a string literal in a comment on a changed line), crypto.hash (literal, a string literal in a comment on a changed line), crypto.uuid (literal, a string literal in a comment on a changed line))
  • content/docs/automation/hooks.mdx (via api.read (literal, a string literal in a comment on a changed line), api.write (literal, a string literal in a comment on a changed line))
  • content/docs/ui/actions.mdx (via api.write (literal, a string literal in a comment on a changed line))
What this run could not see
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 0046a41b43da8e7d2db9d7f926cc0518a357c379 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 48528b3739e0052aa9c17d348f35b65214166b3a — the merge of head 6c594c4288df62ef44098f7303e80c2f3d5e4695 into base 0046a41b43da8e7d2db9d7f926cc0518a357c379, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 0046a41b43da8e7d2db9d7f926cc0518a357c379 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-bill os-bill added needs:contract-review skip-changeset PR has no user-facing published change; bypasses the changeset gate labels Sep 20, 2026 — with Claude
`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>

os-bill commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6c594c4288df62ef44098f7303e80c2f3d5e4695

① Derived judgments

Reviewed in a detached worktree of the head sha against merge-base 24d622b94b; every reading below was re-run there (pnpm install --frozen-lockfile, then a real pnpm --filter @objectstack/spec build, exit 0). Nothing was taken from the PR body or the dev reports except where ③ says so.

  1. Diff shape — two files, both under packages/spec/src/shared/, 308+/1−. retired-key.ts gains one module-docblock paragraph, the RetiredValueGuidance type and the enumWithRetiredValues function after line 216; retiredKey and acceptRetiredDefaultResidue bodies are untouched. retired-key.test.ts gains one import and a 7-case suite. No .zod.ts, no barrel, no changeset, no generated artifact. Right.
  2. Accept/reject behaviour of every schema that exists today — unchanged. Callers of enumWithRetiredValues / RetiredValueGuidance at head outside the two changed files: 0 (grep over the whole tree). The two hand-rolled sites the docblock names (data/hook-body.zod.ts line 59, data/object.zod.ts line 1718) still carry their own ternaries and were not converted. After the real build git status shows zero tracked artifacts moved and check:generated reports all 16 artifacts up to date, so json-schema/, authorable-surface/, api-surface/ and the docs tree are byte-unchanged. No z.enum changes what it accepts or what message it produces. Right.
  3. Public surface — nothing reaches a published path. packages/spec/package.json files[] is dist, json-schema, liveness, prompts, llms.txt, README.md, src/**/*.zod.ts, CHANGELOG.md, api-surface, api-surface-declarations, spec-changes.json. retired-key.ts is not a .zod.ts, is not one of the 17 tsup.config.ts entries, and src/shared/index.ts (and every other index.ts) re-exports nothing from it at head. Census over every files[] path after the build, with controls: enumWithRetiredValues 0 files, RetiredValueGuidance 0 files; acceptRetiredDefaultResidue 20, retiredKey 153. The module is bundled into 40-odd entry outputs through retiredKey, and the new export is tree-shaken out of every one of them, .d.ts included. check:api-surface, check:api-surface-declarations, check:dual-source-exports, check:entry-nameability, check:exported-any, check:browser-reachable-entries, check:liveness, check:docs: exit 0 each. Right — internal to the package, exactly the posture of its sibling.
  4. The construction-time refusal is total for the declared contract, and refuses no legitimate construction. Own probe (56 checks, run with tsx against the head source): still-listed member, two still-listed members (both named), empty map, blank prescription as spaces / empty string / tab-newline, an undefined value forced past the type, and a mixed map (one listed, one fine) all throw the helper's own message. Accepted, as they must be: the minimal shape, two retired members, a prescription with surrounding whitespace but content, a frozen map, a null-prototype map, numeric-string member names, an own key named constructor or hasOwnProperty, duplicate live values, a readonly as const tuple. Right. One edge outside the type contract, recorded under ③ as a nit: a null value forced past RetiredValueGuidance is not refused (the map then answers null, zod falls back to its own message), and a number forced past it throws a raw TypeError from .trim rather than a helper message — both reachable only by defeating tsc.
  5. Prototype-chain lookup and non-string input. End to end through safeParse: constructor, __proto__, toString, hasOwnProperty, valueOf, isPrototypeOf, propertyIsEnumerable, toLocaleString, __defineGetter__ each get zod's own Invalid option: expected one of … message, never a prescription, no throw; a number, null, undefined, a plain object, an array holding the retired name, a Symbol, a boolean, a String object and a bigint likewise. Asked directly, the error map answers the retired member with its prescription string and answers every inherited name and every non-string input with undefined — never a function. Control on zod 4.4.3 in this tree: an error map returning a function or undefined falls back to zod's message (an object carrying message is honoured, a string is honoured), which is why the message-level pin was vacuous and the direct-map pin is the correct instrument. Right.
  6. z.input erasure. Own tsc --noEmit probe against the head module (a scratch tsconfig extending tsconfig.test.json): the input type of the fixture enum is exactly the five-member live union by a type-equality assertion; assigning 'heading' is TS2322, assigning a typo is TS2322 (so the type is not string), and a second enum built from a widened as const tuple rejects its retired member the same way. The in-tree @ts-expect-error is not a phantom: retired-key.test.ts is in the tsconfig.test.json program, not in test-typecheck-debt.json, and compiles with 0 errors; pnpm --filter @objectstack/spec typecheck exit 0. Right.
  7. Composition. .optional() accepts absence and refuses the retired member with the prescription at path variant; .default('body') materializes on an omitted key and on an explicit undefined, still refuses the authored retired member; inside z.array the prescription lands at path [1]; .options is the live set only; .describe() composes. Right.
  8. The pins discriminate — re-ablated here, not taken on the PR's word. Each leg edited the head source in the worktree with an anchor that had to hit exactly once, ran vitest run --project local src/shared/retired-key.test.ts, then restored with git checkout HEAD and compared git hash-object to the HEAD blob f367f8ba01: (a) neutralise the prescription lookup → red, 4 of 15 (own prescription, inherited-member pin, .optional(), .default()); (b) drop the hasOwnProperty guard to a bare retired[input] → red, 1 of 15 (the rewritten inherited-member pin — the leg that was green before the rewrite); (c) let a still-listed member past the construction guard → red, 1 of 15. All three restores byte-identical. Clean run: 15 of 15. Right.
  9. The ADR citation drop and the house shape. node scripts/check-adr-anchors.mjs --self-test (106 assertions) and the check itself: exit 0 at head. The two fixture strings keep the backticked fully-qualified name, the version, the dash clause, the imperative fix and (on the first) the pinned os migrate meta sentence; only the fictional ADR id is gone. Gates that walk prescription text, all at head: check:adr-0087-registration (self-test 441 assertions, then --base merge-base: no declared-breaking changeset, exit 0), check-empty-changeset exit 0, check-changeset-no-major exit 0, check:doc-authoring exit 0 (1335 tombstone strings clean), and the class-wide migrate-sentence pin retired-key-migrate-sentence.test.ts under its repo project: 14 of 14. Right.
  10. The narrowed docblock claim is true. The pin's walk() yields .ts and drops .test.ts / .spec.ts, so the fixture is outside its corpus by construction. Measured both directions: planting the withdrawn automatic-rewrite spelling in the fixture left the pin green (14 of 14); planting the same spelling in a code string literal of retired-key.ts turned it red (3 of 14) naming spec:shared/retired-key.ts:329. So a shipped value prescription in this very file IS judged by the pin with no change to it, and the fixture is not, which is exactly what the sentence now claims. Both files restored byte-identical (f367f8ba01, 8badd4fb44). Right.
  11. Docblock citations. The three-route table in the retirement skill's §2 is key-addressed and the skill itself says none of the routes applies to a def that survives with one value gone; the skill's ratchet table says an enum-value narrowing is byte-invisible to the four generated ratchets; RETIRED_KEYS_BY_MAJOR rows are spelled 'defKey:name' (e.g. api/AuthFeaturesConfig:magicLink); the liveness walk is per property. Right, with one wording nit under ③ about the spec-changes.json sentence.
  12. Lint. eslint on the two files: exit 0.

② Semver level

None owed — skip-changeset is the correct declaration, and it is consistent. Re-derived on the built tree (① item 3): no published symbol, key, value, schema file or declaration moves; the new export reaches no entry, no dist byte, no api-surface row. A minor changeset here would publish a CHANGELOG entry for a change no consumer can observe.

The Clause-② question, resolved: this diff is no. The claim comment 5745904292 declared yes and said so explicitly as the conservative routing declaration (「claim 拿不准 ⇒ 按 yes」) that a review may overturn; the PR body mirrors it. The clause-② criterion is whether the accept set widens or the public surface grows, and neither does (① items 2 and 3). So the yes did its one job — it summoned this at-tier review — and is overturned here; per references/contract-review.md an overturned declaration is not a seat fault, and this is the same resolution the record on PR 18485 reached for the same shape. Mechanically: check-clause2-carriers --pair 19215 reads exit 0 on the labels (both carriers hung, declaration readable); the level axis in check-changeset-no-major that would refuse yes with no minor changeset never runs while skip-changeset stands, so no gate reads the body's yes — recorded, not hidden. ⚠️ A re-declaration to no on the card would today be refused by the pair predicate's C5: check-widening-tells --declaration no on this diff fires two false T2 tells (retired-key.ts:329, a prose fragment of the construction error message, and :349, the z.enum(values, { opener that builds the return value from its argument, not a new member of anything) — exit 4. That is a matcher false positive, not a widening, and its remedy is the matcher's own rule (a repair with a self-test case, or a card), never a false declaration. Follow-up under ③.

③ Boundary flags

  • Q1 (who converts HookBodyCapability.crypto.hash and object.managedBy: 'system') — A adopted by the seat, and concur. Both sites still carry their own ternaries at head and both work; converting them is behaviour-preserving, wants a reviewer already looking at that vocabulary, and belongs to the author of the first real value retirement (objectstack#17108 release 2). Not a filing class. Nothing further.
  • Q2 (no 维护者速读 section) — A confirmed by the seat, and concur: the diff touches zero governed paths (Governed Surface Queue Guard success on this head), the body stays English.
  • Carrier split from the first report — closed by the seat's label write; needs:contract-review reads on both card and PR, skip-changeset on the PR. This comment is the record of record for this head.
  • skip-changeset not applied by the dispatch — applied since; correct per ②.
  • Three NOT MEASURED gate families (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt, each exit 3 = prerequisite not met) — not re-run here either; they want a full repo build. CI's Type Check · debt ledger is success on this head, which covers the third.
  • The platform-appended second session-URL footer on the PR body — cosmetic; AGENTS.md forbids re-sending a body that already carries an appended footer. Leave it.
  • Docs Drift Check bot rows (hook-bodies.mdx, hooks.mdx, actions.mdx) — all three were listed via string literals inside the @example block of the new docblock, a comment on a changed line; the HookBodyCapability vocabulary itself did not move (① item 2). No docs change owed.
  • Nit, non-blocking (① item 4): a null or a number forced past RetiredValueGuidance reaches parse or throws a raw TypeError. Only reachable by defeating tsc; if a follow-up wants the guard total at runtime too, typeof retired[name] !== 'string' || retired[name].trim() === '' closes both in one clause.
  • Wording nit, non-blocking (① item 11): the docblock and PR body say spec-changes.json "twice records" the "cannot carry a retiredKey() fix-it error" conclusion as two entries. Measured: the sentence appears twice in the file, both occurrences being the data.field.changed rationale, which cites owd-full-alias-removed as having hit "the same limit"; the owd entry's own text does not carry the sentence. The dev's own out-of-scope note is the right disposition (regenerated prose; whoever next edits those entries).
  • Follow-up for the seat, not this PR (②): the two T2 false positives on a z.enum(values, …) opener inside a helper body and on a concatenation fragment are a demonstrated matcher false positive under check-widening-tells.mjs's own rule; file it against that script (with the --self-test shape) if a clean no on this class of card is wanted, or leave the resolution to this record.
  • Not re-run here, taken from CI at this head: the full spec test suite (14605 tests), the repo-wide eslint sweep and the full workspace type-check. Read at 00:27Z: 35 check runs on 6c594c4288, every one completed, 0 failure, 0 in_progress; all seven required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard) success; Check Changeset skipped on the label. The seat re-reads this as its landing pre-check ③ after the carrier strip, since a strip re-fires Check Changeset.
  • check:adr-anchors scanning *.test.ts (dev's out-of-scope note): concur that the gate is right and the note beside the fixtures is the local remedy; a recognised spelling for a deliberately fictional citation would be a gate change and is not this card's.

Implemented-by: claude/issue-17109-value-level-retirement
Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3

VERDICT: PASS


Generated by Claude Code

os-bill commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Provenance — at-tier contract review adopted, carriers cleared, landing three checks passed

Written by the domain:spec execution seat (session_01JbZnqu8bt6YqfJsr9vaFb3) at 2026-09-20T00:37Z, in the same act as the carrier strip, the ready flip and the auto-merge arm.

Record of record: comment 5746455074 on this PR — ## Contract review, Served-tier: CONTRACT_REVIEW_TIER, Head-sha: = 6c594c4288df62ef44098f7303e80c2f3d5e4695, ①②③ itemised, Implemented-by: claude/issue-17109-value-level-retirement, Reviewed-by: session_01JbZnqu8bt6YqfJsr9vaFb3, VERDICT: PASS. Adopted verbatim; ⛔ not edited, not polished, not summarised into this comment.

Tier evidence, read off the reviewer's own transcript (per-request harness model on every type:"assistant" line, against CONTRACT_REVIEW_TIER imported live from scripts/pm/dispatch-gates.mjs):

CONTRACT_REVIEW_TIER (imported live) = claude-fable-5-1
transcript lines=267  type:"assistant" lines=113  unparseable=0
   113  claude-fable-5-1   ✅ AT TIER
VERDICT: every one of the 113 assistant request(s) is stamped claude-fable-5-1 — the review is AT TIER.

⛔ get_session was not consulted (it measures the dispatching session), and ⛔ the reviewer's own statement about its tier is not evidence. This seat's served tier is below the constant, so ⛔ no in-seat review was permissible and the isolated at-tier subagent was the only route.

② adopted as the ruling on this PR's declaration. The review resolves Clause-② to no for this diff and judges skip-changeset correct and consistent (semver level: none). The claim's yes was the conservative routing declaration — it says so on its own face — and 「声明被复核推翻 ⛔ 不作席位过失」. The published-surface census behind it, re-run by the reviewer after a real build over every files[] path: enumWithRetiredValues 0 files, RetiredValueGuidance 0, against lit controls retiredKey 153 and acceptRetiredDefaultResidue 20; no barrel or entry re-exports retired-key.ts. ⚠️ The PR body still carries the line Clause-②: yes; it is left as written (⛔ no body PATCH after create) and this record is what governs.

The three landing pre-checks, each read rather than recalled:

# check reading
① at-tier clause-② PASS of record on this head ✅ 5746455074; shape probes all true (heading / Served-tier constant name / head sha in a span of its own / ①②③ / Implemented-by: and Reviewed-by: value-first / **VERDICT: PASS** / zero angle brackets)
② both carriers cleared, machine-read ✅ needs:contract-review stripped in one act from this PR (read back size/m, tests, skip-changeset) and from card #17109 (read back priority:p2, pm:dispatched, domain:spec); PM_SWEEP_REPO=objectstack-ai/objectstack node scripts/pm/check-clause2-carriers.mjs --pair 19215 → exit 0
③ every check green, ⛔ not the required subset ✅ 35 check-runs on 6c594c4288: 31 success, 4 skipped, 0 failure, 0 pending; Check Changeset skipped on the label; mergeable_state: clean

The red this PR actually carried, and how it was cleared — recorded because the fix is a shape that will recur. Head fbc6c5ad90… no: head 9f44e9b51d was red on Lint & Repo Gates step 10, pnpm check:adr-anchors. Same instrument, two trees: main 8f42b17461 → exit 0; that head → exit 1, first error line naming a fictional ADR id cited by packages/spec/src/shared/retired-key.test.ts. ⇒ not a base red, not a flake. The fix dropped the citation rather than repointing it: an ADR id is the one element of a prescription this repo resolves, so a deliberately 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. Gate at 6c594c4288: exit 0, 36673 citations across 4723 files resolve.

Follow-up this seat takes, from the review's ② note: check-widening-tells.mjs --declaration no fires two false T2 tells on this diff (retired-key.ts:329, a prose fragment; :349, the z.enum(values, { opener). That is a matcher false positive in the instrument, ⛔ not a reason to declare falsely and ⛔ not a defect in this PR. Filed separately by this seat; it does not block this landing.

Landing now: ready → auto-merge → the merge queue. ⛔ No out-of-queue merge.


Generated by Claude Code

os-bill commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my own provenance comment 5746465515 — one of the two tells is NOT false

Same seat, 2026-09-20T00:42Z. ⛔ The earlier comment is not rewritten; this supersedes its last paragraph only.

I wrote there that check-widening-tells.mjs --declaration no fires two false T2 tells on this diff. I then measured them instead of repeating the review's summary, and only one is false:

tell line verdict why
retired-key.ts:329 + 'is a declaration with no enforcement; call `z.enum()` directly instead.', FALSE positive the matcher reads the z.enum( token inside a string literal as a closed-set opener
retired-key.ts:349 return z.enum(values, { a REAL opener — ⛔ not a matcher defect #18640 settled this shape by maintainer ruling (batch #155 item 3, letter A, 「同意」 2026-09-18T05:13Z): the subset test stays, and the remedy is Clause-②: yes plus the spec lane's at-tier review — ⛔ no licence card. That is exactly the route this PR took

Three-leg probe on a synthetic diff through the gate itself, so the classification is not an opinion:

leg added line got
A — prose fragment whose text mentions the opener in backticks + 'is a declaration with no enforcement; call `z.enum()` directly instead.', T2 fires ⇐ the defect
B — dark control: same continuation-fragment shape, no opener token in the text + 'in the enum — the enum accepts the value, …', silent
C — firing control: a real new opener return z.enum(values, { T2 fires

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 :329 shape only, with the #18640 ruling quoted on its face so the next reader does not re-litigate it. Dedupe: 12 siblings over open and closed, listed on the card; none covers a token read out of a string literal.

⛔ 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

Merged via the queue into main with commit 7e4ecc5 Sep 20, 2026
40 checks passed
@os-bill
os-bill deleted the claude/issue-17109-value-level-retirement branch September 20, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: there is no VALUE-level retirement mechanism — retiredKey() retires a key, and a ruled enum-member retirement has nothing to land on

2 participants