fix(spec): a retired-key prescription is the top-level message at PUT /api/v1/meta/view, not a buried invalid_union sub-error - #17900
Conversation
… /api/v1/meta/view ViewMetadataSchema is the union behind the runtime write door. A shape-level refusal raised inside one of its four branches did not become the union's message: the top level read zod's bare `Invalid input` and the prescription sat at error.issues[0].errors[k][j].message. Shipped behaviour since 17.0.0. The lift is family-wide: retiredKey() raises one declared issue shape (invalid_type / expected: 'never', message = the prescription), and the union's existing #7510 .check() now lifts it verbatim from the claimed branch. Measured: the union's members reach 67 `never` leaves and only 8 are tombstones -- zod folds a rejecting `never` catchall into unrecognized_keys, so the other 59 never raise the lifted shape. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…its text The #17063 overlay-door pin found its issue with `message.includes(...)`. Since the prescription is now lifted onto the union wrapper too, that find is satisfied by either the wrapper (path []) or the tombstone (path pageName), and the pin's subject is the one with a path. Selecting on `expected === 'never'` names the tombstone directly and the message assertion is kept alongside. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 6dfa7a28c60470d4f571411cf55ae12a95d083b2 && git checkout 6dfa7a28c60470d4f571411cf55ae12a95d083b2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ef47459474dcf53bf3899c6712c22e7a2ebc7a9d fa6ee04ff89ede59cf883f06fa4501dcdee390fb && git checkout -B drift-repro ef47459474dcf53bf3899c6712c22e7a2ebc7a9d && git merge --no-ff fa6ee04ff89ede59cf883f06fa4501dcdee390fb
node scripts/docs-audit/affected-docs.mjs --json ef47459474dcf53bf3899c6712c22e7a2ebc7a9d
|
Fixes #17299
Clause-②: no — the same body parses or refuses identically before and after; only the 422's top-level message moves. Answered as measurements below: zero new exported symbols and zero new published-payload keys, each with a lit control.
What was wrong
ViewMetadataSchemais the union behindPUT /api/v1/meta/view— the door #13100 measured as the one an MCP/AI author actually reaches, with no CLI anywhere on the path. A shape-level refusal raised inside one of its four branches did not become the union's message: the top level read zod's bareInvalid input, and the prescription sat aterror.issues[0].errors[k][j].message.Re-run first on this branch's own base (
origin/maina61ae59f93), against the built artifact, with the card's own repro:Shipped behaviour, not a regression:
virtualScroll,stripedandborderedhave read this way since 17.0.0.Reach — one pin per union branch, named
Triage's ruling: fixing the top-level message for one branch is not the deliverable. All four branches were measured burying a tombstone before the change and surfacing it after, each landing in its own claimed-branch slot (so
focusClaimedBranchis selecting the branch the pin names):viewItemconfig.virtualScrollInvalid input`view.virtualScroll` was removed …containerlist.stripedInvalid input`view.striped` was removed …listOverlayvirtualScroll(the card's repro)Invalid input`view.virtualScroll` was removed …formOverlayariaInvalid input`form.aria` was removed …Pinned in
packages/spec/src/ui/view-union-retirement-prescription.test.ts§1, which also assertsselectViewMetadataBranch(body)equals the branch each pin claims — a pin surfacing the right text from the wrong branch would measure nothing about reach — and asserts the lifted string is byte-identical to the nested issue it came from.Per case or family-wide — decided by measurement
Family-wide.
retiredKey()isz.never({ error: () => guidance }).optional(), so a tombstone raises one declared issue shape:code: 'invalid_type',expected: 'never',message= the prescription. The union's existing #7510.check()now lifts that message verbatim from the claimed branch.The measurement that permitted it — and the reason a naive family-wide lift would have been wrong:
strictObject()closes a shape with az.never()catchall, so the union's four members reach 67neverleaves in the schema graph and only 8 are tombstones.nevercatchall intocode: 'unrecognized_keys'instead. Measured on the built artifact — a bogus key underpaginationreportsunrecognized_keyswith its curated prose, neverexpected: 'never'.z.never()in the graph, so a discriminant reading the graph would have caught all 67. Reading the raised issue catches exactly the 8. §2 walks the population out of the schema graph rather than listing it (so a retirement added tomorrow joins the pin with nobody editing it) and fails if either population is empty; §3 fails if any catchall ever starts lifting.Why not per case:
exportOptionsPdfUnionError(#8010) re-readsissue.inputstructurally, which is right for an enum value narrowing — it leaves no tombstone to key on. A key retirement does leave one, and there are eight on this surface reachable at four different depths (virtualScroll,config.virtualScroll,list.virtualScroll,listViews.KEY.virtualScroll), which a structural input-reader would have to re-implement the whole nesting to find. There are 592retiredKey()call sites in this package; the symptom is the buried messages, but the defect is that the next retirement reproduces it silently.Sibling #17320 — one convention does not cover both
#17320 landed today as
2d34f320d11. It wired a{ error }callback into seven leaffilterdoors so a bareinvalid_typesays something. This card is the other half: the message already exists and is already correct at the leaf, and the union wrapper hides it. Different mechanism, different level — triage's "sibling, not folded" holds. They are complementary rather than overlapping: #17320 makes a leaf message worth reading, and a lift that surfaced a bare leaf message would only have raised bare text to the top.What must not change, and did not
Invalid input; an unknown list-view type, an empty container and a body claiming nothing all keep zod's message, and none acquires "was removed" text (§4).Invalid input(§3).publicPickerreports the CONTAINER branch's diagnostic — the author is told to fixviewKind/config, not the key they typed #7510.check(), after the union has reached its verdict, and writes one string. Verdicts and issue codes stay pinned by the corpora inview-union-branch-focus.test.tsandview-union-diagnostics.test.ts, untouched here and green.Ablation — mutate on disk, prove it landed, RED, restore, prove by hash
From the committed state. HEAD blob for
view.zod.ts:046557c80e9d285158311253935091a15dcabb37.Leg A, discriminating — delete the lift. Occurrence count 1 to 0; on-disk hash moved to
08e8f65c89bb…. Result RED: 5 failed / 10 passed. Restored: hash back to046557c80e9d…,git diff HEADempty.Leg B, cost direction — make the lift unconditional (drop the
expected === 'never'discriminant, lift any claimed-branch message). Occurrence count 1 to 0; hash moved to32928cada68d…. Result RED: 2 failed / 13 passed, and the two it killed are exactly the cost-direction pins:Restored: hash back to
046557c80e9d…. Both legs ran under atraprestoring againstHEADat an absolute path.Clause-② (a) — new exported symbols: ZERO, with a lit control
retirementPrescriptionis deliberately module-private, exactly asfocusClaimedBranchandexportOptionsPdfUnionErrorbeside it are.Read from the regenerated artifacts, not from memory, after
pnpm --filter @objectstack/spec build:export constto the same file, rebuilding and regenerating, movedpackages/spec/api-surface/ui.json, and the probe name greps out of it. The probe works and is aimed at the artifact that recordsui/exports.git status --porcelain packages/spec/api-surface packages/spec/export-originsis empty.check:api-surfaceandcheck:export-originsboth green with no regeneration owed.Clause-② (b) — new published-payload keys: ZERO, with a measurement
check:authorable-surface(authorable-surface/ + authorable-defaults/ + the JSON schemas) is green with nothing to regenerate, so no authorable key moved in either direction.check:generatedreports all 15 generated artifacts up to date.The publish surface itself was measured with
npm pack --dry-run --jsoninpackages/spec(2012 files) against two controls: the edited sourcesrc/ui/view.zod.tsdoes ship (the manifest'sfiles[]carriessrc/**/*.zod.ts) alongsidedist/ui/*, while both test files correctly do not — so the listing discriminates. That is what makes the change consumer-visible and the changeset owed.Changeset
.changeset/17299-view-union-retirement-prescription.md, graded patch against this repo's own precedent: #17320's changeset, landed today for the same class of change ("the change is to what a refusal says"), is'@objectstack/spec': patch.The population is real, not an empty one read as clean — the file was committed before the gates ran, and they counted it:
check:empty-changesetreports 1 declaring changeset(s) added andcheck:adr-0087-registrationreports 1 non-breaking changeset(s) seen.Verification
Both
packages/specvitest projects, run separately, exit codes reported separately:pnpm --filter @objectstack/spec test(projectlocal)pnpm --filter @objectstack/spec test:repo(projectrepo)pnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec check:generatedDerived gate families —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, 81 commands over the real change set: 77 exit 0.The remaining 4 are exit 3 = NOT MEASURED, not green and not red — every one prints
PREREQUISITE NOT METbecause a sibling package has nodist/in this worktree, where only@objectstack/specwas built:check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt,check:doc-formula-expressions. None of them reads this diff's files; all four are declared to CI, which builds the whole repo.One existing pin was repaired rather than deleted. The #17063 overlay-door pin selected its issue with
message.includes(...); the prescription now legitimately appears at two levels, so that find is satisfied by either the wrapper (path[]) or the tombstone (pathpageName), and the pin's subject is the one with a path. It now selects onexpected === 'never'and keeps the message assertion beside it. The stale comment inview.test.tsthat left the per-case-vs-family question open now points at where it is settled.Overlap, declared
The declared file face
packages/spec/src/ui/view.zod.tsoverlaps two parked pieces of work — PR #17877 (card #17054) and card #17507. Declared, worked anyway per this lane's rule; whoever lands second resolves the conflict. Noview.columns/view.tabs/view.sorttitle work is touched here — that is #17507's subject.Generated by Claude Code