From 65f8d0436a6da4505ec5f0a65bdafd3e7cb92446 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 23:33:07 +0000 Subject: [PATCH] refactor(types)!: retire the inert node-recursion-point refinement, and repair the pin that read its shape (objectui#9659) The objectui#8344 `superRefine` clause on `defineNodeComponentUnion` could no longer fire once ruling A on objectui#8572 made `ChatbotSchema.body` an ADR-0049 tombstone: zod skips a check once the schema it wraps has refused, and the arm refuses every defined `body`, while the clause returns early on `undefined`. Measured on the issue tree of a nested refusal (the arm's tombstone, no clause-shaped issue) against a lit control built from the pre-retirement record arm, which produces the clause's issue and no tombstone. The accept set does not move: byte-identical verdicts over 432 schema-catalog documents plus a 60-case sweep of three chatbot faces x ten `body` shapes x both depths. The wrapper's other role is kept. The read-back assertion is untouched and the `fill is LIVE` leg is re-pointed from the wrapper's shape to the installation. A second leg was vacuous: `names body in the refusal` read the serialized issue blob for `"body"`, which the parent card slot supplies for any refused child. It now reads the child's own `body` path and asserts the remedy reaches the author at depth, with the three documents that made it vacuous pinned as controls. Also annotates the plain `chatbot` row of the objectui#9256 narrowing table as a one-sided reading, and amends the two prose sites this change falsified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UanLVj6xvbS6puBCewLr8L --- .../8344-node-recursion-point-redirect.md | 13 +++ .../9659-node-recursion-point-inert-clause.md | 74 +++++++++++++++ .../content-channel-family-d-9256.test.ts | 16 ++++ .../node-recursion-point-8344.test.ts | 94 ++++++++++++++++--- packages/types/src/zod/base.zod.ts | 80 +++++++++------- packages/types/src/zod/index.zod.ts | 7 +- 6 files changed, 234 insertions(+), 50 deletions(-) create mode 100644 .changeset/9659-node-recursion-point-inert-clause.md diff --git a/.changeset/8344-node-recursion-point-redirect.md b/.changeset/8344-node-recursion-point-redirect.md index 765ba7028d..e5a3b583b4 100644 --- a/.changeset/8344-node-recursion-point-redirect.md +++ b/.changeset/8344-node-recursion-point-redirect.md @@ -72,6 +72,19 @@ and the deferral itself is the part that stayed true. ⛔ Nothing else in the pa the nested verdict, the installed arm's check and the "narrows at all 109, widens at none" reading are all readings of THIS card's head and cannot rot. +⚠️ **AMENDED AGAIN (objectui#9659 — also landing in THIS release).** One clause of the note +above has since moved: "the installed arm's check" is GONE. The check it names was the +`superRefine` clause on `defineNodeComponentUnion`, and once ruling A retired the key it +narrowed, it could no longer fire for any input — measured on the issue tree of a nested +refusal, which carries the arm's own tombstone and no clause-shaped issue, against a lit +control built from the pre-retirement record arm. objectui#9659 retires the clause and +re-points the pin that read its shape. ⛔ The READING is unaffected and still cannot rot: the +nested verdict is the same (refused before, refused now, by the arm instead of by the +clause), the accept set is byte-identical over 432 corpus documents plus a 60-case chatbot +sweep, and "narrows at all 109, widens at none" is still what this card's redirect does. What +changed is WHICH mechanism delivers the nested refusal, and the sentence above names the +retired one. + **3. ⚠️ KNOWN GAP, declared rather than papered over: a bundled consumer that never reads `AnyComponentSchema` can tree-shake the redirect away.** This package declares `"sideEffects": false` and the arm is filled by a statement in the `./zod` barrel's body, so a diff --git a/.changeset/9659-node-recursion-point-inert-clause.md b/.changeset/9659-node-recursion-point-inert-clause.md new file mode 100644 index 0000000000..7e45a02bdc --- /dev/null +++ b/.changeset/9659-node-recursion-point-inert-clause.md @@ -0,0 +1,74 @@ +--- +'@object-ui/types': patch +--- + +Retire the objectui#8344 `superRefine` clause on the node recursion point, and re-point the +two test legs that had become readings of an inert thing (objectui#9659, carrying +non-blocking residuals a contract review named on objectui#9639). + +**What was there.** `defineNodeComponentUnion` (`zod/base.zod.ts`) installed the component +union into the node slot WRAPPED in a `superRefine` clause that re-issued the node-slot's own +refusal under `body` for a `chatbot` node. #8344 needed it because `ChatbotSchema.body` was +then a record — the one redeclaration across the arms that was WIDER than the base key it +restated — so installing the union bare would have narrowed at 108 child slots and widened at +one. + +**Why it is gone, measured rather than argued.** Ruling A on objectui#8572 made +`ChatbotSchema.body` an ADR-0049 retirement tombstone on both published faces, and +objectui#9639 landed it. The clause could then no longer FIRE, which is a stronger statement +than "no longer matter": + +- zod skips a check once the schema it wraps has refused, and the arm refuses every DEFINED + `body` — measured over record / node / node array / string / number / boolean / null / + empty array / empty record, all REFUSED — while the clause's own first line returns early + on `undefined`. The two conditions "reaches the clause" and "has a `body` to check" are + disjoint, with no input in between. +- read off the issue tree of a nested refusal: the arm's tombstone at the child's own `body` + path and NO clause-shaped issue. LIT CONTROL, same probe, same document, with the + pre-objectui#9639 record arm rebuilt in place: the clause's issue appears there and the + tombstone does not. That control is what makes the zero a reading instead of a broken + instrument. + +**The accept set does not move, and that is measured too.** 432 schema-catalog documents +through `safeValidateSchema`, plus a 60-case sweep of the three chatbot faces x ten `body` +shapes x both depths: byte-identical verdict list before and after. A clause that cannot fire +cannot be narrowing anything. + +**The wrapper's OTHER role is kept, not dropped.** The read-back assertion that announces a +zod which stopped keeping its option array by reference is untouched, and +`node-recursion-point-8344.test.ts`'s `fill is LIVE` leg is re-pointed from the wrapper's +SHAPE — `not.toBe` the bare union, plus `checks` of length exactly 1 — to the INSTALLATION: +slot 0 holds the component union and not the pre-#8344 base shape. That pin still fails on +the failure it exists for; an unfilled holder answers a different object. + +**A second leg was vacuous and is repaired.** `names body in the refusal` read +`JSON.stringify(issues)` for the substring `"body"`, which the PARENT card slot supplies for +any refused child. Measured: three documents with nothing wrong at `body` — a nested off-spec +`icon`, a nested unmirrored `metric-card`, a nested `chatbot` missing `messages` — all +satisfy the old assertion, and none carries an issue at the child's own `body` path. The leg +now reads that path, asserts the remedy reaches the author at DEPTH and not only at the root, +and the three controls are pinned alongside it so the discrimination is tested rather than +described. + +**`patch` rather than empty frontmatter, and the grade is measured.** `packages/types` +publishes `dist`, and this change moves published bytes: the clause leaves the shipped bundle +(`dist/zod/index.zod.js` 515,520 to 516,078 bytes; `dist/zod/base.zod.js` 43,462 to 44,370), +and the declaration emit moves with it. Positive control: neighbouring shipped content is +present in both builds and two builds of the same source are byte-identical, so the delta is +this change and not build noise. ⛔ An empty frontmatter here would have been a claim that +nothing published moved, and it would have been false. + +**Prose amended where this change falsified it, ⛔ not rewritten.** `zod/index.zod.ts` said +`defineNodeComponentUnion` "wraps it rather than replacing it" — true only while a wrapper +existed. `zod/base.zod.ts`'s note on the loose parameter bound still names `ChatbotSchema`'s +record `body` in the present tense; the paragraph is kept for the reason it records and +carries an AMENDED note saying the exclusion set that pin reads is now empty. + +**One reading in a neighbouring table got a line, not a rewrite.** In +`content-channel-family-d-9256.test.ts` the plain `chatbot` row lists `children` only, which +on the two TWIN faces still means "`body` held out and live" — and objectui#9639 had to +re-point that table's LIVE CONTROL at a twin precisely because the plain face refuses `body` +now, for objectui#8572's reason rather than this table's. Measured: `body` ACCEPTED on +`chatbot-enhanced` and `chatbot-floating`, REFUSED on `chatbot`. The row is annotated as +one-sided so the next reader does not take it for a two-sided reading. ⛔ No assertion in that +table moved. diff --git a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts index 3056f73565..c7d627cba6 100644 --- a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts @@ -285,6 +285,22 @@ const ROWS: ReadonlyArray { + it('the fill is LIVE, and slot 0 holds the COMPONENT UNION, not the pre-objectui#8344 base shape', () => { // `z.union` re-reads its option array on every parse, so the recursion point is whatever // slot 0 holds NOW — not whatever it held when some other file in this worker first // parsed something (the unit project runs `isolate: false`, one module graph per worker). - // ⛔ Do not assert `toBe(AnyComponentSchema)` here: what is installed is deliberately a - // `superRefine` WRAPPER rather than the bare union, and a pin on the bare union would go - // green the moment the wrapper stopped being installed. ⚠️ The clause that wrapper carries - // is objectui#8344's `chatbot` body narrowing, and objectui#8572 retired the arm it - // narrowed — so what this leg reads is the wrapper's INSTALLATION, not the clause's - // usefulness; the clause's disposition belongs to `../zod/base.zod.ts` and is not made - // here. + // + // ⭐ RE-POINTED at the INSTALLATION by objectui#9659, carrying a contract-review residual + // on objectui#9639. This leg used to read the wrapper's SHAPE — `not.toBe` the bare union, + // plus `checks` of length exactly 1 — because `defineNodeComponentUnion` installed a + // `superRefine` clause narrowing objectui#8344's `chatbot` `body`. Ruling A on + // objectui#8572 retired the arm that clause narrowed, and objectui#9659 measured the + // consequence: the clause could no longer FIRE for any input, so those two assertions had + // become a pin on the shape of an inert clause. The clause is retired at its source and + // this leg now reads what it was always really for — that the fill TOOK. + // + // ⚠️ The old warning here — "⛔ do not assert `toBe(AnyComponentSchema)`, it would go green + // the moment the wrapper stopped being installed" — was correct WHILE a wrapper existed, + // and it retires with the wrapper. It is not a licence to reintroduce one: with nothing + // wrapped, identity with the component union is the strongest reading available, and it + // FAILS on the failure this leg exists for — an unfilled holder still answers + // `BaseSchemaCore`, which is a different object. const arm = (SchemaNodeSchema as unknown as { _zod: { def: { getter: () => { _zod: { def: { options: readonly { _zod: { propValues?: Record< string, unknown >; def: { checks?: unknown[] } } }[] } } } } }; })._zod.def.getter()._zod.def.options[0]; - expect(arm).not.toBe(AnyComponentSchema); - // it is still the discriminated union objectui#8498 built — the discrimination survives - // the wrapper, which is what keeps a nested refusal costing one arm instead of 106 — + expect(arm).toBe(AnyComponentSchema as unknown as typeof arm); + // and it is still the discriminated union objectui#8498 built, which is what keeps a + // nested refusal costing one arm instead of 106. expect(Object.keys(arm._zod.propValues ?? {})).toContain('type'); - // and it carries exactly the one check that narrowing adds. - expect(arm._zod.def.checks).toHaveLength(1); }); }); @@ -273,10 +280,69 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a expect(AnyComponentSchema.safeParse(nested(CHATBOT)).success).toBe(true); }); - it('names `body` in the refusal, so the author is told which key is wrong', () => { + it('names `body` AT ITS OWN PATH one slot down, and carries the remedy there too', () => { + // ⭐ RE-POINTED by objectui#9659, carrying a contract-review residual on objectui#9639. + // This leg used to read `JSON.stringify(issues)` for the substring `"body"` and was + // VACUOUS: the parent card slot puts `"body"` in the issue path for ANY refused child, so + // the old assertion held whether or not the `chatbot` arm named anything. Measured on this + // head — three documents with nothing wrong at `body`, all three satisfying the old + // assertion, none of them carrying an issue at the child's own `body` path: + // + // nested off-spec `icon` (size: 'huge') → blob contains `"body"`: true, issues at + // `body.0.body`: 0 + // nested unmirrored `metric-card` → true / 0 + // nested `chatbot` missing `messages` → true / 0 + // nested `chatbot` with a record `body` → true / 1 ← the only one that is about `body` + // + // ⇒ the reading that discriminates is the child's OWN path, not the serialized blob. The + // controls above are kept as assertions below so the discrimination is pinned rather than + // recorded in prose. const result = AnyComponentSchema.safeParse(nested(withRecordBody)); expect(result.success).toBe(false); if (result.success) return; + const at = (issues: readonly unknown[], path: string): { code?: string; message?: string }[] => { + const out: { code?: string; message?: string }[] = []; + const walk = (node: unknown, prefix: readonly (string | number)[]): void => { + if (Array.isArray(node)) { for (const child of node) walk(child, prefix); return; } + const issue = node as { path?: readonly (string | number)[]; errors?: readonly unknown[]; code?: string; message?: string }; + const here = [...prefix, ...(issue.path ?? [])]; + if (here.join('.') === path) out.push({ code: issue.code, message: issue.message }); + for (const bucket of issue.errors ?? []) walk(bucket, here); + }; + walk(issues, []); + return out; + }; + const named = at(result.error.issues, 'body.0.body'); + expect(named.length).toBeGreaterThan(0); + // the refusal one slot down is the ARM's tombstone, and the REMEDY reaches the author at + // depth and not only at the root — the root leg above reads the same string at depth 0. + expect(named.some((i) => i.code === 'invalid_type')).toBe(true); + expect(named.some((i) => (i.message ?? '').includes('requestBody'))).toBe(true); + }); + + it.each([ + ['off-spec `icon`', { type: 'icon', icon: 'check', size: 'huge' }], + ['unmirrored `metric-card`', { type: 'metric-card', title: 'x' }], + ['`chatbot` missing `messages`', { type: 'chatbot' }], + ])('CONTROL — a nested %s is refused with NOTHING at the child\'s `body` path', (_label, child) => { + // These are the documents that made the old leg vacuous. Each is refused for a reason that + // has nothing to do with `body`, so the leg above must find nothing at `body.0.body` here. + // ⛔ Do not "repair" a future failure by widening the path: a refusal that starts naming + // `body` for an off-spec `icon` is a defect in the recursion point, not in this control. + const result = AnyComponentSchema.safeParse(nested(child)); + expect(result.success).toBe(false); + if (result.success) return; + const paths: string[] = []; + const walk = (node: unknown, prefix: readonly (string | number)[]): void => { + if (Array.isArray(node)) { for (const c of node) walk(c, prefix); return; } + const issue = node as { path?: readonly (string | number)[]; errors?: readonly unknown[] }; + const here = [...prefix, ...(issue.path ?? [])]; + paths.push(here.join('.')); + for (const bucket of issue.errors ?? []) walk(bucket, here); + }; + walk(result.error.issues, []); + expect(paths).not.toContain('body.0.body'); + // and the old assertion holds anyway — which is the whole reason it was replaced. expect(JSON.stringify(result.error.issues)).toContain('"body"'); }); }); diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index b48dd0cc13..bf2529370f 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -127,51 +127,61 @@ export const KeyedI18nLabelSchema = z.object({ * naming that single arm in `__tests__/node-recursion-point-8344.test.ts`. ⇒ a SECOND * arm drifting the same way turns that pin red instead of passing unnoticed. * + * ⚠️ AMENDED (objectui#8572 ruling A, recorded here by objectui#9659). The paragraph + * above is kept in the tense it was written in, because what it records is WHY the bound is + * loose — but the arm it names has since moved: `ChatbotSchema.body` is an ADR-0049 + * retirement tombstone on both published faces, so the exclusion set that pin reads is now + * EMPTY and the pin asserts `never`. ⛔ Do not read the paragraph as a live census of what + * `tsc` refuses today; the pin is the instrument, and it is the one that answers. + * * @internal — the package's only zod entry point is the `./zod` barrel, which is * `index.zod.ts`; this exists for that one call site and is not re-exported. */ export function defineNodeComponentUnion(union: T): T { - // ⭐ objectui#8344 F2 — what goes into the slot is the union WRAPPED, never the bare union. + // ⭐ objectui#8344 F2 — what goes into the slot is the component union itself. + // + // WHY A `superRefine` WRAPPER USED TO SIT HERE. `ChatbotSchema.body` mirrored the chat API's + // body params as a record, which was WIDER than `BaseSchemaCore.body` — the only wider + // redeclaration among the 109 base-key redeclarations across the arms — so installing the + // union bare would have narrowed at 108 child slots and WIDENED at one. #8344's appetite + // forbade widening in flight, so the INSTALLED arm carried a clause that re-issued the + // node-slot's own refusal under `body` for a `chatbot` node, and the published mirror was + // left alone. + // + // WHY IT IS GONE (objectui#9659, carrying a contract-review residual on objectui#9639). + // Ruling A on objectui#8572 made `ChatbotSchema.body` an ADR-0049 retirement tombstone on + // both published faces, so the arm refuses the key BY ITSELF, at every depth. The note that + // stood here said the clause was REDUNDANT but ⛔ not removable "until a card decides + // otherwise". This is that card, and the disposition rests on a MEASUREMENT rather than on + // the redundancy argument: + // + // 1. the clause could no longer FIRE, not merely no longer matter. zod skips a check once + // the schema it wraps has refused, and the arm refuses every DEFINED `body` — measured + // key by key over record / node / node[] / string / number / boolean / null / [] / {}, + // all REFUSED — while the clause's own first line returns early on `undefined`. ⇒ the + // two conditions "reaches the clause" and "has a `body` to check" had become disjoint; + // 2. read on the issue tree of a nested refusal: the arm's tombstone at `body.0.body` and + // NO clause-shaped issue. The same probe against the pre-#9639 record arm produces the + // clause's `body.0.body` issue and no tombstone — the lit control that makes the zero + // above a reading rather than a broken instrument; + // 3. ⇒ the accept set does not move: a clause that cannot fire cannot be narrowing + // anything. Pinned over the 432-document schema catalog plus a 60-case sweep of the + // three chatbot faces x ten `body` shapes x both depths, byte-identical either way. // - // WHY THE CLAUSE WAS WRITTEN. `ChatbotSchema.body` mirrored the chat API's body params as a - // record, which was WIDER than `BaseSchemaCore.body` — the only wider redeclaration among the - // 109 base-key redeclarations across the arms — so installing the bare union would have - // narrowed at 108 child slots and WIDENED at one. #8344's appetite forbade widening in - // flight, so the arm took the check and the published mirror was left alone, which is why a - // root `chatbot` with a record `body` parsed while the same node one slot down did not. + // ⛔ What must NOT happen now is the opposite of #8344's old instruction: do not restore a + // record `body` arm on that mirror to make a narrowing clause meaningful again. // - // ⚠️ AMENDED (objectui#8572, ruling A). That ruling was made: `ChatbotSchema.body` is an - // ADR-0049 retirement tombstone on both published faces now, pointing the author at - // `requestBody`. Three consequences, stated because the sentences above would otherwise read - // as live: - // 1. the arm refuses the key BY ITSELF, at every depth, so the asymmetry this clause - // existed to prevent no longer exists and the clause is REDUNDANT; - // 2. ⛔ redundant is not removable. The WRAPPER is load-bearing independently of what it - // checks: `__tests__/node-recursion-point-8344.test.ts`'s `the fill is LIVE` leg reads - // that slot 0 holds the WRAPPED union and not the bare one, and the read-back assertion - // below throws when the recursion point did not take. Dropping the clause means - // rebuilding what gets installed — surgery on #8344's recursion point, not a deletion — - // so it stays exactly as it is until a card decides otherwise; - // 3. the old instruction here said ⛔ not to narrow `ChatbotSchema` because that face was - // not #8344's to move and the question was recorded on objectui#8572. That question has - // been answered, so the instruction is retired with it: what ⛔ must not happen now is - // the opposite — do not restore a record `body` arm on that mirror to make this clause - // meaningful again. - const installed = union.superRefine((value, ctx) => { - const node = value as { type?: unknown; body?: unknown } | null | undefined; - if (!node || node.type !== 'chatbot' || node.body === undefined) return; - const asNodeSlot = BaseSchemaCore.shape.body.safeParse(node.body); - if (asNodeSlot.success) return; - for (const issue of asNodeSlot.error.issues) { - ctx.addIssue({ ...issue, path: ['body', ...issue.path] }); - } - }) as unknown as T; - nodeUnionOptions[0] = installed; + // ⚠️ The WRAPPER was load-bearing independently of what it checked, and that role is kept, + // not dropped: `__tests__/node-recursion-point-8344.test.ts` reads that slot 0 holds the + // component union and not the pre-#8344 `BaseSchemaCore`, and the read-back assertion below + // throws when the recursion point did not take. Retiring the clause re-points both at the + // INSTALLATION, which is what they were really for. + nodeUnionOptions[0] = union; // The assertion the paragraph above exists for. ⛔ Do not delete it as noise: it is // the only thing standing between a zod that copies its option array and a // recursion point that silently reverts to the pre-#8344 base shape. const readBack = (nodeUnion as unknown as { _zod: { def: { options: readonly unknown[] } } })._zod.def.options[0]; - if (readBack !== installed) { + if (readBack !== union) { throw new Error( 'objectui#8344: `z.union` no longer keeps its option array by reference, so the node ' + 'recursion point did not take. The redirect is INERT and every nested node is being ' diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 31ad271596..f8ff265bbb 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -453,7 +453,12 @@ import { ViewComponentSchema } from './views.zod.js'; * ⚠️ BOTH of the above are live here, and the composition is the whole resolution: * objectui#8498 changed WHICH arm reports, objectui#8344 changed WHERE this union is * consulted. The discriminated union is what gets written into the node option slot, - * so `defineNodeComponentUnion` wraps it rather than replacing it. The slot itself is + * unchanged: `defineNodeComponentUnion` installs it as it stands. ⚠️ AMENDED + * (objectui#9659) — this used to read "so `defineNodeComponentUnion` wraps it rather + * than replacing it", which named the `superRefine` clause objectui#8344 carried on the + * installed arm. Ruling A on objectui#8572 retired the key that clause narrowed, + * objectui#9659 measured that it could no longer fire for any input, and it is gone; the + * fill is an installation and nothing more. The slot itself is * still a plain `z.union` in `base.zod.ts` — that is what keeps its option array by * reference, and it is untouched by the discrimination. */