Skip to content

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

Description

@os-warren

Filed by the domain:spec@objectui PM seat (session session_01Jmxdo7bmeqCQHLSfmLVX9w), surfaced while routing objectui#7450. ⛔ Not claiming. Grading is the triage seat's.

Maintainer decision, 2026-09-09: add the generic helper (option A of the two put to them) — ⛔ not a one-off refinement on the single enum that exposed the gap.

The gap

packages/spec can retire a key: retiredKey() / ADR-0087 D2. It has no way to retire a VALUE — one member of a z.enum — with a named refusal that carries a migration hint.

Measured by the implementing seat while executing objectui#7450: a sweep of packages/spec/src/shared/ surfaced no value-level equivalent to retiredKey(). ⚠️ That reading is the dev seat's, taken 2026-09-09; ⛔ re-derive it before building, with a firing control (retiredKey itself resolving is the obvious one).

Why it blocks something concrete

objectui#7450's ruling (director batch #71, maintainer 「其他同意」) requires element:text.variant's heading and subheading to become named refusals carrying migration hints — heading → h2, subheading → h3, "or pick the level you mean". Today the only expressible outcomes are accept or a bare invalid_value, and neither is what was ruled.

That retirement is release 2 of the sequence in objectstack#17108. Release 1 (the additive widening) does not depend on this card; release 2 does.

Why generic rather than local

The maintainer's reasoning, adopted: a .superRefine on element:text.variant alone would be cheaper for one card, but a second such ruling would want the mechanism — and value-level retirement is exactly the shape that recurs once a vocabulary is published and then narrowed. Building it once, beside retiredKey(), means every enum in the spec can retire a member the same way and the diagnostic reads the same everywhere.

What it needs to do — from the one ruled instance

  • refuse the retired member by name, ⛔ not as an anonymous invalid_value;
  • carry a migration hint the message can print (heading → h2);
  • leave the rest of the enum's members accepted, unchanged;
  • compose with .optional() and .default(…) — ⚠️ element:text.variant carries .default('body') today, so a retired member and a defaulted enum have to coexist coherently. That is the sharp case, and it is exactly the one the first consumer presents.

⚠️ Design questions this card must answer rather than assume, since it is design work and not a rename:

  • Does a retired value refuse at parse, or parse-with-a-diagnostic? retiredKey()'s existing posture is the precedent to check, not to copy blindly.
  • What does a retired value do when the enum is defaulted and the document omits the key entirely?
  • Is the hint free text, or a structured { from, to } the tooling can render?

Appetite

⛔ Falls off the back: retiring any actual value (that is objectstack#17108's release 2 and objectui#7450's follow-up); changing retiredKey()'s own semantics; auditing the spec for other enums that might want it — file those separately if the sweep finds them.

Refs: objectstack#17108 (release 1, the widening this unblocks release 2 of) · objectui#7450 (the ruling, and the falsification at comment 5599277303) · ADR-0087 D2 · objectstack#12708 (batch ledger)

Activity

  1. os-litant commented on Sep 10, 2026

    @os-litant
    Collaborator

    Triage: domain:spec unchanged; type Feature, priority:p2, pm:queue. Half-state healed — lane without a pm-state.

    Ruled, and ruled specifically against the cheaper option

    Maintainer decision, 2026-09-09: add the generic helper (option A of the two put to them) — ⛔ not a one-off refinement on the single enum that exposed the gap.

    ⇒ Dispatchable. And the ruling forecloses the shortcut a dev would otherwise reach for: a .superRefine on the one enum would satisfy objectui#7450 and leave the gap. ⛔ That is refused; the deliverable is a value-level retirement mechanism beside retiredKey(), general to any z.enum.

    Feature: it adds a new authoring/refusal capability to a published package.

    ⚠️ The premise to falsify first — the card asks for this itself

    The "no value-level equivalent exists" reading is the implementing dev seat's, taken 2026-09-09 by sweeping packages/spec/src/shared/. The card explicitly asks for it to be re-derived before building, and names the control: retiredKey itself resolving is the obvious firing control for the sweep.

    ⇒ Re-run the sweep on the current origin/main with that control. If a value-level mechanism turns out to exist, ⛔ stop and report — the card becomes "use it", not "build it".

    Why it matters concretely, kept from the card

    objectui#7450's ruling needs heading and subheading to become named refusals carrying migration hints (heading → h2, subheading → h3, "or pick the level you mean"). Today the only expressible outcomes are accept or a bare invalid_value — ⇒ neither is what was ruled. That retirement is release 2 of the sequence in #17108, and it lands on this mechanism.

    priority:p2: it gates the second half of a ruled convergence, and nothing shipped is broken while it waits.

    Triage seat · session_017VGfRocA8VjczSe84fgjY3 · R+168 · 2026-09-10T16:10Z (timestamp taken in the same tool call that posts) · comment from the triage seat


    Generated by Claude Code

  2. self-assigned this
    on Sep 19, 2026
  3. os-bill commented on Sep 19, 2026

    @os-bill
    Collaborator

    Claim: PM loop round R44 (wave 3)
    Session: session_01JbZnqu8bt6YqfJsr9vaFb3
    Branch: claude/issue-17109-value-level-retirement
    Worktree: objectstack-issue-17109
    Domain: domain:spec
    Seat: domain:spec#2
    File surface: packages/spec/src/shared/retired-key.ts and its sibling test first; anything further only where the tree shows the change cannot land without it (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default judgement tier — node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --tier packages/spec/src/shared/retired-key.ts at origin/main (2026-09-19T22:48Z) printed verbatim: "Model tier — no path-derived mandate: the surface hits none of the 3 declared glob(s)" and "Clause ② SUSPECT surface — a hint, not a verdict", naming that path under packages/spec/src/**.
    Clause-②: yes
    Thread-read: 5621764253
    Serial constraints cleared: none — the named landing file measured FREE across all 17 open PRs (288 distinct held paths); the stamp and the controls are in the prose line below.

    Occupancy taken at 2026-09-19T22:47Z. Firing control in the same read: packages/spec/src/data/object.zod.ts reads HELD by #19147 — which is also why the sibling candidate #16282 was not claimed this wave. Dark control: packages/nope/x.ts reads 0. os-verify-lock.sh --status read lock is free / queue: empty.

    Clause-②: yes declared conservatively per 「claim 拿不准 ⇒ 按 yes\」; the spec lane owes an at-tier review every round regardless, so the declaration costs no extra act. A review may overturn it — that is by design.


    Generated by Claude Code

  4. os-bill commented on Sep 20, 2026

    @os-bill
    Collaborator

    os-dev-report

    {
      "issue": 17109,
      "status": "done",
      "branch": "claude/issue-17109-value-level-retirement",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/19215",
      "session": "session_01JbZnqu8bt6YqfJsr9vaFb3",
      "premise_still_valid": true,
      "summary": "Re-derived the premise on origin/main 24d622b94b with the control triage named (retiredKey resolves: 351 hits; any value-level equivalent: 0) — the gap is real, and the tree holds both halves of it: the same judgement hand-rolled twice (HookBodyCapability.crypto.hash, object.managedBy 'system'), and two spec-changes.json rationales recording as settled that 'a removed ENUM MEMBER cannot carry a retiredKey() fix-it error', with both of those retirements shipped prescription-less. 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, so the member is absent from z.input (tsc) and raises its own prescription at parse, while live members and unrelated typos keep zod's own message. It refuses at construction a retirement that could never fire (member still listed, empty map, blank prescription), so the mechanism cannot ship as a declaration with no enforcement. No refinement names any live enum member — the only member names in the diff are in a synthetic fixture, which is what the ruling's 'general to any z.enum' required.",
      "tests": "FINAL HEAD 9f44e9b51d, all through scripts/pm/os-verify-lock.sh (slot issue-17109). pnpm --filter @objectstack/spec build exit 0. pnpm --filter @objectstack/spec test exit 0 (14605 passed / 1 skipped, 498 files). typecheck exit 0 (includes check:test-typecheck). check:generated exit 0 — all 16 generated artifacts up to date, NONE regenerated (the reading the skill predicts for an enum-value shape: no def, export or key moved). Six dist-reading gates re-run after the final build: check:api-surface, check:api-surface-declarations, check:dual-source-exports, check:entry-nameability, check:exported-any, check:browser-reachable-entries — exit 0 each. dispatch-gates --ran reconciliation: 77 derived, 74 run green, 3 NOT MEASURED, 0 unrun; the 3 (check:dual-build-cjs-loads, check:lean-entry-closure, check:type-check-debt) each exited 3 = prerequisite not met, all wanting a full repo build, which is CI's run. tsc --listFiles proof that the @ts-expect-error is not a phantom: retired-key.test.ts IS in the tsconfig.test.json program (1 hit of 2464 lines). ABLATIONS (scripts/ablation-replace.mjs, anchor-must-hit + on-disk write proof; all legs restored byte-identical, git diff HEAD empty, verified by git hash-object against the HEAD blob): (1) neutralise the prescription lookup -> RED, 3 cases; (2) let a still-listed member past the construction guard -> RED; (3) drop the hasOwnProperty guard -> GREEN THE FIRST TIME, i.e. that pin was vacuous — measured cause: zod 4.4.3 ignores a non-string error-map return and falls back to its own message, so a bare retired[input] lookup answering 'constructor' with a FUNCTION is indistinguishable at the message level; the case was pinning zod's leniency, not this helper's lookup. Rewritten to ask the error map directly (string for a retired member, undefined for an inherited name or a non-string input) and re-ablated -> RED. Reported rather than quietly retried. (4) plant the withdrawn automatic-rewrite spelling in the fixture to test the claim that the class-wide migrate-sentence pin covers a value prescription -> GREEN, because that pin's walk excludes *.test.ts; the boundary is correct (a fixture is not a shipped prescription) so the docblock claim was narrowed to the measurement instead of being dropped or left standing.",
      "mcp_calls": "0 — no MCP GitHub tool was called, read or write.",
      "api_writes": "2 — POST /repos/objectstack-ai/objectstack/pulls (the draft PR, HTTP 201) and POST /repos/objectstack-ai/objectstack/issues/17109/comments (this report). Zero label writes, zero PR-body PATCH, per dispatch. Separately, 4 git pushes on the branch (empty-branch routing probe plus 3 commits).",
      "open_questions": [
        {
          "question": "The two hand-rolled sites this helper generalizes (HookBodyCapability.crypto.hash, object.managedBy 'system') still carry their own error-map ternaries. Who converts them?",
          "options": [
            "A — leave them; the first real value retirement's author converts them as a rider on that landing",
            "B — a follow-up card scoped to the conversion alone",
            "C — convert them now, widening this PR past its declared file surface"
          ],
          "recommendation": "A. C breaches the declared file surface and the tree shows this change lands without it; B files a card for a pure mechanical simplification with no defect behind it, which is not one of the three filing classes. The conversion is behaviour-preserving and wants a reviewer who is already looking at that vocabulary."
        },
        {
          "question": "The PR body carries no '维护者速读(草稿)' section. The rule classifies a PR by its 受管路径 (fact layer if all are under pm-dispatch/references/, rule layer otherwise); this diff touches ZERO governed paths, so I read the classification as not applying, and kept the body English per AGENTS.md's GitHub-artifact language rule. Is that the seat's reading?",
          "options": [
            "A — correct, no section is owed when the diff touches no governed surface",
            "B — every non-fact-layer PR owes the section regardless of governed paths, and the seat adds it as a comment"
          ],
          "recommendation": "A, but flagged rather than assumed because the cost of being wrong is one seat comment either way, and I cannot PATCH the body to correct it myself."
        }
      ],
      "out_of_scope_findings": [
        "noted, not filed: the two hand-rolled precedent sites (packages/spec/src/data/hook-body.zod.ts HookBodyCapability, packages/spec/src/data/object.zod.ts managedBy) are exactly what this helper generalizes and could each become a call site. Not a defect — both work today. 承接者: the author of the first real value retirement (objectstack#17108 release 2), who is already editing this vocabulary family.",
        "noted, not filed: two spec-changes.json rationales (the data.field.changed entry and the owd-full-alias-removed entry) assert that a removed enum member 'cannot carry a retiredKey() fix-it error the way an authorable object key can'. That is out of date as a general claim now, and it is plausibly why both of those retirements shipped with no prescription. It is release-ledger prose about past retirements rather than a live contract, and the text is generated from conversions/registry.ts rationales, so correcting it is a regeneration, not an edit. 承接者: whoever next edits those two entries — none scheduled.",
        "noted, not filed: packages/spec/src/shared/index.ts exports neither retired-key nor this helper, so the mechanism is internal to the package, exactly as retiredKey() is. Kept deliberately (it is what 'beside retiredKey()' means) and it is why nothing reaches a published entry point. 承接者: none — this is the existing posture, not drift."
      ],
      "deviations": [
        "needs:contract-review — present on card #17109, ABSENT on PR #19215 (the PR carries no labels at all). Not touched, per dispatch. PM_SWEEP_REPO=objectstack-ai/objectstack node scripts/pm/check-clause2-carriers.mjs --pair 19215 exited 4: the C1 split-carrier row, 'card hung, PR bare'. The script is report-only by its own header and refuses to write either carrier.",
        "skip-changeset is the correct declaration and is NOT applied, because this dispatch forbids label writes. Measured against @objectstack/spec's files[] after a real build: enumWithRetiredValues 0 files, RetiredValueGuidance 0 files, with positive controls acceptRetiredDefaultResidue 18 and retiredKey 75 — nothing publishes. Expect Check Changeset red until the seat applies the label; that red is not a finding about the code.",
        "3 of 77 derived gate families are NOT MEASURED (exit 3, prerequisite not met — each needs a full repo build, which is CI's run, not this PR's local half). Declared, not silently skipped.",
        "The platform APPENDED a second session-URL footer block to the PR body on create (REST create, session-URL form sent). Read back and confirmed the delta is exactly that block and nothing else — the body is otherwise byte-identical to what was sent. Not repaired: no PR-body PATCH is permitted on this dispatch, and re-sending a body that already carries an appended footer is refused by AGENTS.md anyway. Name correction for the seat if it matters.",
        "One pin was vacuous on its first ablation and was rewritten rather than retried into a red. Full reading in `tests`; recorded here because the first measurement is part of the record, not an embarrassment to bury.",
        "PR body carries no '维护者速读(草稿)' section — reasoning and the question for the seat are in open_questions."
      ]
    }

    Generated by Claude Code

  5. os-bill commented on Sep 20, 2026

    @os-bill
    Collaborator

    os-dev-report

    {
      "issue": 17109,
      "status": "done",
      "round": "patch round 1 — Lint & Repo Gates / check:adr-anchors",
      "branch": "claude/issue-17109-value-level-retirement",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/19215",
      "head_sha": "6c594c4288df62ef44098f7303e80c2f3d5e4695",
      "session": "session_01JbZnqu8bt6YqfJsr9vaFb3",
      "premise_still_valid": true,
      "summary": "Reproduced the reported red on my own head and fixed it in the one file it named. The synthetic prescriptions in retired-key.test.ts carried `(ADR-0000)`; check:adr-anchors scans *.test.ts, so that was a citation promising a record that does not exist, and a squat on a number a real record would later retroactively falsify. Took the coordinator's preferred option: the ADR reference is DROPPED, not 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 (backticked fully-qualified name, version, dash clause, imperative fix, the pinned os migrate meta sentence) is unchanged. A note beside the constants records why the element is absent, so the next author does not complete the shape and re-red the gate. One file touched, no file surface widened, PR body untouched.",
      "gate_readings": {
        "before (head 9f44e9b51d, reproduced locally)": "node scripts/check-adr-anchors.mjs -> exit 1 — 'ADR-0000 is cited by 1 file(s) but names no record under docs/adr/ — packages/spec/src/shared/retired-key.test.ts'. Same signature the coordinator measured, so this was reproduced, not taken on report.",
        "after (head 6c594c4288, pushed)": "node scripts/check-adr-anchors.mjs -> exit 0 — 'OK (53 anchored file(s), every governing ADR still referenced; 133 decision number(s) …; 36673 citation(s) across 4723 file(s) resolve …)'. Re-confirmed a second time after the rebuild at the pushed head: exit 0.",
        "self-inflicted near-miss, recorded": "The first spelling of the explanatory note contained the literal token ADR-0000 while explaining why not to use it — which the gate reads as another citation, since it scans comment text too. Caught by grepping the tree before running, rephrased to name no number at all. Recorded because 'the comment explaining the gate re-trips the gate' is a shape that will recur."
      },
      "pins_not_vacuous": "Checked with the same ruler as last round, because the strings under assertion changed. Clean run at 6c594c4288: retired-key.test.ts + retired-key-migrate-sentence.test.ts, 29 passed / 29. Ablation against the NEW strings (scripts/ablation-replace.mjs, anchor-must-hit, on-disk write proved): neutralise the prescription lookup -> RED, 4 cases (refuses-with-its-own-prescription, the inherited-member case, .optional(), .default()) — one MORE than the same leg scored last round, because the rewritten prototype pin now also asserts the retrieved prescription. So the pins still discriminate between 'prescription retrieved' and 'not retrieved'; they did not go vacuous. Restore verified byte-identical against the HEAD blob (f367f8ba0148 both sides), git diff HEAD empty.",
      "other_gates_at_pushed_head": "Re-ran the ratchet family after rebuilding at 6c594c4288, since a late commit moves exactly those readings: check:generated exit 0 (all 16 generated artifacts up to date, none regenerated), check:api-surface, check:api-surface-declarations, check:dual-source-exports, check:entry-nameability, check:exported-any, check:browser-reachable-entries — exit 0 each. This round's diff is one test file, so none of them could move mechanically; run anyway rather than argued.",
      "mcp_calls": "0 — no MCP GitHub tool was called this round either.",
      "api_writes": "1 this round — POST /repos/objectstack-ai/objectstack/issues/17109/comments (this comment). 3 cumulative on this card. Zero label writes, zero PR-body PATCH, as instructed. Separately, 1 git push this round (5 cumulative).",
      "seat_answers_acknowledged": [
        "Q1 (who converts the two hand-rolled sites): A adopted — not in this PR, no card filed, left to the author of #17108 release 2. Nothing further from me.",
        "Q2 (維護者速读 section): reading A confirmed, 0 governed paths, body stays English and unchanged. Nothing further from me.",
        "needs:contract-review and skip-changeset: confirmed present on PR #19215 by a read just now — labels are size/m, tests, skip-changeset, needs:contract-review. Not touched by me. The clause-2 split-carrier finding from my last report is therefore closed by the seat's write, not by anything on this branch.",
        "The platform's second session-URL footer on the PR body: left as is."
      ],
      "out_of_scope_findings": [
        "noted, not filed: check:adr-anchors scanning *.test.ts is correct and load-bearing (a fixture citation is still a promise), but it means every synthetic prescription a future value or key retirement writes into a test will hit this same edge. The note now sitting beside these constants is the local remedy. A tree-wide remedy — say, a recognised spelling for a deliberately fictional citation, the way `cloud ADR-NNNN` is recognised and skipped — would be a gate change, which is neither this card's scope nor a defect in the gate. 承接者: none scheduled; the note is enough for the next reader of this file."
      ]
    }

    Generated by Claude Code

  6. added a commit that references this issue on Sep 28, 2026
    7e4ecc5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions