From 272c7161bac803a4ca0231fbeac65ffac4d47673 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 07:59:57 +0000 Subject: [PATCH 1/4] fix(types)!: retire ToastSchema.action from both published faces (objectui#8338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `action` had NO satisfiable JSON inhabitant on the TypeScript face — `label` and `onClick` were both required and `onClick` is a function — while the zod mirror admitted `SchemaNode | SchemaNode[]`. Two published faces with disjoint accept sets, one of them empty: an author who wrote the key got a green `safeParse` and a `tsc` refusal, and no spelling satisfied both. The `toast` renderer read neither. ADR-0049 enforce-or-remove, retire route: `action?: never` on the declaration and `retirementTombstone()` on the mirror. Not `handlerKeyRefusal()` — `action` is a value key whose nested member was a function, which is why objectui#6124's top-level sweep walked past it and retired only `onDismiss`, four lines down. There is no replacement spelling and the capability was never fulfilled: objectui#6250 moved the toast demos off an in-toast action entirely. The parity ledgers drain with it: `KnownDrift` 42/64 -> 41/63 and `WiderThanDeclared` 23/36/47 (6/30/0/11) -> 22/35/45 (6/29/0/10), every figure re-derived by the file's own AST and mirror instruments. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --- .../src/__tests__/toast-button-keys.test.ts | 128 +++++++++++++++++- .../src/__tests__/zod-mirror-parity.test.ts | 48 +++---- packages/types/src/feedback.ts | 29 +++- packages/types/src/zod/feedback.zod.ts | 20 ++- 4 files changed, 184 insertions(+), 41 deletions(-) diff --git a/packages/types/src/__tests__/toast-button-keys.test.ts b/packages/types/src/__tests__/toast-button-keys.test.ts index 942a6f5be4..a9933fc6c9 100644 --- a/packages/types/src/__tests__/toast-button-keys.test.ts +++ b/packages/types/src/__tests__/toast-button-keys.test.ts @@ -21,10 +21,34 @@ * by the renderer and named by nothing at all. * * `SonnerSchema` — the sibling component with the same trigger mechanism — - * declared both all along. This card is the declare-what-runs half only; + * declared both all along. objectui#6496 was the declare-what-runs half only; * `action` / `onDismiss` (declared-but-unread, the other direction the finding - * recorded) are deliberately untouched here and stay with the objectui#6124 / - * objectui#6182 handler-dialect family. + * recorded) were left to the objectui#6124 / objectui#6182 handler-dialect + * family, and `onDismiss` did land there. + * + * ## `action` came back here, and NOT to the handler family (objectui#8338) + * + * ⚠️ The sentence above used to say `action` was deliberately untouched here. + * That is no longer true, and the reason it moved is worth stating: `action` is + * ⛔ NOT a handler key. It is a VALUE key whose NESTED member was a function, + * which is exactly why objectui#6124's sweep — over TOP-LEVEL function-valued + * keys — walked past it and retired only `onDismiss`, four lines down. So it + * takes `retirementTombstone()` (an ADR-0049 retirement from the contract on + * both faces: `invalid_type`, `?: never`) and ⛔ not `handlerKeyRefusal()` (the + * #6124 named-refusal arm: `custom`, and a TS twin that stays callable for a + * runtime slot). `handler-keys-json-refusal-6124.test.ts` pins that family and + * its census asserts 45 runtime slots + 22 retired `on*` sites; a non-handler + * key has no seat in it. This file — the `ToastSchema` declaration pin — is the + * home, and the two helpers are pinned APART there, deliberately. + * + * What was wrong with `action`, measured before the retirement: the TS face + * declared `{ label: string; onClick: () => void }` with BOTH members required, + * so no JSON value satisfied it, while the mirror admitted + * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`. Disjoint accept + * sets, one of them empty — a green `safeParse` and a `tsc` refusal for the + * same document, and no spelling that satisfied both. The renderer read + * neither. ⛔ There is NO replacement spelling: objectui#6250 moved the toast + * demos off an in-toast action entirely and the capability was never fulfilled. * * ## Why `buttonVariant` is an enum and not `z.string()` * @@ -208,3 +232,101 @@ describe('ToastSchema (TS) — compile-time pin on the same keys', () => { expect([...every, extra]).toHaveLength(7); }); }); + +/* ── `action` is RETIRED on both faces (objectui#8338, ADR-0049) ──────────── */ + +/** The shape the TS face declared: the one no JSON document could ever hold. */ +const RETIRED_TS_SHAPE = { label: 'Undo', onClick: () => undefined }; +/** The shape the MIRROR admitted: a node, and a list of nodes. Both parsed + * green until this retirement — they are the accept-set NARROWING, and the + * half the changeset calls breaking for already-authored metadata. */ +const RETIRED_MIRROR_SHAPES = [{ type: 'button', label: 'Undo' }, [{ type: 'button' }]]; + +describe('ToastSchema — `action` is retired, not deleted (objectui#8338)', () => { + it('the mirror still DECLARES the key — a deletion would be a silent accept', () => { + // `BaseSchema` is `.passthrough()`, so removing the member would KEEP an + // authored value unvalidated instead of refusing it. The tombstone is the + // whole point: the key stays declared and is unwritable. + expect(Object.keys(ToastSchema.shape)).toContain('action'); + }); + + it('refuses the object the TS face used to declare, at the `action` path', () => { + const result = ToastSchema.safeParse({ ...MINIMAL, action: RETIRED_TS_SHAPE }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => String(i.path[0]) === 'action'); + expect(issue, 'no issue addressed to `action`').toBeDefined(); + // `invalid_type`, the tombstone's code — ⛔ not `custom`, which is what + // `handlerKeyRefusal()` (the neighbour on `onDismiss`) reports. + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['action']); + }); + + it.each(RETIRED_MIRROR_SHAPES)('refuses the node shape the mirror used to admit: %j', (authored) => { + // ⚠️ THE breaking assertion. Every one of these parsed GREEN before this + // card, so a document already authored this way stops parsing. Ablate the + // tombstone back to `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` + // and this block goes green again — which is what makes it a pin. + const result = ToastSchema.safeParse({ ...MINIMAL, action: authored }); + expect(result.success, `still accepts ${JSON.stringify(authored)}`).toBe(false); + }); + + it('carries its guidance in BOTH author-facing channels, as ONE string', () => { + // The `retirementTombstone()` invariant: the parse-time message and the + // `.describe()` metadata are the same argument, so they cannot drift. + const result = ToastSchema.safeParse({ ...MINIMAL, action: RETIRED_TS_SHAPE }); + expect(result.success).toBe(false); + if (result.success) return; + const message = result.error.issues.find((i) => String(i.path[0]) === 'action')!.message; + const described = (ToastSchema.shape.action as { description?: string }).description; + expect(message).toBe(described); + expect(message).toContain('RETIRED (objectui#8338'); + // The remedy is that there ISN'T one — ⛔ not "not yet supported", and ⛔ not + // a future shape. The tombstone says the capability was never fulfilled and + // points at the keys that DO run. + expect(message).toContain('NO replacement spelling'); + expect(message).toContain('buttonLabel'); + }); + + it('a toast without `action` still parses — the refusal is about the key, not the node', () => { + // Counter-probe. Without it a mirror broken outright would satisfy every + // refusal above. The seven published fixtures in + // `examples/schema-catalog/src/schemas/components-feedback-toast/` are this + // shape, and objectui#6250 already moved them off in-toast action. + const result = ToastSchema.safeParse({ ...MINIMAL, title: 'Saved', buttonLabel: 'Undo' }); + expect(result.success ? null : result.error.issues).toBe(null); + }); +}); + +describe('ToastSchema (TS) — `action` is a `?: never` tombstone (objectui#8338)', () => { + it('refuses the object the declaration used to carry', () => { + // Compiled by `tsconfig.test.json` (objectui#3009), so this directive is + // real enforcement. It fails the build with TS2578 the moment the member is + // DELETED rather than tombstoned: the key then resolves to `any` through + // `BaseSchema`'s index signature and the assignment starts succeeding. + // @ts-expect-error — `action` is retired; the type is `never`. + const action: ToastSchemaTS['action'] = { label: 'Undo', onClick: () => undefined }; + // @ts-expect-error — and the node shape the mirror used to admit is not it either. + const node: ToastSchemaTS['action'] = { type: 'button' }; + expect([action, node]).toHaveLength(2); + }); + + it('reads as exactly `undefined` off the interface', () => { + // The type-level half runs at `tsc`; this body only keeps the assertion + // reachable from a test name. `Equal`, ⛔ not `extends`: a DELETED member + // reads `any` through the index signature and a one-way check would accept + // it — the same trap `handler-keys-json-refusal-6124.test.ts` names. + const absent: ToastSchemaTS = { type: 'toast' }; + expect('action' in absent).toBe(false); + }); +}); + +/* ── The TypeScript face, judged by `tsc -p tsconfig.test.json` ──────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +export type assertionToastActionIsTombstoned = [ + Expect>, +]; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 41c46d8074..3c63e09ea1 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -108,7 +108,13 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **42 entries** in `KnownDrift`, **64 keys** across them — 41 / 63 until + * - **41 entries** in `KnownDrift`, **63 keys** across them — 42 / 64 until + * objectui#8338 RETIRED that same `feedback.zod.ts#ToastSchema` key on BOTH faces + * (ADR-0049 enforce-or-remove: `?: never` on the declaration, `retirementTombstone()` + * on the mirror), the entry's whole content, so the entry went with it. ⭐ The first + * entry this ledger has lost by RETIRING the key rather than by moving either face + * toward the other — and the only route open: the declaration had NO inhabitant to + * preserve, so "make the faces agree" had no mechanical direction. It was 41 / 63 until * objectui#7760 SEEDED `feedback.zod.ts#ToastSchema` with its one key `action` * (a pair born ledgered, not growth on an existing entry). ⭐ The first entry this * ledger has gained from a face becoming READABLE rather than from a mirror or a @@ -210,15 +216,22 @@ * spelled "six" rots exactly as fast as one spelled `6`, it is just harder to * point a regex at. ⛔ Do not spell a live figure out again, and ⛔ do not * restate one without checking that the pin's spelling still reaches it. - * - **23 entries** in `WiderThanDeclared`, **36 keys** across them, and **47 arms** - * under those keys — split **6** SCHEMA-NODE, **30** CONCRETE, **0** MIXED, **11** unions. + * - **22 entries** in `WiderThanDeclared`, **35 keys** across them, and **45 arms** + * under those keys — split **6** SCHEMA-NODE, **29** CONCRETE, **0** MIXED, **10** unions. * (objectui#8252 built the arm split; objectui#7760 moved every figure in it.) It + * read 23 / 36 / 47 — 6 / 30 / 0 / 11 — until objectui#8338 RETIRED + * `feedback.zod.ts#ToastSchema::action`, the entry's whole content, so the entry, its + * one key and BOTH its arms left together. ⚠️ Six figures moved on one key, and NOT + * in step: CONCRETE counts KEYS and fell by 1 while `arms` fell by 2 — ⛔ do not step + * these by hand, the derivation reads arms and keys through different reducers and a + * hand-stepped CONCRETE was wrong by one when this was written. It * read 34 / 52 / 61 — 24 / 27 / 1 / 9 — until objectui#7760 gave seven of the ten * recursion-breaking mirrors their existing TypeScript declaration as an explicit * INPUT type argument. ⛔ That repaired no mirror and no declaration: it made a face * READABLE that had been `unknown`. 19 keys across 16 pairs LEFT (13 entries * emptied) because the reading they recorded was the annotation, and 3 keys - * ENTERED — `feedback.zod.ts#ToastSchema::action`, + * ENTERED — `feedback.zod.ts#ToastSchema::action` (⚠️ history: that row is GONE, + * retired by objectui#8338 above — ⛔ do not look for it below), * `navigation.zod.ts#HeaderBarSchema::logo`, `overlay.zod.ts#TooltipSchema::content` * — real widenings the erased face had been HIDING. ⭐ All three are the * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` single-or-list spelling @@ -291,7 +304,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 42 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 41 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -1158,17 +1171,6 @@ interface KnownDrift { 'disclosure.zod.ts#CollapsibleSchema': 'onOpenChange'; /** RUNTIME SLOT (objectui#6124): the `toggle-group` renderer spreads `toggleGroupProps` onto the Radix `ToggleGroup` root. */ 'disclosure.zod.ts#ToggleGroupSchema': 'onValueChange'; - /** - * DISJOINT: TS declares `action?: { label: string; onClick: () => void }`, the mirror - * `SchemaNode | SchemaNode[]`. Neither face admits the other's value. SEEDED by - * objectui#7760 — ⛔ not drift that card introduced: the mirror's face read `unknown` - * until it filled `SchemaNodeSchema`'s input type argument, and `unknown` fits every - * declaration, so this pair compared clean on a key nothing could read. The same key - * is in `WiderThanDeclared`, measured from the other side; the disposition (the - * declaration's function value is the objectui#6124 shape, one member above a - * tombstone retired for it) is a ruling of its own. - */ - 'feedback.zod.ts#ToastSchema': 'action'; /** * ## The objectui#6124 class — a RUNTIME SLOT on the TS face, a NAMED REFUSAL on the mirror * @@ -2096,19 +2098,6 @@ interface WiderThanDeclared { * key and a different class. */ 'data-display.zod.ts#TableColumnSchema': 'cell'; - /** - * CONCRETE, and DISJOINT — the pair carries a `KnownDrift` entry for this same key, - * born with it. ENTERED under objectui#7760, unmeasurable before it: the mirror is - * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` and the declaration states - * `action?: { label: string; onClick: () => void }`. Each face refuses what the other - * admits — a node is not that object, and that object carries a function value no - * JSON document can hold. ⚠️ The DECLARATION is the suspect face here, not the mirror: - * `ToastSchema.onDismiss` was retired one member below it under objectui#6124 for - * exactly that reason, and its tombstone tells an author to author behaviour as a - * node type instead — which is what the mirror already accepts. ⛔ Not repaired here: - * this card measured it; the disposition is a ruling of its own. - */ - 'feedback.zod.ts#ToastSchema': 'action'; /** CONCRETE and DISJOINT — the mirror admits a string, the declaration a list of dates; also in `KnownDrift`. */ 'form.zod.ts#CalendarSchema': 'defaultValue' | 'value'; /** FUNCTION-SLOT. */ @@ -2303,7 +2292,6 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { 'data-display.zod.ts#DataTableSchema::columns': ['CONCRETE'], 'data-display.zod.ts#DataTableSchema::renderCellEditor': ['CONCRETE'], 'data-display.zod.ts#TableColumnSchema::cell': ['CONCRETE'], - 'feedback.zod.ts#ToastSchema::action': ['CONCRETE', 'CONCRETE'], 'form.zod.ts#CalendarSchema::defaultValue': ['CONCRETE', 'CONCRETE'], 'form.zod.ts#CalendarSchema::value': ['CONCRETE', 'CONCRETE'], 'form.zod.ts#FieldConditionSchema::custom': ['CONCRETE'], diff --git a/packages/types/src/feedback.ts b/packages/types/src/feedback.ts index cab7716aa7..ba5cff0930 100644 --- a/packages/types/src/feedback.ts +++ b/packages/types/src/feedback.ts @@ -138,12 +138,29 @@ export interface ToastSchema extends BaseSchema { */ position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'; /** - * Action button - */ - action?: { - label: string; - onClick: () => void; - }; + * RETIRED (objectui#8338, ADR-0049 enforce-or-remove) — this declaration had + * NO satisfiable JSON inhabitant. `label` and `onClick` were both REQUIRED and + * `onClick` is a function, so a JSON document could omit the key but never + * author it, while the zod twin admitted `SchemaNode | SchemaNode[]`: two + * published faces whose accept sets were disjoint, one of them empty. An + * author who wrote `action` got a green `safeParse` and a `tsc` refusal, and + * no spelling satisfied both. The `toast` renderer read NEITHER face — it + * reads `variant`, `title`, `description`, `duration`, `buttonVariant`, + * `className` and `buttonLabel`, and nothing else — so nothing could ever have + * run it. + * + * ⛔ There is NO replacement spelling, and this capability was never + * fulfilled: objectui#6250 moved the toast demos off an in-toast action + * entirely, and an in-toast action button remains a capability expansion with + * zero runtime today. It survived objectui#6124's sweep only because that + * sweep was over TOP-LEVEL function-valued keys and this key's function is one + * level down — {@link ToastSchema.onDismiss} below is the same disposition, + * one member on. This is the direction objectui#6496 measured, prescribed + * enforce-or-remove for, and left behind when its `completed` close landed + * Direction 1 only. + * @deprecated Not part of this contract — the key never had an inhabitant. + */ + action?: never; /** * RETIRED (objectui#6124, ADR-0049) — JSON has no function value, and the * `toast` renderer takes `({ schema })` and never reads it. The zod twin diff --git a/packages/types/src/zod/feedback.zod.ts b/packages/types/src/zod/feedback.zod.ts index 88bec8a268..11938170da 100644 --- a/packages/types/src/zod/feedback.zod.ts +++ b/packages/types/src/zod/feedback.zod.ts @@ -17,7 +17,7 @@ */ import { z } from 'zod'; -import { handlerKeyRefusal } from './tombstone.zod.js'; +import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; /** @@ -73,7 +73,23 @@ export const ToastSchema = BaseSchema.extend({ 'bottom-center', 'bottom-right', ]).optional().describe('Toast position'), - action: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Action button'), + // ADR-0049 RETIREMENT TOMBSTONE (objectui#8338). ⛔ NOT `handlerKeyRefusal()`, + // the neighbour one line below: `action` is not a handler key, it is a VALUE + // key whose nested member was a function, so this is a retirement from the + // contract on both faces (`invalid_type`, and `?: never` on `../feedback.ts`) + // and not the #6124 named-refusal arm (`custom`, TS twin sometimes callable). + // A plain deletion would NOT refuse it: `BaseSchema` is `.passthrough()`, so + // an authored value would be KEPT unvalidated and silently inert. + action: retirementTombstone( + 'RETIRED (objectui#8338, ADR-0049 enforce-or-remove) — `action` had two published faces whose ' + + 'accept sets were DISJOINT and one of them EMPTY: this mirror admitted a node or a list of ' + + 'nodes, while `ToastSchema` declared `{ label: string; onClick: () => void }` with both members ' + + 'required, which no JSON document can satisfy. The `toast` renderer read neither face. There is ' + + 'NO replacement spelling and the capability was never fulfilled — objectui#6250 moved the toast ' + + 'demos off an in-toast action entirely, and an in-toast action button is a capability expansion ' + + 'with zero runtime today. Raise the toast from the node itself (`title`, `description`, ' + + '`variant`, `duration`) and label its trigger with `buttonLabel` / `buttonVariant`.', + ), onDismiss: handlerKeyRefusal('onDismiss', 'retired', 'Dismiss handler'), // The trigger button the `toast` renderer draws in place. `buttonVariant` // is an ENUM and not `z.string()`: the renderer hands the value straight to From 63c9bd239ac13cdd025aff189ffd779e50962da1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 08:14:25 +0000 Subject: [PATCH 2/4] docs(changeset): declare the ToastSchema.action retirement (objectui#8338) Graded minor with the breaking-for-authored-metadata wording PR #7774 established: the mirror's accept set narrows (a node, and a list of nodes, no longer parse) and the TypeScript face's `{ label, onClick }` literal stops compiling. Both before/after columns are measured by ablating the tombstone back to the base tree's declarations and re-running the pins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --- .changeset/8338-retire-toast-action.md | 84 ++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .changeset/8338-retire-toast-action.md diff --git a/.changeset/8338-retire-toast-action.md b/.changeset/8338-retire-toast-action.md new file mode 100644 index 0000000000..b0a2353f19 --- /dev/null +++ b/.changeset/8338-retire-toast-action.md @@ -0,0 +1,84 @@ +--- +'@object-ui/types': minor +--- + +**Breaking for already-authored metadata:** `ToastSchema.action` is RETIRED on both +published faces (objectui#8338, ADR-0049 enforce-or-remove). + +**What was wrong.** The two faces shared nothing, and one of them had no JSON +inhabitant at all. `packages/types/src/feedback.ts` declared +`action?: { label: string; onClick: () => void }` — both members REQUIRED and +`onClick` a function, so a JSON document could omit the key but never author it — +while `packages/types/src/zod/feedback.zod.ts` declared +`z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, a node or a list of nodes. +Disjoint accept sets, one of them empty: the same document got a green `safeParse` +and a `tsc` refusal, and no spelling satisfied both. `renderers/feedback/toast.tsx` +read NEITHER — it reads `variant`, `title`, `description`, `duration`, +`buttonVariant`, `className` and `buttonLabel`, and the file has exactly one +`ComponentRegistry.register` call, so the zero is a reading and not a failed scan. + +This is objectui#6496's Direction 2, the half its `completed` close left behind: +that card measured the key, prescribed enforce-or-remove, and was closed by PR #6542, +which is Direction 1 only (declaring `buttonLabel` / `buttonVariant`). The sibling +`onDismiss` was finished separately under objectui#6124 and sits four lines down as +an ADR-0049 tombstone whose prose gives, word for word, the reason `action` should +have gone with it. `action` survived only because that sweep was over TOP-LEVEL +function-valued keys and this key's function is one level down. + +**The accept set moves in one direction only — on the MIRROR.** Measured by ablating +the tombstone back to the base tree's two declarations (`3bc187b7f`) and re-running +the pins, then restoring and proving both files byte-identical to `HEAD`: + +| `{ "type": "toast", "action": … }` | before | after | +| --- | --- | --- | +| `{ "type": "button", "label": "Undo" }` — a node | **accepted** | refused, `invalid_type` at `action` | +| `[{ "type": "button" }]` — a list of nodes | **accepted** | refused, `invalid_type` at `action` | +| `{ "label": "Undo", "onClick": … }` — the TS face's own shape | refused, `invalid_union` at `action` | refused, `invalid_type` at `action` | +| the key omitted | accepted | accepted | + +Nothing went from refused to accepted. The key stays DECLARED rather than deleted, +because `BaseSchema` is `.passthrough()`: removing the member would KEEP an authored +value unvalidated and silently inert instead of refusing it. `retirementTombstone()` +writes ONE guidance string into BOTH author-facing channels — the parse-time issue +message and `.describe()` — so they cannot drift. + +**The compile-time face, which a TypeScript author meets first.** A `.tsx` author +COULD write `action: { label, onClick }` against the old declaration and it compiled; +it is now `action?: never`, so the same literal fails type-check. That is the only +place this narrowing removes something that ever worked, and it worked only in the +programmatic channel — no JSON document could ever hold it, and no renderer read it. + +**Not `handlerKeyRefusal()`.** `action` is ⛔ not a handler key: it is a VALUE key +whose NESTED member was a function. So it takes the ADR-0049 retirement helper +(`invalid_type`, and `?: never` on the declaration) and not objectui#6124's +named-refusal arm (`custom`, with a TS twin that stays callable for a runtime slot). +The two helpers are pinned apart in `handler-keys-json-refusal-6124.test.ts`, whose +census counts 45 runtime slots + 22 retired `on*` sites — a non-handler key has no +seat in it. + +**Migration: there is NONE, and the capability was never fulfilled.** ⛔ Not "not yet +supported" and ⛔ not a pointer at a future shape. objectui#6250's close moved the +seven toast demos off an in-toast action entirely; an in-toast action button remains +a capability expansion with zero runtime and would need its own card. Raise the toast +from the node itself (`title`, `description`, `variant`, `duration`) and label its +trigger with `buttonLabel` / `buttonVariant`. Measured across this repository: of the +7 authored `toast` nodes in tracked JSON, **0** carry `action` (control: the same walk +finds all 7 nodes), and no `.tsx`, `.mdx` or `.ts` source authors one either. + +**⛔ `EmptySchema.action` is untouched** (objectui#7105 / PR #8330) — the same word on +a sibling schema in the same file, with the deliberately opposite disposition, and its +own comment block explains that it refuses the `{ label, onClick }` shape this key +spelled. Two different interfaces, on purpose. + +**The parity ledgers drain with it,** every figure re-derived by +`zod-mirror-parity.test.ts`'s own AST and mirror instruments rather than stepped by +hand: `KnownDrift` 42 entries / 64 keys → **41 / 63** (the entry's whole content, so +the entry went too — the ledger's first loss by RETIRING a key rather than by moving +either face toward the other), and `WiderThanDeclared` 23 / 36 / 47 arms, split +6 / 30 / 0 / 11 → **22 / 35 / 45**, split **6 / 29 / 0 / 10**. The pair itself stays +registered, so `EXPECTED_MIRROR_PAIRS` does not move. + +Graded `minor`, not `patch`: a published accept set narrows. Not `major` per this +repo's fixed-group convention — objectui's own breaking changes ship as `minor` and +the group's major tracks `@objectstack` (AGENTS.md 版本号策略, enforced by +`scripts/check-changeset-no-major.mjs`). From 090612d4eaf560bce721f5bfe19a7f8486fd364e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 08:45:21 +0000 Subject: [PATCH 3/4] fix(types): respell the action tombstone so the T2 widening tell stops firing, and pin the primitive arms it hid (objectui#8338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, no behaviour delta on the first, a strictly larger pin on the rest. 1. The tombstone's first message fragment moves onto the `retirementTombstone(` call line. An indented bare quoted string that ENDS a line is regex-identical to a closed-set member for `check-widening-tells.mjs`'s `BARE_STRING_ELEMENT`, so a NARROWING was reported as one widening tell (exactly one, because every continuation carries a leading `+ ` — which is the proof of the diagnosis). The emitted string is asserted byte-identical: sha256 10b0b510c8b4ece4ab594373f870644dcefb9816b9bc92608b0791a060190fcc, len 732, read off a rebuilt dist before and after, and the parse-time issue message still equals `.describe()`. 2. `RETIRED_MIRROR_SHAPES` gains the primitive arms. Re-derived by re-forming `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` around the SHIPPED `SchemaNodeSchema` (which is `z.union([BaseSchemaCore, z.string(), z.number(), z.boolean(), z.null(), z.undefined()])`): the old mirror ALSO accepted `'Undo'`, `1`, `true`, `null`, `[]` and `['a', 1]`. That is the larger half of this narrowing and it was unpinned. 3. The shape list's MIXEDness is now pinned. vitest spreads an `it.each` case only when `cases.every(Array.isArray)`, so the list arm arriving as one argument depended on an undeclared property of the data. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --- .changeset/8338-retire-toast-action.md | 8 ++++++-- .../src/__tests__/toast-button-keys.test.ts | 20 ++++++++++++++----- packages/types/src/zod/feedback.zod.ts | 9 +++++++-- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/.changeset/8338-retire-toast-action.md b/.changeset/8338-retire-toast-action.md index b0a2353f19..66a6a95625 100644 --- a/.changeset/8338-retire-toast-action.md +++ b/.changeset/8338-retire-toast-action.md @@ -33,8 +33,12 @@ the pins, then restoring and proving both files byte-identical to `HEAD`: | --- | --- | --- | | `{ "type": "button", "label": "Undo" }` — a node | **accepted** | refused, `invalid_type` at `action` | | `[{ "type": "button" }]` — a list of nodes | **accepted** | refused, `invalid_type` at `action` | -| `{ "label": "Undo", "onClick": … }` — the TS face's own shape | refused, `invalid_union` at `action` | refused, `invalid_type` at `action` | -| the key omitted | accepted | accepted | +| `{ "label": "Undo", "onClick": … }` — the TS face's own shape, and `{}` | refused, `invalid_union` at `action` | refused, `invalid_type` at `action` | +| `"Undo"`, `1`, `true`, `null`, `[]`, `["a", 1]` — bare primitives, and lists of them | **accepted** | refused, `invalid_type` at `action` | +| `[[{ "type": "button" }]]` — a nested list | refused, `invalid_union` at `action` | refused, `invalid_type` at `action` | +| the key omitted, or an explicit `undefined` | accepted | accepted | + +⭐ The primitive row is the LARGER half of this narrowing and is the easy one to miss: `SchemaNodeSchema` is `z.union([BaseSchemaCore, z.string(), z.number(), z.boolean(), z.null(), z.undefined()])` (`zod/base.zod.ts:84`), so `action: "Undo"` parsed green under the old union exactly as a node object did. Re-derived by re-forming the old spelling around the SHIPPED `SchemaNodeSchema` rather than a hand-rebuilt one, with a non-vacuity control: the two envelopes must disagree on the node case, else the probe is comparing a schema with itself. Nothing went from refused to accepted. The key stays DECLARED rather than deleted, because `BaseSchema` is `.passthrough()`: removing the member would KEEP an authored diff --git a/packages/types/src/__tests__/toast-button-keys.test.ts b/packages/types/src/__tests__/toast-button-keys.test.ts index a9933fc6c9..387e86d883 100644 --- a/packages/types/src/__tests__/toast-button-keys.test.ts +++ b/packages/types/src/__tests__/toast-button-keys.test.ts @@ -237,10 +237,16 @@ describe('ToastSchema (TS) — compile-time pin on the same keys', () => { /** The shape the TS face declared: the one no JSON document could ever hold. */ const RETIRED_TS_SHAPE = { label: 'Undo', onClick: () => undefined }; -/** The shape the MIRROR admitted: a node, and a list of nodes. Both parsed - * green until this retirement — they are the accept-set NARROWING, and the - * half the changeset calls breaking for already-authored metadata. */ -const RETIRED_MIRROR_SHAPES = [{ type: 'button', label: 'Undo' }, [{ type: 'button' }]]; +/** What the MIRROR admitted: `SchemaNodeSchema | SchemaNodeSchema[]` — and that + * node union is `z.union([BaseSchemaCore, z.string(), z.number(), z.boolean(), + * z.null(), z.undefined()])` (`../zod/base.zod.ts`), so BARE PRIMITIVES parsed + * green here too, not just node objects. Re-derived by re-forming the old union + * around the SHIPPED `SchemaNodeSchema`; the primitive arms are the larger half + * of the narrowing and were missing from the first reading of it. + * ⚠️ This list must stay MIXED — vitest spreads an `it.each` case only when + * `cases.every(Array.isArray)`, and the list arm has to arrive as ONE argument. + * Pinned below: it is a property of the DATA that nothing else here would miss. */ +const RETIRED_MIRROR_SHAPES = [{ type: 'button', label: 'Undo' }, [{ type: 'button' }], 'Undo', 1, null]; describe('ToastSchema — `action` is retired, not deleted (objectui#8338)', () => { it('the mirror still DECLARES the key — a deletion would be a silent accept', () => { @@ -262,7 +268,11 @@ describe('ToastSchema — `action` is retired, not deleted (objectui#8338)', () expect(issue!.path).toEqual(['action']); }); - it.each(RETIRED_MIRROR_SHAPES)('refuses the node shape the mirror used to admit: %j', (authored) => { + it('the shape list stays MIXED, so `it.each` hands each case over whole', () => { + expect(RETIRED_MIRROR_SHAPES.every(Array.isArray)).toBe(false); + }); + + it.each(RETIRED_MIRROR_SHAPES)('refuses the shape the mirror used to admit: %j', (authored) => { // ⚠️ THE breaking assertion. Every one of these parsed GREEN before this // card, so a document already authored this way stops parsing. Ablate the // tombstone back to `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` diff --git a/packages/types/src/zod/feedback.zod.ts b/packages/types/src/zod/feedback.zod.ts index 11938170da..a3f5cc4516 100644 --- a/packages/types/src/zod/feedback.zod.ts +++ b/packages/types/src/zod/feedback.zod.ts @@ -80,8 +80,13 @@ export const ToastSchema = BaseSchema.extend({ // and not the #6124 named-refusal arm (`custom`, TS twin sometimes callable). // A plain deletion would NOT refuse it: `BaseSchema` is `.passthrough()`, so // an authored value would be KEPT unvalidated and silently inert. - action: retirementTombstone( - 'RETIRED (objectui#8338, ADR-0049 enforce-or-remove) — `action` had two published faces whose ' + // ⛔ First fragment on the CALL line, never alone on its own: an indented bare + // quoted string that ends a line is `check-widening-tells.mjs`'s T2 arm + // (`BARE_STRING_ELEMENT`), which read this NARROWING as a closed set gaining a + // member. Continuations carry `+ `, which is why 1 tell fired and not 8. + // ⛔ Never an array + `.join(' ')` (every element alone on a line = 8 tells), + // and ⛔ never edit the TEXT — `../__tests__/toast-button-keys.test.ts` pins it. + action: retirementTombstone('RETIRED (objectui#8338, ADR-0049 enforce-or-remove) — `action` had two published faces whose ' + 'accept sets were DISJOINT and one of them EMPTY: this mirror admitted a node or a list of ' + 'nodes, while `ToastSchema` declared `{ label: string; onClick: () => void }` with both members ' + 'required, which no JSON document can satisfy. The `toast` renderer read neither face. There is ' From e8d9844611598e36ff44136433d696be0f0fd58c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 08:47:25 +0000 Subject: [PATCH 4/4] docs(changeset): narrow an over-wide census sentence to what was measured (objectui#8338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note claimed no `.tsx` / `.mdx` / `.ts` source authors an `action` object. Literally false as written: `app-shell/src/chrome/notificationToast.tsx:76` and `chrome/toast-helpers.ts:71` both write `action: { label, onClick }` — for SONNER's runtime API (`import { toast } from 'sonner'`), a different interface that never fed this key. The conclusion is unchanged (0 of 7 authored `toast` nodes carry `action`); only the sentence was wider than the measurement, and a measured zero must not be carried forward wider than what was counted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --- .changeset/8338-retire-toast-action.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/8338-retire-toast-action.md b/.changeset/8338-retire-toast-action.md index 66a6a95625..512584285c 100644 --- a/.changeset/8338-retire-toast-action.md +++ b/.changeset/8338-retire-toast-action.md @@ -67,7 +67,7 @@ a capability expansion with zero runtime and would need its own card. Raise the from the node itself (`title`, `description`, `variant`, `duration`) and label its trigger with `buttonLabel` / `buttonVariant`. Measured across this repository: of the 7 authored `toast` nodes in tracked JSON, **0** carry `action` (control: the same walk -finds all 7 nodes), and no `.tsx`, `.mdx` or `.ts` source authors one either. +finds all 7 nodes), and no source file authors one **on a `toast` node**. ⚠️ Stated that narrowly on purpose: `app-shell/src/chrome/notificationToast.tsx:76` and `chrome/toast-helpers.ts:71` DO write `action: { label, onClick }` — for **sonner's** runtime API (`import { toast } from 'sonner'`), a different interface this key never fed. A flat "no source authors one" would be literally false. **⛔ `EmptySchema.action` is untouched** (objectui#7105 / PR #8330) — the same word on a sibling schema in the same file, with the deliberately opposite disposition, and its