Skip to content

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

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-17299-view-union-prescription-surfacing
Sep 12, 2026
Merged

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
os-bill merged 2 commits into
mainfrom
claude/issue-17299-view-union-prescription-surfacing

Conversation

@os-bill

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

Copy link
Copy Markdown
Collaborator

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

ViewMetadataSchema is the union behind PUT /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 bare Invalid input, and the prescription sat at error.issues[0].errors[k][j].message.

Re-run first on this branch's own base (origin/main a61ae59f93), against the built artifact, with the card's own repro:

### CARD REPRO — list overlay virtualScroll
  top.code: invalid_union
  top.message: "Invalid input"
    errors[2][0] code=invalid_type expected=never path=["virtualScroll"]
      msg="`view.virtualScroll` was removed in @objectstack/spec 17.0.0 (ADR-0049 …"

Shipped behaviour, not a regression: virtualScroll, striped and bordered have 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 focusClaimedBranch is selecting the branch the pin names):

branch tombstone exercised before after
viewItem config.virtualScroll Invalid input `view.virtualScroll` was removed …
container list.striped Invalid input `view.striped` was removed …
listOverlay virtualScroll (the card's repro) Invalid input `view.virtualScroll` was removed …
formOverlay aria Invalid input `form.aria` was removed …

Pinned in packages/spec/src/ui/view-union-retirement-prescription.test.ts §1, which also asserts selectViewMetadataBranch(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() is z.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 a z.never() catchall, so the union's four members reach 67 never leaves in the schema graph and only 8 are tombstones.
  • The other 59 never raise the lifted shape: zod folds a rejecting never catchall into code: 'unrecognized_keys' instead. Measured on the built artifact — a bogus key under pagination reports unrecognized_keys with its curated prose, never expected: 'never'.
  • The control is lit by construction: those 59 are 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-reads issue.input structurally, 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 592 retiredKey() 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 leaf filter doors so a bare invalid_type says 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

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 to 046557c80e9d…, git diff HEAD empty.

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 to 32928cada68d…. Result RED: 2 failed / 13 passed, and the two it killed are exactly the cost-direction pins:

FAIL §3 catchall control — so its curated prose stays where it was and the top-level message is untouched
FAIL §4 cost direction   — an unknown list-view type keeps zod's bare union message

Restored: hash back to 046557c80e9d…. Both legs ran under a trap restoring against HEAD at an absolute path.

Clause-② (a) — new exported symbols: ZERO, with a lit control

retirementPrescription is deliberately module-private, exactly as focusClaimedBranch and exportOptionsPdfUnionError beside it are.

Read from the regenerated artifacts, not from memory, after pnpm --filter @objectstack/spec build:

  • Lit control — appending one dummy export const to the same file, rebuilding and regenerating, moved packages/spec/api-surface/ui.json, and the probe name greps out of it. The probe works and is aimed at the artifact that records ui/ exports.
  • Real answer — control removed, rebuilt, regenerated: git status --porcelain packages/spec/api-surface packages/spec/export-origins is empty. check:api-surface and check:export-origins both 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:generated reports all 15 generated artifacts up to date.

The publish surface itself was measured with npm pack --dry-run --json in packages/spec (2012 files) against two controls: the edited source src/ui/view.zod.ts does ship (the manifest's files[] carries src/**/*.zod.ts) alongside dist/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-changeset reports 1 declaring changeset(s) added and check:adr-0087-registration reports 1 non-breaking changeset(s) seen.

Verification

Both packages/spec vitest projects, run separately, exit codes reported separately:

run exit result
pnpm --filter @objectstack/spec test (project local) 0 473 files passed, 1 skipped; 13463 tests passed, 1 skipped
pnpm --filter @objectstack/spec test:repo (project repo) 0 31 files passed; 523 tests passed
pnpm --filter @objectstack/spec typecheck 0 test layer compiles; debt ledger held
pnpm --filter @objectstack/spec check:generated 0 all 15 generated artifacts up to date

Derived 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 MET because a sibling package has no dist/ in this worktree, where only @objectstack/spec was 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 (path pageName), and the pin's subject is the one with a path. It now selects on expected === 'never' and keeps the message assertion beside it. The stale comment in view.test.ts that 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.ts overlaps 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. No view.columns / view.tabs / view.sort title work is touched here — that is #17507's subject.


Generated by Claude Code

os-bill and others added 2 commits September 12, 2026 20:11
… /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
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/error-catalog.mdx (via invalid_type (literal, a string literal in retirementPrescription; a string literal on a changed line))
  • content/docs/api/error-handling-server.mdx (via invalid_type (literal, a string literal in retirementPrescription; a string literal on a changed line))
  • content/docs/api/plugin-endpoints.mdx (via /api/v1/meta/view (route, a path literal in focusClaimedBranch))
  • content/docs/concepts/metadata-lifecycle.mdx (via /api/v1/meta/view (route, a path literal in focusClaimedBranch))
  • content/docs/deployment/cli.mdx (via invalid_type (literal, a string literal in retirementPrescription; a string literal on a changed line))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/meta/view (route, a path literal in focusClaimedBranch))
  • content/docs/protocol/objectql/types.mdx (via unrecognized_keys (literal, a string literal on a changed line))
  • content/docs/protocol/objectui/concept.mdx (via invalid_type (literal, a string literal in retirementPrescription; a string literal on a changed line))
  • content/docs/protocol/objectui/index.mdx (via /api/v1/meta/view (route, a path literal in focusClaimedBranch))
  • content/docs/ui/forms.mdx (via /api/v1/meta/view (route, a path literal in focusClaimedBranch))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via invalid_type (literal, a string literal in retirementPrescription; a string literal on a changed line), unrecognized_keys (literal, a string literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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.

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 ef47459474dcf53bf3899c6712c22e7a2ebc7a9dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 6dfa7a28c60470d4f571411cf55ae12a95d083b2 — the merge of head fa6ee04ff89ede59cf883f06fa4501dcdee390fb into base ef47459474dcf53bf3899c6712c22e7a2ebc7a9d, 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 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

⚠️ 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 ef47459474dcf53bf3899c6712c22e7a2ebc7a9d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/m tests tooling

Projects

None yet

1 participant