From ccf64eb302fc12e652580093815641399caac803 Mon Sep 17 00:00:00 2001 From: hotlong Date: Wed, 16 Sep 2026 16:01:46 +0000 Subject: [PATCH 1/2] feat(types)!: retire ChatbotSchema.body on both published faces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling A on objectui#8572 (decision batch #137, item 4): the chat API's body params are authored as `requestBody`, and `body` means the content slot on every component. The record-shaped arm on the zod mirror becomes an ADR-0049 retirement tombstone and the TypeScript face gains the matching `?: never`, so `tsc` and the parse face say the same thing. The two load-bearing pins in node-recursion-point-8344.test.ts are INVERTED, not deleted: the root case asserts the refusal (path, code and the named replacement) and `ArmsNotAssignableToSchemaNode` reads `never` — with an empty-set guard, because the bare projection resolves to `unknown` on an empty exclusion set. zod-mirror-parity.test.ts drops the key from `KnownDrift` and the whole `WiderThanDeclared` entry, header figures re-derived by that file's own pins. Two pending changesets whose paragraphs this falsifies are amended in place, prose only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VCpmqvacV4BypY48QdoxcE --- .../8344-node-recursion-point-redirect.md | 10 ++ .changeset/8572-chatbot-body-retired.md | 117 ++++++++++++++++++ .changeset/9256-content-channel-family-d.md | 7 ++ .../node-recursion-point-8344.test.ts | 93 ++++++++++---- .../src/__tests__/zod-mirror-parity.test.ts | 57 ++++++--- packages/types/src/complex.ts | 47 +++++++ packages/types/src/zod/complex.zod.ts | 29 +++-- 7 files changed, 310 insertions(+), 50 deletions(-) create mode 100644 .changeset/8572-chatbot-body-retired.md diff --git a/.changeset/8344-node-recursion-point-redirect.md b/.changeset/8344-node-recursion-point-redirect.md index 2eb9c6aa56..765ba7028d 100644 --- a/.changeset/8344-node-recursion-point-redirect.md +++ b/.changeset/8344-node-recursion-point-redirect.md @@ -62,6 +62,16 @@ none. The published `ChatbotSchema` is untouched — whether its own `body` shou chat API's params is a separate question, recorded on objectui#8572 and deliberately not decided here. +⚠️ **AMENDED (objectui#8572, ruling A — landing in THIS release).** Two statements in the +paragraph above were true of this card's head and are no longer true of the release they +ship in, so they are corrected here rather than left to contradict the entry beside them: +`ChatbotSchema.body` is no longer the record — it is an ADR-0049 retirement tombstone on +both published faces, pointing the author at `requestBody` — and the root document above is +now REFUSED, not accepted. The separate question this paragraph deferred has been answered, +and the deferral itself is the part that stayed true. ⛔ Nothing else in the paragraph moved: +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. + **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/8572-chatbot-body-retired.md b/.changeset/8572-chatbot-body-retired.md new file mode 100644 index 0000000000..4cd7b99042 --- /dev/null +++ b/.changeset/8572-chatbot-body-retired.md @@ -0,0 +1,117 @@ +--- +'@object-ui/types': minor +--- + +**BREAKING** — `ChatbotSchema` no longer accepts `body`, on either published face +(objectui#8572). The chat API's body params are authored as `requestBody`, which is what +the renderer has always read. + +**FROM** `body?: Record` on the zod mirror ("Additional API body params") +**TO** a refusal by name. On the TypeScript face the key was never the record: it arrived +from `BaseSchema` as the content slot, and it is now `?: never` there, so `tsc` says the +same thing the mirror says. + +```ts +// before +{ type: 'chatbot', messages: [], body: { model: 'gpt-4', temperature: 0.2 } } +// after +{ type: 'chatbot', messages: [], requestBody: { model: 'gpt-4', temperature: 0.2 } } +``` + +Executes the maintainer ruling recorded on objectui#8572 (decision batch #137, item 4, +2026-09-15, verbatim 「同意」), letter **A**. **B** — keep the record and write it into the +contract — was refused; **C** — narrow `body` back to the node slot — was refused because +`chatbot` reads NEITHER content channel, so a node-slot `body` would be legal and inert. +The governing precedent is objectui#9256 / ADR-0049, the `children` tombstone on this same +node, whose reason sentence already named `body` as equally unread. + +**Disposition: an ADR-0087 D2 tombstone, not a deletion.** The key stays declared and +unwritable — `?: never` on the interface, `retirementTombstone()` on the zod mirror — so an +author gets `invalid_type` at `body` carrying the prescription, not a bare +`unrecognized_keys` and not silence. Deleting the arm would have been the silent route: +`BaseSchema` carries `[key: string]: any` and its mirror ends `.passthrough()`, so a dropped +member is KEPT rather than refused, which trades one no-op for another. + +**Why this key and not another.** It was the ONE place in this vocabulary where `body` did +not mean "what goes inside this component": `zod-mirror-parity.test.ts` carried the pair +under `KnownDrift` as "two different meanings of one key", and the same collision was the +whole reason `chatbot` was the single arm of the component union whose output was not +assignable to `SchemaNode`. Both ledger rows move with this change, and the two pins that +recorded the old state are INVERTED rather than deleted (see below). + +**Delivery surface, measured — this is where the refusal does and does not arrive.** The +refusal is PARSE-TIME on the mirror and COMPILE-TIME on the declaration. Run against the +BUILT artifacts (`@object-ui/cli` and its dependency closure built from this branch, so the +`dist` bytes a consumer installs): + +``` +objectui validate + exit 1 · Path: body · Code: invalid_type · message names `requestBody` <- SUBJECT +objectui validate + exit 0 · "Schema is valid!" <- LIT CONTROL +objectui validate + exit 1 · refused through the node union <- DEPTH +``` + +⚠️ **A consumer path DOES skip the root parse, and it is the one authors hit most.** The +runtime render path never parses through these mirrors: `SchemaRenderer` validates in dev +builds only, through `@object-ui/core`'s hand-written `validateSchema`, which knows base +keys and recursion and no per-component key at all. Measured on the same document, rendered: + +``` +chatbot + body: { model: 'gpt-4' } console.warn "schema.children.type: type is required" +chatbot + requestBody (no warning) <- LIT CONTROL +chatbot, neither key (no warning) <- LIT CONTROL +card + body: [{ label: 'x' }] console.warn "schema.children[0].type: type is required" + <- FIRING CONTROL +``` + +That warning is NOT this retirement arriving. It is the generic child walker mistaking the +API params for a child node — it names `children` for a key the author spelled `body`, and +it prescribes adding a `type` to the params. It says the same thing before and after this +change, and in a production build it is not emitted at all. The VS Code extension's +validator is the same shape: its own walker, no mirror. ⇒ the retirement is delivered where +documents are AUTHORED and CHECKED (`tsc`, `objectui validate`, `objectui check`, and any +consumer that calls `safeValidateSchema`), and ⛔ not where they are RENDERED. Making the +render path agree is a different change on a different package and is not made here. + +**Nothing that rendered stops rendering.** No renderer read consumes `body` for this node: +the `chatbot` registration spells the chat runtime's `body` option off `requestBody`, and +`SchemaRenderer` strips both content keys out of the props bag it spreads. An authored +`body` drew nothing before this change and draws nothing after it — it is refused first +instead of dropped silently, on the two faces that refuse anything. + +**Migration, in this repository: no authored document changed.** Every tracked file carrying +a `chatbot` node is in `examples/schema-catalog/src/schemas/plugin-chatbot/`, and parsing all +three and reading their key sets structurally (rather than grepping a name that also appears +in comments) finds `body` in none of them and `requestBody` in none of them, against +`messages` present in all three as the control that the reader can see keys at all. + +⚠️ This repository's census cannot see a consumer outside it that authored the key. Such a +consumer gets `invalid_type` at `body` with the replacement spelled in the message, or a +compile error naming the member — which is why the FROM/TO is written out above. + +`minor` rather than `major` for the reason this repo's version policy gives: `major` is +forbidden in any changeset here (41 packages in one `fixed` group), and `minor` plus an +explicit breaking note is the spelling for a breaking change. + +**Pins moved, and how.** `node-recursion-point-8344.test.ts` keeps both directions and +inverts the two the ruling names: the root case now asserts the refusal (with the issue path, +the code and the named replacement) instead of acceptance, and the +`ArmsNotAssignableToSchemaNode` type pin now reads `never` instead of `'chatbot'`. ⚠️ That +type alias needed an empty-set guard, measured rather than assumed: its bare projection +`Exclude< … > extends { type: infer K } ? K : never` resolves to `unknown`, not `never`, when +the exclusion set is empty, so the naive inversion could only ever be red. +`zod-mirror-parity.test.ts` loses `body` from `ChatbotSchema`'s `KnownDrift` row (the entry +survives on its two runtime slots) and loses the whole `WiderThanDeclared` entry, with the +header figures re-derived from the ledgers by that file's own pins. + +**Two pending changesets in this release were amended, prose only.** +`8344-node-recursion-point-redirect.md` said the root document was "accepted at the root +before and after" and that "the published `ChatbotSchema` is untouched"; both halves sat in +one paragraph and both are falsified by this entry, so that paragraph now carries an AMENDED +note naming this card. `9256-content-channel-family-d.md` recorded this key as "a naming +collision awaiting a ruling" and now records that the ruling landed for one of the three +chatbot faces. A third — `7655-chatbot-registration-authoring-faces.md` — says the twins +"do not copy `ChatbotSchema`'s `body` naming collision"; that sentence's claim about the +TWINS is still true and is left alone, and the collision it names is the one retired here. diff --git a/.changeset/9256-content-channel-family-d.md b/.changeset/9256-content-channel-family-d.md index d678832ded..82e74f8144 100644 --- a/.changeset/9256-content-channel-family-d.md +++ b/.changeset/9256-content-channel-family-d.md @@ -32,6 +32,13 @@ shorthand face, whose registrations are `any`-typed), `AppComponentSchema` and `body` key of the three chatbot faces (the parity ledger records it as a naming collision awaiting a ruling — their `children` is narrowed). +⚠️ **AMENDED (objectui#8572, ruling A — landing in THIS release).** That ruling has since +been made, so the hold-out above has ended for ONE of the three faces: `ChatbotSchema`'s own +`body` is retired on both published faces and points the author at `requestBody`. The two +twins are unchanged and go on inheriting `body` as the content slot, and the reason this card +held the key out — it was a naming collision to rule on, not a family-D read-site verdict — +is the part that stayed true. + Four published documents in the schema catalog and three component reference pages were authoring `children` on `dialog`, `drawer`, `popover` and `collapsible`, all of which read `content`. They rendered empty boxes and are corrected here. diff --git a/packages/types/src/__tests__/node-recursion-point-8344.test.ts b/packages/types/src/__tests__/node-recursion-point-8344.test.ts index fd5021e0e9..9d24b50311 100644 --- a/packages/types/src/__tests__/node-recursion-point-8344.test.ts +++ b/packages/types/src/__tests__/node-recursion-point-8344.test.ts @@ -160,9 +160,13 @@ describe('the late-binding wiring, read by IDENTITY on the exported wrapper', () // `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 the - // `superRefine` WRAPPER that keeps the `chatbot` arm from widening the node slot, and a - // pin on the bare union would go green the moment that narrowing was dropped. + // ⛔ 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. 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]; @@ -180,40 +184,63 @@ describe('the late-binding wiring, read by IDENTITY on the exported wrapper', () * The EXACT bound on the one assertion `base.zod.ts` needs to make. * * `SchemaNodeSchema` keeps its objectui#7760 annotation `z.ZodType< SchemaNode, - * SchemaNode >`, and `z.output< typeof AnyComponentSchema >` is not assignable to - * `SchemaNode` for exactly ONE of its 106 arms: `complex.zod.ts#ChatbotSchema` - * mirrors the chat API body params under the key `body`, which is `BaseSchema`'s - * CHILDREN slot. That collision is pre-existing (the parity ledger carries it under - * `KnownDrift`, the TS declaration renamed the key to `requestBody`, and - * `ChatbotSharedMirrorShape` says a ruling on `ChatbotSchema`'s own `body` arm is a - * separate question), and objectui#8344 does not decide it. + * SchemaNode >`, and this reads back which arms of `AnyComponentSchema` are NOT + * assignable to it. * - * ⇒ the fill site takes a loose bound and this states the real one instead. A SECOND - * arm drifting the same way turns this red — where a wide bound would have said - * nothing. ⛔ Do not repair a red here by adding the new name to the union below: - * that records a second declaration defect as if it were a contract. + * ⭐ INVERTED by ruling A on objectui#8572, ⛔ not deleted. It read `'chatbot'` for as long + * as `complex.zod.ts#ChatbotSchema` mirrored the chat API body params under the key `body`, + * which is `BaseSchema`'s CHILDREN slot — that collision WAS the whole exclusion, the parity + * ledger carried it under `KnownDrift`, and objectui#8344 was not allowed to decide it. The + * ruling retires the record arm on both faces, so every arm's output is assignable and the + * honest bound is `never`. + * + * ⇒ the fill site takes a loose bound and this states the real one instead. A SECOND arm + * drifting the same way turns this red — where a wide bound would have said nothing. + * ⛔ Do not repair such a red by adding the new name to the union below: that records a + * declaration defect as if it were a contract. ⛔ And do not delete this pin now that it + * reads `never`: an empty exclusion set nothing asserts is indistinguishable from an + * exclusion set nobody has looked at since. + * + * ⚠️ The `[…] extends [never]` guard is not decoration, and ⛔ it may not be simplified + * away. The bare projection `Exclude< … > extends { type: infer K } ? K : never` does NOT + * resolve to `never` on an EMPTY exclusion set: `never` is assignable to the probe shape, + * so the true branch is taken and `K` is inferred from nothing — measured on this branch, + * the bare spelling resolves to `unknown`. Written that way the pin can only ever be red, + * which reads as drift where there is none. The guard answers the empty case first and + * leaves the projection to do exactly what it did before: NAME the arm when there is one. */ type ArmsNotAssignableToSchemaNode = - Exclude< z.output< typeof AnyComponentSchema >, SchemaNode > extends { type: infer K } ? K : never; + [Exclude< z.output< typeof AnyComponentSchema >, SchemaNode >] extends [never] + ? never + : Exclude< z.output< typeof AnyComponentSchema >, SchemaNode > extends { type: infer K } ? K : never; export type NodeRecursionPointDeclarationDrift = [ - Expect< Equal< ArmsNotAssignableToSchemaNode, 'chatbot' > >, + Expect< Equal< ArmsNotAssignableToSchemaNode, never > >, ]; /** - * The one arm the redirect would have WIDENED, narrowed on the arm itself. + * The one arm the redirect would have WIDENED — retired at the source by objectui#8572. + * + * `ChatbotSchema.body` mirrored the chat API's body params as a record: the one + * redeclaration across the union's arms that was WIDER than the base key it restated. + * objectui#8344 was not allowed to move the published mirror, so it carried the narrowing on + * the INSTALLED arm (`defineNodeComponentUnion`'s `superRefine` in `../zod/base.zod.ts`) and + * this suite pinned the asymmetry that left behind: refused one slot down, still accepted at + * the root. * - * `ChatbotSchema.body` mirrors the chat API's body params as a record — the only wider - * redeclaration among the 109 base-key redeclarations across the union's arms. Without the - * `superRefine` on the installed arm the redirect would narrow at 108 slots and widen at - * this one, which is what the card's appetite forbids in as many words. + * ⭐ Maintainer ruling A on objectui#8572 (decision batch #137, item 4, 2026-09-15) retired + * that arm — `retirementTombstone` on the mirror, `?: never` on the declaration — so the ROOT + * leg below is INVERTED, ⛔ not deleted: it is the only assertion in this file that reads the + * published face at the depth the retirement is about, and an inverted pin keeps the history + * of the key readable where a deleted one would leave the ROOT unwatched. * - * ⛔ Both directions are load-bearing, and a fix that only satisfies the first is the - * failure this pin exists to catch: narrowing the ROOT mirror would also refuse the nested - * node, and it would be a change to a published face this card does not own. + * ⛔ Both directions stay load-bearing, for a reason that OUTLIVED the asymmetry. The nested + * refusal is now produced by the ARM itself rather than by the wrapper clause, and the root + * refusal is the only one that can see the published mirror move; a repair that satisfies one + * and not the other is exactly the failure this suite exists to catch. */ -describe('objectui#8344 — the `chatbot` record `body` is refused NESTED and still accepted at the ROOT', () => { +describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT and one slot down alike', () => { const CHATBOT = { type: 'chatbot', messages: [{ id: '1', role: 'assistant', content: 'hi' }], @@ -225,8 +252,20 @@ describe('objectui#8344 — the `chatbot` record `body` is refused NESTED and st expect(AnyComponentSchema.safeParse({ type: 'div', children: [withRecordBody] }).success).toBe(false); }); - it('is still ACCEPTED at the ROOT — the published mirror is untouched', () => { - expect(AnyComponentSchema.safeParse(withRecordBody).success).toBe(true); + it('is REFUSED at the ROOT too — objectui#8572 retired the published record arm', () => { + // INVERTED by ruling A on objectui#8572. Until that ruling this leg read `toBe(true)`, + // and the `superRefine` clause on the installed arm was the only thing narrowing the + // nested form. ⛔ Do not read a failure here as "the wrapper leaked to the root": the + // refusal below comes from the ARM, and its path and code say so. + const result = AnyComponentSchema.safeParse(withRecordBody); + expect(result.success).toBe(false); + if (result.success) return; + const bodyIssue = result.error.issues.find((issue) => issue.path.join('.') === 'body'); + expect(bodyIssue?.code).toBe('invalid_type'); + // The REMEDY reaches the author by name — `retirementTombstone` writes one string into + // both the parse-time message and the published `.describe()`, so this asserts the named + // replacement key rather than the sentence carrying it. + expect(bodyIssue?.message).toContain('requestBody'); }); it('NON-VACUITY: the same node without `body` is accepted at both depths', () => { diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 791da3c213..8a1a9e87ed 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,15 @@ * 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. - * - **45 entries** in `KnownDrift`, **83 keys** across them — 41 / 72 until + * - **45 entries** in `KnownDrift`, **82 keys** across them — 45 / 83 until + * objectui#8572 RULED `complex.zod.ts#ChatbotSchema`'s `body`, the naming collision + * this ledger carried as "two different meanings of one key": the record arm is + * retired on both faces, the key measures clean and LEFT. ⚠️ One key moved and NO + * entry did — the entry survives on its two runtime slots — so this figure moved + * while the entry count above did not, and the objectui#7733 block stayed green + * while this one failed. That asymmetry is the shape a whole-entry departure does + * NOT have; read it before assuming both figures always travel together. + * It was 41 / 72 until * objectui#7804's `objectql.ts` slice DECLARED nine handler keys across FOUR * arms in that one mirror file, three of them NEW entries here * (`ObjectFormSchema`, `ObjectGridSchema`, `ObjectViewSchema`) plus a fourth @@ -340,8 +348,16 @@ * 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. - * - **20 entries** in `WiderThanDeclared`, **30 keys** across them, and **37 arms** - * under those keys — split **5** SCHEMA-NODE, **25** CONCRETE, **0** MIXED, **7** unions. + * - **19 entries** in `WiderThanDeclared`, **29 keys** across them, and **36 arms** + * under those keys — split **5** SCHEMA-NODE, **24** CONCRETE, **0** MIXED, **7** unions. + * It read 20 / 30 / 37 — 5 / 25 / 0 / 7 — until objectui#8572 RETIRED + * `complex.zod.ts#ChatbotSchema::body`, that entry's whole content, so the entry, its + * one key and its one arm left together. ⚠️ Compare the objectui#8338 move below: the + * same DEPARTURE shape, but that key carried TWO arms, so CONCRETE and `arms` fell by + * different amounts there and by the same amount here — which is why the arithmetic is + * ⛔ not steppable by hand in either direction, and why both moves are written out. + * ⭐ The key left because the mirror stopped being wider, not because it was reclassified: + * the retirement makes the mirror REFUSE what the declaration also refuses. * ⭐ objectui#8517 taught the operator to tell an OPEN record — `z.record(z.string(), V)` * — from a partial record over a finite key union, and NOT ONE figure on this line moved * with it. ⛔ Do not read that as the clause measuring nothing. It was built on two live @@ -1524,15 +1540,25 @@ interface KnownDrift { */ 'complex.zod.ts#CalendarViewSchema': 'onEventClick' | 'onViewChange'; /** - * `body` — TS declares `SchemaNode | SchemaNode[]` (a rendered slot); the mirror - * declares `Record` ("additional API body params"). Two different - * meanings of one key — a naming collision to rule on, not a widening. + * ⭐ `body` — RULED, and therefore GONE from this row (objectui#8572, maintainer + * ruling A, decision batch #137 item 4, 2026-09-15). It was ledgered here as two + * different meanings of one key: `SchemaNode | SchemaNode[]` on the declaration (the + * content slot) against `Record` on the mirror ("Additional API body + * params") — the naming collision this entry recorded as "to rule on, not a widening", + * and the thing that made `chatbot` the one arm whose output was not assignable to + * `SchemaNode`. The ruling does not pick one of the two meanings: it RETIRES the + * record arm — `retirementTombstone()` on the mirror, `?: never` on the declaration — + * and leaves the chat API's params where the renderer already read them, + * `requestBody`. ⇒ the two faces now say the same thing about the key, it measures + * clean, and the entry would be STALE if it kept naming it (the `a listed key that has + * been fixed` half of this ledger's own ratchet). ⛔ Do not re-add `body` to keep the + * history readable — the history is this paragraph, beside the keys that remain. * * `onError` / `onSend` — RUNTIME SLOT (objectui#6124): `plugin-chatbot` forwards both off * `schema.*` into `useObjectChat`, so the TS side keeps the callables; the mirror * refuses them by name (`handlerKeyRefusal`). See the class note above `ButtonSchema`. */ - 'complex.zod.ts#ChatbotSchema': 'body' | 'onError' | 'onSend'; + 'complex.zod.ts#ChatbotSchema': 'onError' | 'onSend'; /** * RUNTIME SLOT (objectui#6124) — pairs born ledgered by objectui#7655, which gave * the `chatbot-enhanced` and `chatbot-floating` registrations their own faces. @@ -2685,13 +2711,15 @@ interface WiderThanDeclared { * pair measures clean and this entry would be STALE if it stayed. */ 'app.zod.ts#AppComponentSchema': 'areas'; - /** - * CONCRETE, and DISJOINT rather than strictly wider — the pair also carries a - * `KnownDrift` entry for the same key, one of the measured cases where each face - * refuses something the other admits. The mirror restates `body` as an arbitrary - * record; the declaration inherits the base's schema-node-or-list. - */ - 'complex.zod.ts#ChatbotSchema': 'body'; + // `complex.zod.ts#ChatbotSchema` recorded `body` here (CONCRETE, and DISJOINT rather + // than strictly wider — the pair carried a `KnownDrift` entry for the same key, one of + // the measured cases where each face refuses something the other admits: the mirror + // restated `body` as an arbitrary record, the declaration inherited the base's + // schema-node-or-list). objectui#8572 ruled the collision — the record arm is retired on + // both faces and the chat API's params stay at `requestBody` — so the mirror accepts + // nothing the declaration refuses and the entry is GONE, per clause 4 of this ledger's + // "when it fires" note. The pair keeps its `KnownDrift` entry above, which records the + // ruling beside the two runtime slots that remain. /** * CONCRETE. `header` and `globalFilters` carry the inline-locale widening one level * down (a nested `label`) and `dateRange.defaultRange` is a bare string on the @@ -2916,7 +2944,6 @@ const WIDER_ARM_ROW_SEPARATOR = '::'; const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = { 'app.zod.ts#AppComponentSchema::areas': ['SCHEMA-NODE'], - 'complex.zod.ts#ChatbotSchema::body': ['CONCRETE'], 'complex.zod.ts#DashboardComponentSchema::header': ['CONCRETE'], 'complex.zod.ts#DashboardComponentSchema::globalFilters': ['CONCRETE'], 'complex.zod.ts#DashboardComponentSchema::dateRange': ['CONCRETE'], diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index b9e15f9208..fb91b20628 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -1440,6 +1440,53 @@ export interface ChatbotSchema extends BaseSchema { * read by `chatbot-floating` alone and forwarded to ``. */ floatingConfig?: FloatingChatbotConfig; + /** + * REFUSED BY NAME (objectui#8572, ADR-0049) — the chat API's body params are + * authored as `requestBody`, and `body` on this node means what it means on + * every other component: the content slot. Write `requestBody`. + * + * ⚠️ The two faces were not saying the same thing, and only one of them + * carried the record. HERE `body` has always arrived from {@link BaseSchema} + * as the node slot, with the params declared beside it as `requestBody` since + * that rename; the record-shaped arm — "Additional API body params" — lived + * on the ZOD twin alone, and that is the published face this retirement + * narrows. The `?: never` here is what makes `tsc` say the same thing at the + * authoring site. ⇒ the pair stops being two meanings of one key, the + * collision `__tests__/zod-mirror-parity.test.ts` carried under `KnownDrift` + * and the state maintainer ruling A on objectui#8572 (decision batch #137, + * item 4, 2026-09-15) ends. + * + * ⛔ It was NOT narrowed to the node slot — that reading was option C on the + * card and was refused by name: `chatbot` reads NEITHER content channel, so a + * node-slot `body` would be legal and inert, which is the silence the + * retirement exists to end. It is refused instead, the disposition the + * sibling `children` tombstone below already carries — and that tombstone's + * own measurement names `body` in the same sentence. + * + * What the renderer does instead: it spells the chat runtime's `body` option + * off `requestBody` (`body: schema.requestBody`), which is why a `body` + * authored here reached nothing — `SchemaRenderer` strips both content keys + * out of the props bag it spreads, and no read in `packages/plugin-chatbot` + * names `schema.body`. ⛔ That absence is not restated here as a count: the + * instruments that re-derive it are the read-site census in + * `__tests__/content-channel-family-d-9256.test.ts` and the arm pins in + * `__tests__/node-recursion-point-8344.test.ts`. + * + * ⚠️ DELIVERY SURFACE, stated because it bounds what this buys. The zod + * refusal is PARSE-TIME and this member is COMPILE-TIME, and the runtime + * render path is neither: `SchemaRenderer` validates in dev builds only, and + * through `@object-ui/core`'s hand-written `validateSchema`, which walks base + * keys and recurses into content — it never consults these mirrors, so it has + * no per-component key to refuse. ⇒ an authored `body` that meets neither + * `tsc` nor a root parse (`objectui validate` / `objectui check`, which call + * `safeValidateSchema`) is dropped exactly as silently after this change as + * before it. The refusal is delivered where documents are AUTHORED and + * CHECKED, not where they are rendered. + * + * @deprecated Not a channel `chatbot` reads — author the chat API's body + * params as `requestBody`. + */ + body?: never; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `chatbot` reads NEITHER content * channel: no renderer read consumes `body` or `children` for this node. diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 73bc7dc5cf..656fc015a8 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -682,7 +682,14 @@ export const ChatbotSchema = BaseSchema.extend({ model: z.string().optional().describe('AI model identifier'), streamingEnabled: z.boolean().optional().describe('Enable streaming responses'), headers: z.record(z.string(), z.string()).optional().describe('Additional API headers'), - body: z.record(z.string(), z.unknown()).optional().describe('Additional API body params'), + body: retirementTombstone( + 'REFUSED (objectui#8572, ADR-0049) — `body` is the CONTENT slot on every other component, and ' + + '`chatbot` reads NEITHER content channel; this arm restated it as the chat API body params, the one ' + + 'place in this vocabulary where the key carried two meanings. Author the chat API params as ' + + '`requestBody` instead — the key the registration forwards to the chat runtime as its `body` option, ' + + 'and the key the `chatbot-enhanced` and `chatbot-floating` twins already declare. Delete `body` here: ' + + 'nothing renders it, and nothing sends it.', + ), /** @deprecated objectui#5605 — inert; nothing reads it. Cap loops on the agent (`planning.maxIterations`). Slated for removal. */ maxToolRoundtrips: z.number().optional() .describe('DEPRECATED (inert, slated for removal) — Max tool-calling round-trips. Nothing reads this; cap tool loops on the agent via planning.maxIterations'), @@ -721,13 +728,19 @@ export const ChatbotSchema = BaseSchema.extend({ * Not exported: it is a census, not a mirror, and the parity census in * `__tests__/zod-mirror-parity.test.ts` registers `export const`s only. * - * `requestBody` is deliberately NOT in this pick. `ChatbotSchema` above mirrors - * the API body params under the key `body`, which collides with `BaseSchema`'s - * `body` children slot — the naming collision the parity ledger records under - * `KnownDrift`. The two twins below mirror the key the renderer actually reads, - * `requestBody`, and inherit `body` as the children slot, so they are born - * without the collision. Ruling on `ChatbotSchema`'s own `body` arm is a - * separate question and is not decided here. + * `requestBody` is deliberately NOT in this pick, and cannot be: the two twins + * below mirror the key the renderer actually reads, while `ChatbotSchema` above + * does not declare it at all — it rides through `.passthrough()` there, the + * state `__tests__/zod-mirror-parity.test.ts` records under + * `UnmirroredDeclared`. Picking a key this shape does not hold would pick + * nothing. + * + * ⚠️ This note used to end "ruling on `ChatbotSchema`'s own `body` arm is a + * separate question and is not decided here". It has been decided: ruling A on + * objectui#8572 (decision batch #137, item 4, 2026-09-15) retires that arm, and + * `body` is a `retirementTombstone` on `ChatbotSchema` above. ⇒ the naming + * collision this pick was built to sidestep no longer exists on any of the + * three chatbot faces — they now agree that `body` is not theirs to read. */ const ChatbotSharedMirrorShape = ChatbotSchema.pick({ messages: true, From 24b009d088b1c70d547512d19eddaa5d78dbd311 Mon Sep 17 00:00:00 2001 From: hotlong Date: Wed, 16 Sep 2026 16:48:14 +0000 Subject: [PATCH 2/2] test(types): re-point the hold-out controls this retirement ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#8572's retirement of `ChatbotSchema.body` reaches three assertions that encode the hold-out it ends, by entailment rather than by choice: two in `content-channel-family-d-9256.test.ts` and one in `strict-authoring-face-8345.test.ts`. The two family-D controls are RE-POINTED at a twin face, where the hold-out this card recorded still stands, rather than inverted — so this PR's verdict is not restated inside objectui#9256's file. The strict-face case is INVERTED, because dropping its root legs would leave a case that no longer reads the root at all; its comment records that the input no longer discriminates the clause. `zod/base.zod.ts`'s comment block is corrected, prose only: the clause is now redundant, and the block says why it nevertheless stays — the wrapper is load-bearing independently of what it checks, so removing it is surgery on objectui#8344's recursion point, not a deletion. The `requestBody` docs row carries the new mechanism (AGENTS.md #2). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VCpmqvacV4BypY48QdoxcE --- content/docs/plugins/plugin-chatbot.mdx | 2 +- .../content-channel-family-d-9256.test.ts | 50 +++++++++++-------- .../strict-authoring-face-8345.test.ts | 25 +++++++--- packages/types/src/zod/base.zod.ts | 33 ++++++++---- 4 files changed, 73 insertions(+), 37 deletions(-) diff --git a/content/docs/plugins/plugin-chatbot.mdx b/content/docs/plugins/plugin-chatbot.mdx index 48eeab9cb7..a3fd1625bb 100644 --- a/content/docs/plugins/plugin-chatbot.mdx +++ b/content/docs/plugins/plugin-chatbot.mdx @@ -189,7 +189,7 @@ to apply. They are documented in their own table after this one, under | `model` | string | - | AI model identifier (e.g., `gpt-4o`) | | `streamingEnabled` | boolean | `true` | Enable SSE streaming for AI responses | | `headers` | object | - | Additional headers for API requests | -| `requestBody` | object | - | Additional body parameters sent with each API request. Authored on the node as `requestBody`; the renderer forwards it to the chat runtime under its own `body` option. Writing `body` on the node instead sets the base schema's children container and never reaches the API | +| `requestBody` | object | - | Additional body parameters sent with each API request. Authored on the node as `requestBody`; the renderer forwards it to the chat runtime under its own `body` option. Writing `body` on a `chatbot` node instead is **refused by name** - that key is retired there (objectui#8572) and the refusal names `requestBody` as the replacement. The refusal is delivered when the document is parsed (`objectui validate` / `objectui check`) or type-checked, not while it renders | | `maxToolRoundtrips` | number | - | **Deprecated - has no effect.** Nothing reads this value, so it never capped anything. Cap tool-calling loops on the agent instead (`planning.maxIterations`). Still accepted so existing documents keep parsing; slated for removal in a future major | | `surface` | `'card' \| 'plain'` | `'card'` | **`chatbot-enhanced` only** (declared on `ChatbotEnhancedSchema`). Controls whether the chat renders as a bordered panel (`'card'`) or a frameless full-page workspace (`'plain'`). `chatbot` renders the plain chat component, which has no such chrome to switch, and does not read this key. `chatbot-floating` has no named read for it and `ChatbotFloatingSchema` does not declare it; its panel is a `ChatbotEnhanced`, and an authored value used to reach that panel through the registration's unfiltered props spread, an accidental channel objectui#7708 fenced - it is dark there now, so switch the node to `chatbot-enhanced` if you need the frameless chrome | | `processVisibility` | `'hidden' \| 'summary' \| 'debug'` | `'summary'` | **`chatbot-enhanced` only** (declared on `ChatbotEnhancedSchema`; `ChatbotSchema` still declares it too, though the `chatbot` registration has no read for it). Controls how much agent reasoning and tool detail is shown. `chatbot` renders the plain chat component, which has no agent-process display to configure at all - switch the node to `chatbot-enhanced` if you need one. `chatbot-floating` has no named read for it and `ChatbotFloatingSchema` does not declare it; an authored value used to reach its panel through the registration's unfiltered props spread, an accidental channel objectui#7708 fenced - it is dark there now, and there is no floating-side substitute to author | 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 c8f3b0d200..3056f73565 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 @@ -95,6 +95,13 @@ * records that key as "two different meanings of one key — a naming * collision to rule on". Their `children` channel is narrowed here, and the * LIVE CONTROL below proves `body` still parses there. + * ⚠️ AMENDED (objectui#8572): that ruling has since been made and it reaches + * ONE of the three faces — `ChatbotSchema`'s own `body`, whose disposition is + * that card's to state and ⛔ is not restated here. The hold-out this card + * recorded still stands on the two TWIN faces, so both controls below are + * RE-POINTED at a twin rather than inverted; what this card asserts — that it + * narrowed `children` on the chatbot faces and left their `body` alone — is + * unmoved. * * ## ⚠️ Half of this file is a COMPILE-TIME assertion and vitest CANNOT read it * @@ -197,7 +204,6 @@ import { FilterUISchema as FilterUIMirror, SortUISchema as SortUIMirror, } from '../zod/views.zod'; -import { ChatbotSchema as ChatbotBodyControl } from '../zod/complex.zod'; import { AnyComponentSchema } from '../zod/index.zod'; import type { CollapsibleSchema } from '../disclosure'; import type { DialogSchema } from '../overlay'; @@ -207,7 +213,7 @@ import type { KbdSchema, PivotTableSchema, ListSchema, TimelineSchema } from '.. import type { SkeletonSchema } from '../feedback'; import type { PaginationSchema } from '../navigation'; import type { ObjectGallerySchema } from '../objectql'; -import type { CarouselSchema, ChatbotSchema } from '../complex'; +import type { CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema } from '../complex'; import type { ReportViewerSchema } from '../reports'; import type { ViewSwitcherSchema } from '../views'; import type { NLQuerySchema } from '../ai'; @@ -367,14 +373,18 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel, expect(Object.keys(mirror.shape)).toContain(key); }); - it('LIVE CONTROL — `chatbot` still accepts `body`, the key held out of this card', () => { - // The parity ledger records `body` here as "two different meanings of one - // key — a naming collision to rule on": the mirror declares it as API - // request params, the declaration inherits the base's content channel. - // Refusing it would have been a guess, so it is untouched — and this line - // is what keeps the row above a reading about `children`. - const r = (ChatbotBodyControl as unknown as Mirror) - .safeParse({ type: 'chatbot', messages: [], body: { temperature: 0.2 } }); + it('LIVE CONTROL — the chatbot family still accepts `body`, the channel held out of this card', () => { + // The held-out channel, still held out — RE-POINTED, not inverted, by + // objectui#8572. This control was aimed at `ChatbotSchema`, whose own `body` + // the parity ledger recorded as "two different meanings of one key — a naming + // collision to rule on"; that ruling has since been made and belongs to its + // own card, ⛔ which is why this line does not restate its verdict here. The + // hold-out this card recorded still stands on the two TWIN faces: they + // inherit `body` as the content channel and only their `children` is + // narrowed above — so the control keeps doing its job, which is to keep the + // chatbot rows a reading about `children` rather than about the whole mirror. + const r = (ChatbotEnhancedMirror as unknown as Mirror) + .safeParse({ type: 'chatbot-enhanced', messages: [], body: CONTENT }); expect(r.success).toBe(true); }); @@ -561,15 +571,15 @@ describe('objectui#9256 — the TypeScript face refuses both channels at the AUT expect(ok).toHaveLength(7); }); - it('CONTROL — `chatbot` still TYPE-CHECKS with `body`, the held-out key', () => { - // ⚠️ The VALUE differs from the mirror control above, and that difference - // IS the ledgered collision: the TypeScript face inherits `body` from - // `BaseSchema` as a content channel (`SchemaNode | SchemaNode[]`), while - // the mirror declares it as `Record` of unknown, "additional API body - // params". One key, two meanings, on the two published faces of one node — - // which is why objectui#9256 refuses to tombstone it on a measurement and - // leaves it for a ruling. - const held = { type: 'chatbot', messages: [], body: CONTENT } satisfies ChatbotSchema; - expect(held.type).toBe('chatbot'); + it('CONTROL — the chatbot family still TYPE-CHECKS with `body`, the held-out channel', () => { + // RE-POINTED by objectui#8572, for the reason spelled out at the mirror + // control above, and ⛔ deliberately not restating that card's verdict here. + // What survives unchanged is this card's own claim: it narrowed `children` + // on the chatbot faces and left their `body` channel alone. On a TWIN that + // is still visible on both faces — `body` arrives from `BaseSchema` as the + // content channel and `tsc` admits it — and `tsc`, not vitest, is the reader + // of this line. + const held = { type: 'chatbot-enhanced', messages: [], body: CONTENT } satisfies ChatbotEnhancedSchema; + expect(held.type).toBe('chatbot-enhanced'); }); }); diff --git a/packages/types/src/__tests__/strict-authoring-face-8345.test.ts b/packages/types/src/__tests__/strict-authoring-face-8345.test.ts index 4f24ec9339..3a647aeda8 100644 --- a/packages/types/src/__tests__/strict-authoring-face-8345.test.ts +++ b/packages/types/src/__tests__/strict-authoring-face-8345.test.ts @@ -222,18 +222,29 @@ describe('checks survive the clone — a twin rebuilt with `z.object(shape)` wou expect(z.object({ a: z.number() }).safeParse({ a: -1 }).success).toBe(true); }); - it('the live instance: the `chatbot` body clause still fires at a child slot', () => { + it('the live instance: the `chatbot` body document is refused at a child slot', () => { // `defineNodeComponentUnion` installs a `superRefine` on the union that sits - // in the node slot, and only there. So this document is accepted at the root - // and refused one slot down — on BOTH faces. A twin that dropped the check - // would accept the nested form and this test would go red on the strict row. + // in the node slot, and only there. A twin that dropped the check would + // accept the nested form and this test would go red on the strict row. + // + // ⚠️ INVERTED by objectui#8572, which retired `ChatbotSchema.body` on both + // published faces: the two ROOT legs read `toBe(true)` until that ruling, and + // the asymmetry they recorded — accepted at the root, refused one slot down — + // is gone because the ARM itself now refuses the key at every depth. The legs + // are inverted rather than dropped because the root verdict is half of what + // this case reads, and a case that stopped reading the root would stop + // noticing if the two faces ever disagreed there. + // ⚠️ And the honest consequence, stated rather than left for a reader to + // discover: this input no longer DISCRIMINATES the clause, because the arm + // refuses it with or without the wrapper. The clause's own disposition is not + // this file's and not this ruling's; `../zod/base.zod.ts` carries it. const chatbot = { type: 'chatbot', messages: [], body: { foo: 'bar' } }; const nested = { type: 'card', children: [chatbot] }; - expect(StrictAnyComponentSchema.safeParse(chatbot).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(chatbot).success).toBe(false); expect(StrictAnyComponentSchema.safeParse(nested).success).toBe(false); - // …and unchanged on the tolerant face, which is (c) again on this input. - expect(AnyComponentSchema.safeParse(chatbot).success).toBe(true); + // …and the same on the tolerant face, which is (c) again on this input. + expect(AnyComponentSchema.safeParse(chatbot).success).toBe(false); expect(AnyComponentSchema.safeParse(nested).success).toBe(false); }); }); diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index 9b5b728e8f..b48dd0cc13 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -133,15 +133,30 @@ export const KeyedI18nLabelSchema = z.object({ export function defineNodeComponentUnion(union: T): T { // ⭐ objectui#8344 F2 — what goes into the slot is the union WRAPPED, never the bare union. // - // `ChatbotSchema.body` mirrors the chat API's body params as a record, which is WIDER than - // `BaseSchemaCore.body`. It is the only wider redeclaration among the 109 base-key - // redeclarations across the arms, so installing the bare union would narrow at 108 child - // slots and WIDEN at one: a `chatbot` node carrying a record `body` is refused at a child - // slot on `main` and would be accepted here. The card's appetite forbids widening in - // flight, so the arm carries the check and the PUBLISHED mirror is untouched — a root - // `chatbot` with a record `body` still parses, the same node one slot down does not. - // ⛔ Do not "simplify" this by narrowing `ChatbotSchema` itself: that is a change to a - // published face this card does not own, and it is recorded on objectui#8572. + // 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. + // + // ⚠️ 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;