diff --git a/.changeset/17299-view-union-retirement-prescription.md b/.changeset/17299-view-union-retirement-prescription.md new file mode 100644 index 0000000000..7460867e9b --- /dev/null +++ b/.changeset/17299-view-union-retirement-prescription.md @@ -0,0 +1,31 @@ +--- +'@objectstack/spec': patch +--- + +A retirement prescription is the top-level message a `PUT /api/v1/meta/view` 422 carries, instead of sitting buried in `invalid_union` sub-errors + +`ViewMetadataSchema` is the union behind the runtime write door — the one an +MCP/AI author 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 upgrade prescription sat at +`error.issues[0].errors[k][j].message`. Every retirement this platform wrote for +list and form views was therefore invisible at the one door its intended reader +uses — shipped behaviour since 17.0.0 for `virtualScroll`, `striped` and +`bordered`, not a recent regression. + +The lift is family-wide rather than per case. `retiredKey()` raises one declared +issue shape — `code: 'invalid_type'`, `expected: 'never'`, with the prescription +as its `message` — so the union's existing `.check()` now lifts that message +verbatim from the branch the body claims. The next retirement on this shape is +surfaced without anyone remembering to wire it, which is what a per-case fix +could not promise. + +What does not move: the accept/reject verdict of every body (the lift runs after +the union has reached its verdict and writes one string), the issue codes, the +nested `errors` array and its order, and the message of every refusal that is +not a retirement — a plain shape error still reads `Invalid input`, and a +curated unknown-key refusal still reads exactly as it did. That boundary is +measured, not asserted: `strictObject()` closes a shape with a `z.never()` +catchall, so the union's members reach 67 `never` leaves of which only 8 are +tombstones — zod folds a rejecting `never` catchall into `unrecognized_keys`, so +the other 59 never raise the lifted shape at all. diff --git a/packages/spec/src/ui/view-union-retirement-prescription.test.ts b/packages/spec/src/ui/view-union-retirement-prescription.test.ts new file mode 100644 index 0000000000..cb0074e9d5 --- /dev/null +++ b/packages/spec/src/ui/view-union-retirement-prescription.test.ts @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17299] A retirement prescription is the TOP-LEVEL message at + * `PUT /api/v1/meta/view` — not a string buried in `invalid_union` sub-errors. + * + * ## The measured defect + * + * `ViewMetadataSchema` is the union behind the runtime write door, the one an + * MCP/AI author 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 message was zod's bare `Invalid input` and the prescription sat + * at `error.issues[0].errors[k][j].message`. Measured on `origin/main` + * `a61ae59f93`, all four branches, one tombstone each: + * + * ``` + * 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 …" + * ``` + * + * ⚠️ Shipped behaviour, not a regression: `virtualScroll`, `striped` and + * `bordered` have read this way since 17.0.0. Guidance that exists and cannot + * be seen is, from the author's side, indistinguishable from guidance that does + * not exist. + * + * ## Family-wide, not per case — and what decided it + * + * `exportOptionsPdfUnionError` (`view.zod.ts`, #8010) fixed the same burial for + * ONE retired enum value by re-reading `issue.input`. The route taken here is + * the family: `retirementPrescription` lifts any claimed-branch issue whose + * shape is the retirement channel's own — `code: 'invalid_type'` with + * `expected: 'never'`, which is what `retiredKey()` raises. + * + * The measurement that permitted it is §3 below. `strictObject()` closes a + * shape with a `z.never()` CATCHALL, so the union's members reach 67 `never` + * leaves and only 8 are tombstones — but zod folds a rejecting `never` catchall + * into `unrecognized_keys`, so the other 59 never raise this issue shape at all. + * A discriminant reading the schema GRAPH would have caught all 67; reading the + * raised ISSUE catches exactly the 8. §3 lights that control rather than + * asserting it: it fails if the catchall population is empty, and fails if any + * catchall ever starts lifting. + * + * ## What this file pins + * + * 1. **Reach** — one pin per union branch, named, each surfacing its own + * tombstone at the top level. Triage's ruling: fixing one branch is not the + * deliverable. + * 2. **The population is derived, never hand-listed** — every non-catchall + * `never` leaf on the union's surface is walked out of the schema graph and + * asserted to be a prescription. A retirement added tomorrow joins this pin + * without anyone editing it, which is the point of taking the family route. + * 3. **The cost direction** — a refusal that is NOT a retirement keeps zod's + * message byte for byte: a plain shape error still reads `Invalid input`, + * and a curated unknown-key refusal still reads exactly as it did. + * 4. **⛔ The acceptance face did not move.** The lift runs inside the #7510 + * `.check()`, after the union has reached its verdict; it writes one string + * and nothing else. Verdicts and issue CODES are pinned over the corpora in + * `view-union-branch-focus.test.ts` and `view-union-diagnostics.test.ts`, + * which this change leaves untouched; §4 here adds the direct assertion. + */ + +import { describe, expect, it } from 'vitest'; +import { + ViewMetadataSchema, + VIEW_METADATA_BRANCHES, + VIEW_METADATA_MEMBERS, + selectViewMetadataBranch, + type ViewMetadataBranch, +} from './view.zod'; + +/** Zod's own message for a union that nothing else explained. */ +const ZOD_BARE = 'Invalid input'; + +const parse = (body: unknown) => ViewMetadataSchema.safeParse(body); + +const topMessage = (body: unknown): string => { + const r = parse(body); + expect(r.success, `expected a refusal for ${JSON.stringify(body)}`).toBe(false); + return r.error!.issues[0]!.message; +}; + +// ─────────────────────────────────────────────────────────────────────────── +// §1 REACH — one body per union branch, each carrying a tombstone the branch +// owns. The `claims` column is asserted too: a pin that surfaced the right text +// from the WRONG branch would be measuring nothing about reach. +// ─────────────────────────────────────────────────────────────────────────── + +const BRANCH_PINS: ReadonlyArray, string]> = [ + [ + 'viewItem', + 'config.virtualScroll', + { + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', + config: { type: 'grid', columns: ['name'], virtualScroll: true }, + }, + '`view.virtualScroll` was removed', + ], + [ + 'container', + 'list.striped', + { + name: 'crm_lead', object: 'crm_lead', + list: { type: 'grid', columns: ['name'], striped: true }, + }, + '`view.striped` was removed', + ], + [ + 'listOverlay', + 'virtualScroll (the card\'s own repro)', + { + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', + type: 'grid', columns: ['name'], virtualScroll: true, + }, + '`view.virtualScroll` was removed', + ], + [ + 'formOverlay', + 'aria', + { + name: 'crm_lead.edit', object: 'crm_lead', viewKind: 'form', + type: 'simple', aria: { label: 'x' }, + }, + '`form.aria` was removed', + ], +]; + +describe('§1 reach — every branch of the view union surfaces its own prescription', () => { + it.each(BRANCH_PINS)('%s branch (%s)', (branch, _key, body, opening) => { + expect(selectViewMetadataBranch(body), 'the pin must exercise the branch it names').toBe(branch); + + const message = topMessage(body); + expect(message).not.toBe(ZOD_BARE); + expect(message).toContain(opening); + // The prescription is lifted VERBATIM — it is the migration document, and + // its `os migrate meta` sentence is pinned class-wide by + // `../shared/retired-key-migrate-sentence.test.ts`. A message this code + // composed would be a second spelling of a pinned string. + expect(message).toContain('to list the mechanical edits for existing sources; apply them by hand.'); + }); + + it('the lifted string is byte-identical to the nested issue it came from', () => { + for (const [, , body] of BRANCH_PINS) { + const r = parse(body); + const top = r.error!.issues[0]! as unknown as { message: string; errors?: { message: string; expected?: string }[][] }; + const nested = (top.errors ?? []).flat().find((i) => i.expected === 'never'); + expect(nested, 'the tombstone issue must still be where it always was').toBeDefined(); + expect(top.message).toBe(nested!.message); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 THE POPULATION, DERIVED — walked out of the schema graph, never listed. +// ─────────────────────────────────────────────────────────────────────────── + +interface NeverLeaf { readonly path: string; readonly isCatchall: boolean; readonly message: string } + +/** Every `z.never()` leaf the union's four members reach, with its message. */ +function walkNeverLeaves(): NeverLeaf[] { + const seen = new Set(); + const out: NeverLeaf[] = []; + const visit = (schema: unknown, path: readonly string[], depth: number): void => { + if (!schema || depth > 14 || seen.has(schema)) return; + const def = (schema as { _zod?: { def?: Record } })._zod?.def; + if (!def) return; + seen.add(schema); + if (def.type === 'never') { + const probe = (schema as { safeParse(v: unknown): { success: boolean; error?: { issues: { message: string }[] } } }) + .safeParse('probe'); + out.push({ + path: path.join('.'), + isCatchall: path[path.length - 1] === '*', + message: probe.success ? '' : probe.error!.issues[0]!.message, + }); + return; + } + const kids: Array = []; + const shape = typeof def.shape === 'function' ? def.shape() : def.shape; + if (shape) for (const [k, v] of Object.entries(shape)) kids.push([k, v]); + if (Array.isArray(def.options)) def.options.forEach((o: unknown, i: number) => kids.push([`|${i}`, o])); + for (const k of ['element', 'innerType', 'in', 'out', 'left', 'right', 'valueType'] as const) { + if (def[k]) kids.push(['', def[k]]); + } + if (typeof def.getter === 'function') { try { kids.push(['', def.getter()]); } catch { /* unreachable arm */ } } + if (def.catchall) kids.push(['*', def.catchall]); + for (const [k, v] of kids) visit(v, k ? [...path, k] : path, depth + 1); + }; + for (const branch of VIEW_METADATA_BRANCHES) visit(VIEW_METADATA_MEMBERS[branch], [branch], 0); + return out; +} + +describe('§2 population — every non-catchall `never` leaf on this surface is a prescription', () => { + const leaves = walkNeverLeaves(); + const tombstones = leaves.filter((l) => !l.isCatchall); + const catchalls = leaves.filter((l) => l.isCatchall); + + it('the walk reached both populations — the lit control for every zero below', () => { + expect(tombstones.length, 'no tombstone reached: the walk is broken, not the surface clean').toBeGreaterThan(0); + expect(catchalls.length, 'no catchall reached: §3 would be asserting nothing').toBeGreaterThan(0); + }); + + it('every one of them carries a retirement prescription, not zod default text', () => { + for (const leaf of tombstones) { + expect(leaf.message, leaf.path).toContain('was removed'); + expect(leaf.message, leaf.path).not.toContain('expected never, received'); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 THE CATCHALL CONTROL — the 59 that must NOT lift. +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 catchall control — a closed shape\'s `never` catchall never lifts', () => { + const UNKNOWN_NESTED = { + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', + type: 'grid', columns: ['name'], pagination: { bogusNested: 1 }, + }; + + it('an unknown nested key is reported as `unrecognized_keys`, never as `expected: never`', () => { + const r = parse(UNKNOWN_NESTED); + expect(r.success).toBe(false); + const nested = (r.error!.issues[0]! as unknown as { errors?: { code?: string; expected?: string }[][] }).errors ?? []; + const flat = nested.flat(); + expect(flat.some((i) => i.code === 'unrecognized_keys'), JSON.stringify(flat)).toBe(true); + expect(flat.some((i) => i.expected === 'never')).toBe(false); + }); + + it('so its curated prose stays where it was and the top-level message is untouched', () => { + expect(topMessage(UNKNOWN_NESTED)).toBe(ZOD_BARE); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §4 COST DIRECTION — what must NOT change. +// ─────────────────────────────────────────────────────────────────────────── + +describe('§4 cost direction — a non-retirement refusal reads exactly as it did', () => { + const NOT_RETIREMENTS: ReadonlyArray = [ + ['a plain shape error (columns is not an array)', { + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', type: 'grid', columns: 'not-an-array', + }], + ['an unknown list-view type', { + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', type: 'sideways', columns: ['name'], + }], + ['a container missing every slot', { object: 'crm_lead', list: {} }], + ['a body that claims nothing at all', { name: 'x.y', object: 'x', viewKind: 'chart' }], + ]; + + it.each(NOT_RETIREMENTS)('%s keeps zod\'s bare union message', (_label, body) => { + const r = parse(body); + expect(r.success).toBe(false); + const top = r.error!.issues[0]!; + if (top.code === 'invalid_union') expect(top.message).toBe(ZOD_BARE); + expect(r.error!.issues.map((i) => i.message).join('\n')).not.toContain('was removed in @objectstack/spec'); + }); + + it('an ordinary view still parses — the lit control for every refusal above', () => { + expect(parse({ + name: 'crm_lead.dash', object: 'crm_lead', viewKind: 'list', type: 'grid', columns: ['name'], + }).success).toBe(true); + }); + + it('the verdict of every §1 body is still a REFUSAL, and still `invalid_union`', () => { + for (const [, , body] of BRANCH_PINS) { + const r = parse(body); + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.code).toBe('invalid_union'); + } + }); +}); diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index b64940562e..7ee2635137 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -3912,16 +3912,17 @@ describe("ListViewSchema — the RETIRED `page` view type (#17063)", () => { * Walk `invalid_union` wrappers and return every issue, nested arms included * — the same helper the calendar block below needs, for the same reason. * - * ⚠️ The flatten is load-bearing at the OVERLAY door and nowhere else: on - * `ViewMetadataSchema` a shape-level refusal is raised inside a union BRANCH, - * so the top-level message is zod's "Invalid input" and the prescription sits - * one level down. Measured to be the pre-existing behaviour of EVERY - * `retiredKey()` tombstone on this shape (`virtualScroll`, `striped`, - * `bordered` all read identically), so it is the house behaviour of the door - * rather than anything this retirement introduces — the union-level dispatch - * that lifts such a message to the top (`exportOptionsPdfUnionError`, this - * file) is a per-case decision, and widening it to the whole tombstone family - * is its own question with its own measurement. + * ⚠️ The flatten still reaches the ISSUE wherever it is raised, which is why + * it stays: on `ViewMetadataSchema` a shape-level refusal is raised inside a + * union BRANCH, so the tombstone issue itself is one level down and a pin + * that walks only `issues[]` would miss it. What has changed since #17299 is + * the top-level MESSAGE, which used to be zod's bare "Invalid input" for + * every tombstone on this shape (`virtualScroll`, `striped`, `bordered` all + * read identically) and is now the prescription itself. That question — per + * case, as `exportOptionsPdfUnionError` answered it for one retired enum + * VALUE, or family-wide for the whole tombstone class — was opened here and + * is settled in `view-union-retirement-prescription.test.ts`, family-wide, + * with the catchall control that permitted it. */ const flatten = (issues: z.ZodIssue[]): z.ZodIssue[] => issues.flatMap((i) => { @@ -3952,9 +3953,17 @@ describe("ListViewSchema — the RETIRED `page` view type (#17063)", () => { it('REFUSES `pageName` with the tombstone prescription, not a bare unknown-key report', () => { const r = parse({ type: 'grid', pageName: 'sales_dashboard', columns: ['name'] }); expect(r.success).toBe(false); + // Select the TOMBSTONE issue by the shape `retiredKey()` raises rather + // than by its text. Since #17299 the overlay door also carries that text + // on the union WRAPPER (path `[]`), lifted verbatim from the issue below, + // so a text-only find is satisfied by either and this pin's subject — + // that the refusal is raised at the key the author wrote — needs the one + // that has a path. The two are asserted equal in + // `view-union-retirement-prescription.test.ts`. const issue = flatten((r as { error: z.ZodError }).error.issues) - .find((i) => i.message.includes('`view.pageName` was removed')); + .find((i) => (i as { expected?: string }).expected === 'never'); expect(issue, JSON.stringify((r as { error: z.ZodError }).error.issues)).toBeDefined(); + expect(issue!.message).toContain('`view.pageName` was removed'); expect(issue!.path.join('.')).toBe('pageName'); }); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 721c3930ef..046557c80e 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -4838,6 +4838,72 @@ function unclaimedBranchIssue( } as z.core.$ZodIssue; } +/** + * [#17299] The prescription a RETIREMENT tombstone raises, if the branch raised + * one — the FAMILY-WIDE half of {@link exportOptionsPdfUnionError}. + * + * ## The shape this reads, and why it is the family rather than a heuristic + * + * `retiredKey()` (`../shared/retired-key.ts`) is `z.never({ error: () => + * guidance }).optional()`, so a tombstone that is written to raises exactly one + * issue shape: `code: 'invalid_type'`, `expected: 'never'`, and a `message` + * that IS the upgrade prescription. That pair is not a guess about this file — + * it is the retirement channel's declared issue shape, named as such where the + * same shape is produced one grain wider (`ui/component.zod.ts`'s + * `retiredComponentProps`: *"`expected: 'never'` / `code: 'invalid_type'` is + * the same issue shape a key tombstone raises"*). + * + * ## Why the discriminant does not over-reach — measured, with a lit control + * + * `strictObject()` closes a shape with a `z.never()` CATCHALL, so the union's + * four members reach 67 `never` leaves in total and only 8 of them are + * tombstones. The other 59 are those catchalls — and they never produce this + * issue shape, because zod's object parser folds a rejecting `never` catchall + * into `code: 'unrecognized_keys'` instead (measured on the built artifact: + * a bogus key under `pagination` reports `unrecognized_keys`, never + * `expected: 'never'`). The control is lit by construction: those 59 ARE + * `z.never()` in the schema graph, so a discriminant that read the graph + * would have caught all 67; reading the raised ISSUE catches exactly the 8. + * + * So this is not "lift anything that looks like guidance". It lifts one + * declared issue shape, whose `message` is a prescription by the definition of + * the helper that produced it. + * + * ## Per case or family-wide + * + * {@link exportOptionsPdfUnionError} solved the same burial for ONE retired + * enum VALUE by re-reading `issue.input` structurally — the right shape there, + * because an enum-value narrowing leaves no tombstone to key on. A KEY + * retirement does leave one, and there are eight on this union's surface today + * reachable at four different depths (`virtualScroll`, `config.virtualScroll`, + * `list.virtualScroll`, `listViews..virtualScroll`), which a structural + * input-reader would have to re-implement the whole nesting to find. Reading + * the issue the tombstone already raised is depth-independent, and it means the + * NEXT retirement on this shape — there are 592 `retiredKey()` call sites in + * this package — is surfaced without anyone remembering to wire it. + * + * ## What it deliberately does not do + * + * The prescription is lifted **verbatim**: no prefix, no count, no decoration. + * The string is the migration document (`../shared/retired-key.ts`) and its + * wording is pinned class-wide by `retired-key-migrate-sentence.test.ts`; + * a message this function composed would be a second spelling of it. When a + * body writes more than one retired key the FIRST is lifted and the rest stay + * exactly where they were, reachable in the same nested `errors` as before — + * the top-level message is a pointer to the prescription, never a replacement + * for the issue list. + */ +function retirementPrescription(issues: readonly z.core.$ZodIssue[]): string | undefined { + for (const issue of issues) { + const candidate = issue as { code?: string; expected?: string; message?: unknown }; + if (candidate.code === 'invalid_type' && candidate.expected === 'never' + && typeof candidate.message === 'string' && candidate.message.length > 0) { + return candidate.message; + } + } + return undefined; +} + /** * [#7510] Focus a FAILED `ViewMetadataSchema` union on the branch the body * claims, so the branch that got furthest is the one whose prescription the @@ -4902,10 +4968,13 @@ function unclaimedBranchIssue( * - **The `errors` array keeps its length and its ORDER.** A muted branch is * replaced in place, not filtered out, so a positional consumer (objectui's * canary read `errors[2]`) still finds branch 2 where branch 2 was. - * - **The wrapper itself is untouched.** Whether zod emits the + * - **The wrapper's SHAPE is untouched.** Whether zod emits the * `invalid_union` wrapper or returns a lone non-aborted branch's issues * verbatim is decided in `handleUnionResults` before any check runs, so - * nothing about the envelope's shape or issue codes moves. + * nothing about the envelope's shape or issue codes moves. Its `message` is + * the one field #17299 may rewrite, and only to a prescription the claimed + * branch already raised — see {@link retirementPrescription}; every other + * failure keeps zod's `Invalid input` byte for byte. * - **An unclaimed body is left alone.** When no discriminant settles the claim * (`selectViewMetadataBranch` → `null`) the ranking keeps the case, exactly as * {@link diagnoseViewMetadata} keeps it (`candidates = claimed ? [claimed] : @@ -4928,8 +4997,18 @@ function focusClaimedBranch(): z.core.$ZodCheck { // have a different arity, and renaming its branches would be a lie. if (issue.errors.length !== VIEW_METADATA_BRANCHES.length) continue; + const claimedIssues = issue.errors[VIEW_METADATA_BRANCHES.indexOf(claimed)] ?? []; + // [#17299] A retirement prescription raised inside the claimed branch + // becomes the UNION's own message. Without this the top-level message is + // zod's bare `Invalid input` and the prescription sits at + // `issues[0].errors[k][j].message` — invisible at `PUT /api/v1/meta/view`, + // the door an MCP/AI author reaches. `undefined` leaves the message + // exactly as zod wrote it, which is every non-retirement failure. + const prescription = retirementPrescription(claimedIssues); + payload.issues[index] = { ...(payload.issues[index] as object), + ...(prescription === undefined ? {} : { message: prescription }), errors: issue.errors.map((branchIssues, position) => { const branch = VIEW_METADATA_BRANCHES[position]!; return branch === claimed