diff --git a/.changeset/3965-mint-box-neutral-container.md b/.changeset/3965-mint-box-neutral-container.md
index 816615d608..0b75745d96 100644
--- a/.changeset/3965-mint-box-neutral-container.md
+++ b/.changeset/3965-mint-box-neutral-container.md
@@ -19,11 +19,12 @@ gap with a three-clause contract, pinned in
2. authored `className` passes through **verbatim**;
3. **zero** injected classes.
-Deliberately unlike `div`, `box` reads `children` only — never `schema.body`.
-The `div` renderer's `children || body` fallback is what made a mechanical
-`div`→X swap silently drop content on `body`-authoring nodes while the element
-count stayed unchanged; content moves into `children` at migration time (the
-objectui#6771 B-ruling direction).
+Deliberately unlike `div` AT THE TIME, `box` reads `children` only — never
+`schema.body`. The `div` renderer's `children || body` fallback is what made a
+mechanical `div`→X swap silently drop content on `body`-authoring nodes while the
+element count stayed unchanged. ⚠️ objectui#6771 has since executed that B-ruling and
+retired `body` across the protocol, so `div` reads `children` only too and `box` is no
+longer the exception this paragraph mints it as.
Landed on both contract faces per the zod-mirror-parity pairing (objectui#6424
family form): `BoxSchema` interface in `@object-ui/types`, its zod mirror in
diff --git a/.changeset/5632-sidebar-trigger-dom-passthrough.md b/.changeset/5632-sidebar-trigger-dom-passthrough.md
index 62eaee2504..8873b496b3 100644
--- a/.changeset/5632-sidebar-trigger-dom-passthrough.md
+++ b/.changeset/5632-sidebar-trigger-dom-passthrough.md
@@ -9,8 +9,9 @@ The renderer forwarded its whole prop bag to `SidebarTrigger`, which spreads its
own rest onto the `
);
diff --git a/packages/components/src/renderers/layout/aspect-ratio.tsx b/packages/components/src/renderers/layout/aspect-ratio.tsx
index 9eab52c38c..b2656b2ef3 100644
--- a/packages/components/src/renderers/layout/aspect-ratio.tsx
+++ b/packages/components/src/renderers/layout/aspect-ratio.tsx
@@ -30,7 +30,7 @@ ComponentRegistry.register('aspect-ratio',
{schema.image ? (
) : (
- renderChildren(schema.children || schema.body)
+ renderChildren(schema.children)
)}
);
@@ -39,7 +39,7 @@ ComponentRegistry.register('aspect-ratio',
namespace: 'ui',
label: 'Aspect Ratio',
// Same declaration, same reason as `semantic.tsx` (objectui#6764): the
- // renderer falls back to `renderChildren(schema.children || schema.body)`
+ // renderer falls back to `renderChildren(schema.children)`
// whenever no `image` is set, so a ratio box wrapping authored content is
// the documented shape -- and it drew `not-a-container` for it. Not in
// `PUBLIC_BLOCKS`, so the react-page scope builder never saw this tag and
diff --git a/packages/components/src/renderers/layout/box.tsx b/packages/components/src/renderers/layout/box.tsx
index 219b9b6359..f65dd5a937 100644
--- a/packages/components/src/renderers/layout/box.tsx
+++ b/packages/components/src/renderers/layout/box.tsx
@@ -30,12 +30,18 @@ import { forwardRef } from 'react';
* 2. the authored `className` passes through VERBATIM;
* 3. zero injected classes — no `cn()` merge, no default class, nothing.
*
- * Deliberately unlike `div`, this renderer reads `children` ONLY — never
- * `schema.body`. The `div` renderer's `children || body` fallback is exactly
- * what made a mechanical `div`→X swap unsafe (four catalog fixtures authored
- * `body` and would have lost their content silently, with an unchanged element
- * count); `box` keeps the one spelling so that hazard cannot re-grow. Content
- * on a migrated node moves into `children` — the #6771 B-ruling direction.
+ * This renderer reads `children` ONLY, and since objectui#6771 retired the
+ * `body` dialect every renderer outside the `page:*` namespace does too. The
+ * exceptions are FOUR, all in `layout/containers.tsx` — `page:card` and the
+ * three thin `page:section` / `page:footer` / `page:sidebar` containers — and
+ * all four are back-compat reads for STORED documents, which `@objectstack/spec`
+ * says in as many words on `PageContainerProps`. So `box` is no longer the
+ * exception it was written as, and the surviving reads are not an authoring
+ * face at all (escalation: objectui#9916). The hazard it was minted against was `div`'s
+ * `children || body` fallback, which made a mechanical `div`→X swap unsafe
+ * (four catalog fixtures authored `body` and would have lost their content
+ * silently, with an unchanged element count). That fallback is retired; the
+ * one spelling is now the protocol's, not just this renderer's.
*/
// Index signature on the parameter annotation, not on the `forwardRef` type
// argument — mechanism note on `action:bar` (objectui#4422), pinned by
diff --git a/packages/components/src/renderers/layout/card.tsx b/packages/components/src/renderers/layout/card.tsx
index 598c600b03..6f9696e041 100644
--- a/packages/components/src/renderers/layout/card.tsx
+++ b/packages/components/src/renderers/layout/card.tsx
@@ -69,7 +69,7 @@ const CardRenderer = forwardRef (
+ {renderNodeSlot(schema.children, (body) => (
{renderChildren(body)}
))}
{renderNodeSlot(schema.footer, (footer) => (
diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx
index c17512562c..04e33fc292 100644
--- a/packages/components/src/renderers/layout/containers.tsx
+++ b/packages/components/src/renderers/layout/containers.tsx
@@ -438,6 +438,17 @@ const collectRelatedLists = (nodes: any, acc: any[] = []): any[] => {
acc.push(n);
continue; // Don't descend into a related_list's own subtree.
}
+ // ⚠️ `body` STAYS, and an earlier pass of this change wrongly removed it.
+ // This walker does not RENDER anything — it descends a tab's subtree to
+ // count `record:related_list` nodes for a badge. `page:card` still reads
+ // `body` for stored documents, so a stored subtree under that key is still
+ // drawn; dropping it here made the renderer draw content this count could
+ // not see, which is a wrong badge rather than a retirement. The rule for
+ // every NON-RENDERING reader in this tree: while any renderer still reaches
+ // stored `body` content, the readers that must see the same content keep
+ // their arm (objectui#6771, escalation objectui#9916).
+ // `items` stays for its own reason — the `list` registration's item channel,
+ // objectui#9590's card, not this spelling.
const candidates = [
n.children,
n.properties?.children,
@@ -479,6 +490,7 @@ const containsAttachmentsNode = (nodes: any): boolean => {
for (const n of list) {
if (!n || typeof n !== 'object') continue;
if (n.type === 'record:attachments') return true;
+ // `body` stays — same ground as `collectRelatedLists` above.
const candidates = [n.children, n.properties?.children, n.properties?.items, n.body, n.items];
for (const c of candidates) {
if (c && containsAttachmentsNode(c)) return true;
@@ -932,6 +944,19 @@ const PageCardRenderer: React.FC = ({ schema, className, ...props }) => {
// carrying both, which the conversion is what resolves; deleting the read
// before the conversion is live would blank an existing card's content
// silently — the `page-header-subtitle-alias` sequencing precedent, verbatim.
+ // ⚠️ ONE OF FOUR SURVIVING FALLBACKS, all in this file: this one and the
+ // three thin `page:section` / `page:footer` / `page:sidebar` containers
+ // below. ⛔ Do not delete any of them on the authority of this comment —
+ // objectui#6771 retired the spelling on every RENDERER outside the `page:*`
+ // namespace, and what holds these four is stored documents, not the
+ // authoring face.
+ //
+ // What separates THIS one from the three below is a CONVERSION, not a
+ // ground: `@objectstack/spec`'s conversions registry carries
+ // `page-card-body-to-children` (`toMajor: 17`, surface
+ // `page.component.page:card.body`) and carries nothing for the other three.
+ // ⇒ a stored row under this key has a migration path; a stored row under
+ // theirs has none. Escalated as objectui#9916.
const body = schema?.body ?? schema?.children;
const footer = schema?.footer;
@@ -1105,6 +1130,40 @@ const PageSectionRenderer: React.FC = ({ schema, className, ...props }) =>
className={cn('space-y-4', className)}
{...designer}
>
+ {/*
+ ⚠️ `body` KEPT on all three thin containers, and the ground is the
+ SPEC'S OWN — ⛔ not the "they never published it" argument an earlier
+ pass of this comment made, which was false in both directions.
+
+ `@objectstack/spec`'s `PageContainerProps` names these three and says
+ it outright: `children` is the canonical spelling and `body` is
+ deliberately not declared, but 「The renderers keep reading `body` as a
+ back-compat fallback for stored documents; that fallback is objectui's
+ to retire on its own schedule, and it is not a second authorable
+ spelling.」 ⇒ they share `page:card`'s ground exactly. What they lack is
+ a CONVERSION: the spec's registry carries `page-card-body-to-children`
+ and NOTHING for these three, so dropping this arm leaves a stored row
+ with no migration path at all.
+
+ The authoring corpus IS answerable, and was answered by the committed
+ instrument rather than an ad-hoc scan:
+ pnpm census:body-dialect --keys page:section,page:footer,page:sidebar,page:card,card
+ CONTROL (fires): `card` + `body` 40 on the pre-retirement tree -> 2 on
+ this one. SUBJECT: `page:section` resolves 6 nodes on both trees, 0
+ with `body`, 4 with `children` — a LIT zero. ⚠️ `page:footer` and
+ `page:sidebar` resolve 0 nodes at all, so their zero is a key-population
+ zero and says nothing.
+
+ ⇒ nothing in this repository authors it; whether a stored ROW does is a
+ database question this tree cannot ask. Escalated as objectui#9916, and
+ the two facts that belong to it are POSTED there (comment 5733844778),
+ not merely asserted to be: the designer canvas already honours `children`
+ ONLY for `page:section` (`PageBlockCanvas.tsx`), so runtime and canvas
+ already disagree about a stored `body` here; and `page:card`'s "until the
+ conversion lands" precondition is STALE — `pageCardBodyToChildren` carries
+ `toMajor: 17` and `retiredFromLoadPath: true` and this repo installs spec
+ 17.4.0, so the live ground there is unreplayed stored rows.
+ */}
{renderChildren(schema?.children || schema?.body)}
);
@@ -2211,6 +2270,7 @@ const PageFooterRenderer: React.FC = ({ schema, className, ...props }) => {
className={cn('flex items-center justify-between text-sm text-muted-foreground', className)}
{...designer}
>
+ {/* `body` kept — escalation recorded at `page:section` above (objectui#6771). */}
{renderChildren(schema?.children || schema?.body)}
>
@@ -2237,6 +2297,7 @@ const PageSidebarRenderer: React.FC = ({ schema, className, ...props }) =>
className={cn('flex flex-col gap-4 w-full md:w-80 shrink-0', className)}
{...designer}
>
+ {/* `body` kept — escalation recorded at `page:section` above (objectui#6771). */}
{renderChildren(schema?.children || schema?.body)}
);
diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx
index 48fb71622b..d19c242d95 100644
--- a/packages/components/src/renderers/layout/page.tsx
+++ b/packages/components/src/renderers/layout/page.tsx
@@ -8,8 +8,8 @@
* The Page renderer interprets PageSchema into structured layouts.
* It supports four page types (record, home, app, utility) and
* renders named regions (header, sidebar, main, footer, aside) with
- * configurable widths. When no regions are defined, it falls back to
- * body/children for backward compatibility.
+ * configurable widths. When no regions are defined, it falls back to the
+ * node's `children` list (objectui#6771 retired the `body` spelling).
*/
import React, { useMemo } from 'react';
@@ -123,9 +123,9 @@ function isTitledPageHeader(node: any): boolean {
/**
* Depth-bounded walk over the component shapes a page can nest.
*
- * Takes ONE node or a LIST of them, and judges both by the same rule. `body`
- * and `children` are declared `SchemaNode | SchemaNode[]` on `PageNodeSchema`
- * and on `BaseSchema`, and `FlatContent` below has always rendered the bare-node
+ * Takes ONE node or a LIST of them, and judges both by the same rule.
+ * `children` is declared `SchemaNode | SchemaNode[]` on `PageNodeSchema` and on
+ * `BaseSchema`, and `FlatContent` below has always rendered the bare-node
* form, so a single node is a first-class authored shape — at the top level and
* at every nested level the recursion re-enters (objectui#8923). This used to
* open with `if (!Array.isArray(nodes)) return false`, so every bare-node
@@ -148,8 +148,7 @@ function containsTitledPageHeader(nodes: unknown, depth = 0): boolean {
typeof n === 'object' &&
(isTitledPageHeader(n) ||
containsTitledPageHeader(n.components, depth + 1) ||
- containsTitledPageHeader(n.children, depth + 1) ||
- containsTitledPageHeader(n.body, depth + 1)),
+ containsTitledPageHeader(n.children, depth + 1)),
);
}
@@ -176,7 +175,6 @@ function pageHeaderOwnsTitle(schema: PageNodeSchema): boolean {
const regionNodes = (schema.regions ?? []).flatMap((r: any) => r?.components ?? []);
return (
containsTitledPageHeader(regionNodes) ||
- containsTitledPageHeader(schema.body) ||
containsTitledPageHeader(schema.children)
);
}
@@ -278,11 +276,11 @@ const RegionLayout: React.FC<{
};
// ---------------------------------------------------------------------------
-// FlatContent — legacy body/children fallback
+// FlatContent — the `children` fallback when a page declares no regions
// ---------------------------------------------------------------------------
const FlatContent: React.FC<{ schema: PageNodeSchema }> = ({ schema }) => {
- const content = schema.body || schema.children;
+ const content = schema.children;
const nodes: SchemaNode[] = Array.isArray(content)
? content
: content
@@ -701,7 +699,11 @@ const pageMeta: any = {
itemType: 'object',
},
{
- name: 'body',
+ // The flat content list `FlatContent` renders when a page declares no
+ // regions. Published as `body` until objectui#6771 retired that
+ // spelling; `pageMeta` backs five registrations, so this one line is
+ // the authoring face of `page` / `app` / `utility` / `home` / `record`.
+ name: 'children',
type: 'array',
itemType: 'component',
},
diff --git a/packages/components/src/renderers/layout/semantic.tsx b/packages/components/src/renderers/layout/semantic.tsx
index 61d3abfe0e..266b78053a 100644
--- a/packages/components/src/renderers/layout/semantic.tsx
+++ b/packages/components/src/renderers/layout/semantic.tsx
@@ -37,7 +37,7 @@ tags.forEach(tag => {
{...restProps}
{...{ 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, style }}
>
- {renderChildren(schema.children || schema.body)}
+ {renderChildren(schema.children)}
);
});
@@ -48,7 +48,7 @@ tags.forEach(tag => {
label: tag.charAt(0).toUpperCase() + tag.slice(1),
category: 'layout',
// Declared because the factory above RENDERS a child list
- // (`renderChildren(schema.children || schema.body)`), which is the whole
+ // (`renderChildren(schema.children)`), which is the whole
// question this flag answers -- `children` is a BASE property of every
// node in the JSON protocol (`BASE_PROPS` in `sdui-parser/src/validate.ts`),
// not a per-component authoring key, so declaring it widens no spec
diff --git a/packages/components/src/renderers/navigation/sidebar.tsx b/packages/components/src/renderers/navigation/sidebar.tsx
index bb0b018506..da22a7573f 100644
--- a/packages/components/src/renderers/navigation/sidebar.tsx
+++ b/packages/components/src/renderers/navigation/sidebar.tsx
@@ -40,7 +40,7 @@ import {
ComponentRegistry.register('sidebar-provider',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
),
{
namespace: 'ui',
@@ -56,7 +56,7 @@ ComponentRegistry.register('sidebar-provider',
ComponentRegistry.register('sidebar',
({ schema, ...props }: { schema: SidebarSchema; [key: string]: any }) => (
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
),
{
namespace: 'ui',
@@ -76,7 +76,7 @@ ComponentRegistry.register('sidebar',
ComponentRegistry.register('sidebar-header',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
),
{
namespace: 'ui',
@@ -86,7 +86,7 @@ ComponentRegistry.register('sidebar-header',
ComponentRegistry.register('sidebar-content',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
),
{
namespace: 'ui',
@@ -109,7 +109,7 @@ ComponentRegistry.register('sidebar-group',
{resolveInlineI18nLabel(schema.label, locale)}
)}
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
);
@@ -128,7 +128,7 @@ ComponentRegistry.register('sidebar-group',
ComponentRegistry.register('sidebar-menu',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
),
{
namespace: 'ui',
@@ -138,7 +138,7 @@ ComponentRegistry.register('sidebar-menu',
ComponentRegistry.register('sidebar-menu-item',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
),
{
namespace: 'ui',
@@ -161,7 +161,7 @@ ComponentRegistry.register('sidebar-menu-button',
{...toFormControlDomProps(buttonProps)}
style={style}
>
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
);
},
@@ -181,7 +181,7 @@ ComponentRegistry.register('sidebar-menu-button',
ComponentRegistry.register('sidebar-footer',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
),
{
namespace: 'ui',
@@ -191,7 +191,7 @@ ComponentRegistry.register('sidebar-footer',
ComponentRegistry.register('sidebar-inset',
({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => (
- {renderChildren(schema.body)}
+ {renderChildren(schema.children)}
),
{
namespace: 'ui',
diff --git a/packages/components/src/renderers/overlay/tooltip.tsx b/packages/components/src/renderers/overlay/tooltip.tsx
index 769c550284..79283a2b9b 100644
--- a/packages/components/src/renderers/overlay/tooltip.tsx
+++ b/packages/components/src/renderers/overlay/tooltip.tsx
@@ -26,7 +26,7 @@ ComponentRegistry.register('tooltip',
{renderTriggerSlot(TooltipTrigger, schema.trigger)}
- {(schema.content || renderChildren(schema.body)) as any}
+ {(schema.content || renderChildren(schema.children)) as any}
@@ -46,9 +46,13 @@ ComponentRegistry.register('tooltip',
name: 'content',
type: 'string',
},
- {
- name: 'body',
- type: 'slot' },
+ // The rich-content slot. This registration published `body` and was
+ // the one place on the whole authoring surface that ADVERTISED the
+ // dialect in its `inputs`; objectui#6771 retired it, so the slot is
+ // declared under the one spelling the protocol keeps. Same two-sided
+ // move as the `page:card` retirement pinned by
+ // `components/src/__tests__/page-container-authorable-keys.test.tsx`.
+ { name: 'children', type: 'slot' },
{ name: 'className', type: 'string' }
],
defaultProps: {
diff --git a/packages/core/README.md b/packages/core/README.md
index c16b161b07..245641e967 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -37,7 +37,7 @@ import type {
const mySchema: PageNodeSchema = {
type: 'page',
title: 'My Page',
- body: []
+ children: []
}
```
diff --git a/packages/core/src/validation/schema-validator.ts b/packages/core/src/validation/schema-validator.ts
index 380217743f..cf11d741b8 100644
--- a/packages/core/src/validation/schema-validator.ts
+++ b/packages/core/src/validation/schema-validator.ts
@@ -464,7 +464,11 @@ function validateChildren(
): SchemaNodeValidationError[] {
const errors: SchemaNodeValidationError[] = [];
- const children = schema.children || schema.body;
+ // One spelling. This walker resolved `children || body` for ANY node type,
+ // so it outlived every per-registration read of the dialect — objectui#6771
+ // retired it, and a recursive validator that still descended `body` would
+ // keep validating a child list no renderer puts on the page.
+ const children = schema.children;
if (children) {
if (Array.isArray(children)) {
children.forEach((child: unknown, index: number) => {
diff --git a/packages/runner/README.md b/packages/runner/README.md
index 2d24c15390..6767268c65 100644
--- a/packages/runner/README.md
+++ b/packages/runner/README.md
@@ -128,7 +128,7 @@ that do configure it are:
{
"type": "page",
"title": "Dashboard",
- "body": {
+ "children": {
"type": "grid",
"columns": 3,
"gap": 4,
@@ -136,7 +136,7 @@ that do configure it are:
{
"type": "card",
"title": "Total Users",
- "body": {
+ "children": {
"type": "statistic",
"value": 1234,
"trend": "up"
@@ -145,7 +145,7 @@ that do configure it are:
{
"type": "card",
"title": "Revenue",
- "body": {
+ "children": {
"type": "statistic",
"value": "$56,789",
"trend": "up"
@@ -154,7 +154,7 @@ that do configure it are:
{
"type": "card",
"title": "Orders",
- "body": {
+ "children": {
"type": "statistic",
"value": 432,
"trend": "down"
diff --git a/packages/sdui-parser/src/__tests__/body-dialect-6771.test.ts b/packages/sdui-parser/src/__tests__/body-dialect-6771.test.ts
new file mode 100644
index 0000000000..570537ddd1
--- /dev/null
+++ b/packages/sdui-parser/src/__tests__/body-dialect-6771.test.ts
@@ -0,0 +1,132 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * The tier's half of objectui#6771 — the retired `body` child-list dialect.
+ *
+ * ## Why this file exists at all
+ *
+ * The retirement's acceptance evidence was measured on the BUILT CLI, and that
+ * reading is about the ZOD tier: `objectui validate` parses through
+ * `@object-ui/types`' mirrors, where `body` is an `aliasKeyRefusal`. ⛔ It says
+ * NOTHING about `validateTree`, which is a different tier with a different
+ * vocabulary — it parses a manifest, never a zod schema, and it is the tier the
+ * card was FILED about: the original defect was that `validateTree` answered
+ * `unknown-prop` on `body`, the one spelling a dozen registrations resolved.
+ *
+ * ⇒ a change that moves what this tier says about `body` needs its own pin, or
+ * the card's own subject is the one surface with no coverage.
+ *
+ * ## The two verdicts, and why they are two
+ *
+ * The ruling's step 4 has two clauses: the tier teaches `children` only, and a
+ * `body` child list under a non-container draws the same `not-a-container` the
+ * `children` spelling draws. `./body-dialect.ts` answers both by REPLACING the
+ * prop walk's diagnostic rather than adding to it — two diagnostics for one
+ * mistake is the shape `checkMemberTypes` already refuses (objectui#8067), and
+ * the shape `checkKanbanQuickAdd` next door is written against.
+ *
+ * ## ⚠️ What each row must not be allowed to pass on
+ *
+ * Every assertion below names a CODE and asserts the message names `children`.
+ * The code alone is not enough: the pre-retirement tier already answered
+ * `unknown-prop` for `body`, so a row asserting only that code is green in BOTH
+ * worlds and measures nothing. What moved is that the answer now carries the
+ * remedy, which is the whole reason the replacement exists rather than a bare
+ * refusal. And the `children` control beside each row is what keeps "the tier
+ * refuses `body`" a reading rather than "this tier refuses everything".
+ */
+import { describe, expect, it } from 'vitest';
+import { RETIRED_CHILD_LIST_KEY, manifestFromConfigs, validateTree } from '../index.js';
+import type { Diagnostic, Manifest, SchemaElement } from '../types.js';
+
+/**
+ * Two blocks and nothing else: one that accepts children and one that does not.
+ * The containment verdict is keyed on `isContainer`, so those two are the whole
+ * population this rule can distinguish.
+ */
+const manifest: Manifest = manifestFromConfigs([
+ { type: 'box', namespace: 'ui', isContainer: true, inputs: [{ name: 'className', type: 'string' }] },
+ { type: 'badge', namespace: 'ui', isContainer: false, inputs: [{ name: 'label', type: 'string' }] },
+ // Declared so the CHILD below resolves: `validateTree` descends `children`,
+ // and an undeclared child draws `unknown-component`, which would make the
+ // silence controls below assert the wrong absence.
+ { type: 'text', namespace: 'ui', isContainer: false, inputs: [{ name: 'content', type: 'string' }] },
+ // Declares `body` as its OWN input — the carve-out this rule must respect.
+ { type: 'detail', namespace: 'plugin-detail', isContainer: false, inputs: [{ name: 'body', type: 'string' }] },
+] as unknown as Parameters[0]);
+
+const CHILD = { type: 'text', content: 'measured' };
+const diagnose = (node: unknown): Diagnostic[] =>
+ validateTree(node as SchemaElement, manifest).diagnostics;
+
+describe('objectui#6771 — the tier answers the retired `body` with its replacement', () => {
+ it('the exported key IS the retired spelling, so the rule and its pin cannot drift', () => {
+ expect(RETIRED_CHILD_LIST_KEY).toBe('body');
+ });
+
+ it('a `body` child list under a NON-container draws `not-a-container` — the ruling clause, verbatim', () => {
+ const found = diagnose({ type: 'badge', body: [CHILD] });
+ expect(found.map((d) => d.code)).toContain('not-a-container');
+ // The SAME code the live spelling draws — that is what "the same
+ // `not-a-container` the `children` spelling draws" means, and a consumer
+ // keying on the code cannot tell the two spellings apart.
+ expect(diagnose({ type: 'badge', children: [CHILD] }).map((d) => d.code)).toContain('not-a-container');
+ // …and only the retired one also names the remedy.
+ expect(found.find((d) => d.code === 'not-a-container')!.message).toContain('"children"');
+ });
+
+ it('ONE diagnostic, not two — the replacement replaces', () => {
+ // ⛔ The failure this guards is the obvious implementation: leave the prop
+ // walk's `unknown-prop` in place and ADD a containment branch that reads
+ // `body`. That author gets two reports for one mistake.
+ const found = diagnose({ type: 'badge', body: [CHILD] });
+ expect(found.filter((d) => d.message.includes('"body"'))).toHaveLength(1);
+ });
+
+ it('a `body` child list under a CONTAINER draws `unknown-prop` naming `children`', () => {
+ const found = diagnose({ type: 'box', body: [CHILD] });
+ const own = found.filter((d) => d.message.includes('"body"'));
+ expect(own).toHaveLength(1);
+ expect(own[0]!.code).toBe('unknown-prop');
+ expect(own[0]!.message).toContain('"children"');
+ // CONTROL — the live spelling under the same block is silent, so the row
+ // above is about the key and not about the block.
+ expect(diagnose({ type: 'box', children: [CHILD] })).toHaveLength(0);
+ });
+
+ it('a SCALAR `body` is a dead key and nothing more — no containment sentence', () => {
+ // The containment wording names a shape; naming it for `body: "hi"` would
+ // describe a child list the author did not write.
+ const found = diagnose({ type: 'badge', body: 'hi' });
+ const own = found.filter((d) => d.message.includes('"body"'));
+ expect(own).toHaveLength(1);
+ expect(own[0]!.code).toBe('unknown-prop');
+ expect(own[0]!.message).toContain('"children"');
+ });
+
+ it('⛔ a component that DECLARES its own `body` input is untouched', () => {
+ // ⚠️ The fixture below is SYNTHETIC — a manifest is an argument to
+ // `validateTree`, so this file can ask what the rule says about any shape.
+ // The real component this carve-out exists for is `record:alert`, the one
+ // registration in the tree that declares an input named `body` (its value is
+ // an inline translation map, not a child list); the extension's own gate
+ // names it, and `sdui-parser` needs no list because it asks inside the
+ // `!input` branch. That scoping is the opposite of `checkKanbanQuickAdd`'s,
+ // deliberately: there the claim is about the render path, so declaring the
+ // key must not disarm it; here the claim is about a key nobody declares.
+ expect(diagnose({ type: 'detail', body: 'Updated the deal stage.' })).toHaveLength(0);
+ });
+
+ it('the tier still teaches `children` ONLY — `body` never became a base prop', () => {
+ // The option the ruling REFUSED was adding `body` to `BASE_PROPS`, which
+ // would have made it a blessed second spelling forever. If it were there,
+ // every row above would be silent.
+ expect(diagnose({ type: 'box', body: [CHILD] }).length).toBeGreaterThan(0);
+ });
+});
diff --git a/packages/sdui-parser/src/body-dialect.ts b/packages/sdui-parser/src/body-dialect.ts
new file mode 100644
index 0000000000..83745ca052
--- /dev/null
+++ b/packages/sdui-parser/src/body-dialect.ts
@@ -0,0 +1,99 @@
+/**
+ * ObjectUI — the retired `body` child-list dialect (objectui#6771)
+ *
+ * `body` was a declared twin of `children` on `BaseSchema`, and for a long
+ * while it was the ONLY child-list key a dozen registrations read. This tier
+ * never knew it: `BASE_PROPS` lists `children` and not `body`, so the one
+ * spelling that resolved drew `unknown-prop` — the same warning a typo draws,
+ * on the tier built to accept AI-authored pages, where the diagnostic IS the
+ * contract. The maintainer ruled the retirement (2026-09-01, option B): one
+ * concept, one spelling, and the spelling is `children`.
+ *
+ * This module is the tier's half of that ruling. It REPLACES the diagnostic the
+ * prop walk would otherwise emit for `body`, rather than adding to it — two
+ * diagnostics for one mistake is the shape `checkMemberTypes` already refuses
+ * (objectui#8067), and it is the shape `checkKanbanQuickAdd` is written against
+ * next door.
+ *
+ * WHY A REPLACEMENT AND NOT A BARE `unknown-prop`. After the retirement
+ * "`` has no prop body" is TRUE — but it is the answer that sent authors
+ * the wrong way in the first place. Every `body` node in this repository's own
+ * docs, examples and READMEs was migrated in the same change, and an author
+ * carrying the old spelling in from anywhere else needs the replacement NAMED,
+ * not merely the key refused. The two codes below are the ruling's own words:
+ * the tier teaches `children` only, and a `body` child list under a
+ * non-container draws the same `not-a-container` the `children` spelling draws.
+ *
+ * SCOPE — deliberately the opposite of `checkKanbanQuickAdd`'s. That one is
+ * asked AHEAD of the declaration lookup, because its claim is about the render
+ * path and declaring the key must not disarm it. This one is asked INSIDE the
+ * `!input` branch, because its claim is about a key nobody declares: a
+ * component that publishes its own `body` input keeps its declared type check
+ * untouched. Re-derived against the registry rather than recalled: exactly one
+ * registration in the tree declares an input named `body`, and it is
+ * `record:alert` (`plugin-detail`, `register('alert', …, { namespace: 'record' })`),
+ * whose `body` takes an inline translation map and is not this spelling.
+ * Control on the same sweep: `name: 'children'` fires seven times.
+ */
+
+import type { Diagnostic, SchemaNode } from './types.js';
+
+/** The child-list spelling objectui#6771 retired. `children` replaced it. */
+export const RETIRED_CHILD_LIST_KEY = 'body';
+
+const isPlainObject = (v: unknown): v is Record =>
+ typeof v === 'object' && v !== null && !Array.isArray(v);
+
+/**
+ * Is this value shaped like the child list the dialect used to carry?
+ *
+ * A list of nodes, or the bare single node `FlatContent` and `renderChildren`
+ * have always accepted. A scalar is not — `body="hi"` is a dead key and nothing
+ * more, so it does not get the containment sentence, which would name a shape
+ * its author did not write.
+ */
+const isChildList = (value: unknown): value is SchemaNode | SchemaNode[] =>
+ Array.isArray(value) || isPlainObject(value);
+
+/**
+ * The diagnostic for an authored `body`, or `null` when this module has nothing
+ * to say about the key.
+ *
+ * @param tag the node's component type, for the message and the `tag` field
+ * @param key the authored prop name
+ * @param value its authored value
+ * @param isContainer whether the manifest says this component accepts children
+ */
+export function checkRetiredBodyDialect(
+ tag: string,
+ key: string,
+ value: unknown,
+ isContainer: boolean | undefined,
+): Diagnostic | null {
+ if (key !== RETIRED_CHILD_LIST_KEY) return null;
+
+ if (isChildList(value) && !isContainer) {
+ // The ruling, verbatim: "a `body` child list under a non-container draws
+ // the same `not-a-container` the `children` spelling draws". Same code, so
+ // a consumer keying on the code cannot tell the two spellings apart — which
+ // is the single-spelling rule stated in the diagnostic stream. The sentence
+ // carries the extra fact the `children` path has no need of.
+ return {
+ severity: 'warning',
+ code: 'not-a-container',
+ message:
+ `<${tag}> does not accept children — and "body" is the retired spelling of ` +
+ `"children" (objectui#6771), so this child list has no door here at all`,
+ tag,
+ };
+ }
+
+ return {
+ severity: 'warning',
+ code: 'unknown-prop',
+ message:
+ `<${tag}> has no prop "body" — the child-list key is "children" ` +
+ `("body" was retired by objectui#6771)`,
+ tag,
+ };
+}
diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts
index 91b19dab8d..4a53bd618a 100644
--- a/packages/sdui-parser/src/index.ts
+++ b/packages/sdui-parser/src/index.ts
@@ -25,6 +25,7 @@ export {
export { generateDts, propsName, generateBlockList } from './codegen.js';
export type { CodegenOptions } from './codegen.js';
export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js';
+export { checkRetiredBodyDialect, RETIRED_CHILD_LIST_KEY } from './body-dialect.js';
import { parseJsx } from './parse.js';
import { validateTree } from './validate.js';
diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts
index c95691430c..c649d27315 100644
--- a/packages/sdui-parser/src/validate.ts
+++ b/packages/sdui-parser/src/validate.ts
@@ -19,8 +19,17 @@ import type {
import { inputTypeArms } from './input-type.js';
import { checkDashboardWidgetOptions } from './dashboard-widget-options.js';
import { checkKanbanQuickAdd } from './kanban-quick-add.js';
+import { checkRetiredBodyDialect } from './body-dialect.js';
-/** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */
+/**
+ * Base props every node may carry (mirrors BaseSchema) — never "unknown prop".
+ *
+ * ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second
+ * child-list spelling until objectui#6771 retired it; teaching this set the key
+ * was the option that ruling refused, because it would have blessed a second
+ * permanent spelling of one concept. `./body-dialect.ts` answers it by name
+ * instead.
+ */
const BASE_PROPS = new Set([
'type',
'id',
@@ -88,12 +97,20 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Ma
}
const input = byName.get(key);
if (!input) {
- diagnostics.push({
- severity: 'warning',
- code: 'unknown-prop',
- message: `<${node.type}> has no prop "${key}"`,
- tag: node.type,
- });
+ // The retired `body` child-list dialect gets its replacement named
+ // rather than the bare "has no prop" every typo gets
+ // (objectui#6771). Asked INSIDE this branch, so a component that
+ // declares its own `body` input keeps its declared type check —
+ // mechanism in `./body-dialect.ts`.
+ const retiredBody = checkRetiredBodyDialect(node.type, key, value, comp.isContainer);
+ diagnostics.push(
+ retiredBody ?? {
+ severity: 'warning',
+ code: 'unknown-prop',
+ message: `<${node.type}> has no prop "${key}"`,
+ tag: node.type,
+ },
+ );
continue;
}
if (input.binding) {
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 c7d627cba6..6e47db0299 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
@@ -394,27 +394,39 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel,
// 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.
+ // own card, ⛔ which is why this line does not restate its verdict here.
+ //
+ // ⚠️ THE HOLD-OUT ENDED, and not by a decision on this card. It read: the
+ // two TWIN faces inherit `body` as the content channel, only their
+ // `children` is narrowed here. objectui#6771 retired `body` on `BaseSchema`
+ // itself, so there is no content channel left to inherit — and an inherited
+ // refusal would have named `children` as the remedy, which is refused on
+ // these two as well. Each twin therefore declares the same NEITHER-channel
+ // tombstone its `children` already carried, pointing at `requestBody`.
+ // ⛔ This is not a narrowing this card chose: the sentence was already
+ // asserted here, only the declaration was missing.
const r = (ChatbotEnhancedMirror as unknown as Mirror)
.safeParse({ type: 'chatbot-enhanced', messages: [], body: CONTENT });
- expect(r.success).toBe(true);
+ expect(r.success).toBe(false);
+ const bodyIssue = (r.success ? [] : r.error!.issues).find((i) => i.path.join('.') === 'body');
+ expect(bodyIssue?.message).toContain('requestBody');
+ // CONTROL — the key an author should write instead still parses.
+ expect((ChatbotEnhancedMirror as unknown as Mirror)
+ .safeParse({ type: 'chatbot-enhanced', messages: [], requestBody: { model: 'gpt-4' } }).success).toBe(true);
});
- it('LIVE CONTROL — `button` still accepts BOTH channels: it is family C, not family D', () => {
- // Re-derived at this branch point, and it contradicts the table this slice
- // was dispatched from: `ui:button` owns the bare `button` key, and that
- // renderer reads `schema.label || renderChildren(schema.body || schema.children)`
- // — a LIVE fallback. Narrowing it would REMOVE A LIVE READ, which is a
- // behaviour change and the maintainer's call (objectui#8284), so `button`
- // is untouched and this line is what keeps the rows above a reading about
- // the six that moved rather than about the whole mirror.
+ it('`button` is family C with ONE channel left — the `body` arm was retired, not narrowed away', () => {
+ // This line used to read `button` accepts BOTH channels, on the ground that
+ // `ui:button` renders `schema.label || renderChildren(schema.body || schema.children)`
+ // — a LIVE fallback whose removal was a behaviour change and the
+ // maintainer's call. That call was made: objectui#6771's ruling puts the
+ // `body` arm of every fallback reader in scope ("single-spelling applies to
+ // fallback readers too") and names this expression explicitly. `button` is
+ // still family C — it reads a content channel, unlike family D — and the
+ // channel it reads is `children`.
const mirror = ButtonFamilyCControl as unknown as Mirror;
- expect(issues(mirror, { type: 'button', body: CONTENT })).toBeNull();
expect(issues(mirror, { type: 'button', children: CONTENT })).toBeNull();
+ expect(issues(mirror, { type: 'button', body: CONTENT })).not.toBeNull();
});
it('LIVE CONTROL — the ITEM-level channel is untouched: a `tabs` item still parses `content`', () => {
@@ -441,13 +453,15 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel,
})).toBeNull();
});
- it('LIVE CONTROL — family C is NOT narrowed: a `div` still accepts both channels', () => {
- // `div` reads `children || body` (a live fallback). Removing a live read is
- // a behaviour change and is with the maintainer, so this line must stay
- // green for this card to be a declaration repair rather than a behaviour
- // change.
+ it('family C still reads a content channel — `div` takes `children`, and only `children`', () => {
+ // `div` read `children || body`, a live fallback, and this line used to pin
+ // BOTH as accepted so that this card stayed a declaration repair rather than
+ // a behaviour change. objectui#6771 made the behaviour change deliberately,
+ // under a maintainer ruling, and the distinction this control exists to
+ // guard survives it: family C reads a content channel and family D reads
+ // none. What changed is how many spellings family C answers to.
expect(AnyComponentSchema.safeParse({ type: 'div', children: CONTENT }).success).toBe(true);
- expect(AnyComponentSchema.safeParse({ type: 'div', body: CONTENT }).success).toBe(true);
+ expect(AnyComponentSchema.safeParse({ type: 'div', body: CONTENT }).success).toBe(false);
});
});
@@ -587,15 +601,27 @@ describe('objectui#9256 — the TypeScript face refuses both channels at the AUT
expect(ok).toHaveLength(7);
});
- it('CONTROL — the chatbot family still TYPE-CHECKS with `body`, the held-out channel', () => {
+ it('the chatbot family no longer TYPE-CHECKS with `body` — the held-out channel was retired under it', () => {
// 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');
+ //
+ // ⚠️ INVERTED, and not by a decision on this card. Its claim was: objectui#9256
+ // narrowed `children` on the chatbot faces and left their `body` channel
+ // alone, `body` arriving from `BaseSchema` as the content channel. objectui#6771
+ // retired `body` on `BaseSchema` itself, so there is no content channel left
+ // to inherit and each twin now declares the same NEITHER-channel tombstone
+ // its `children` sentence already asserted. ⇒ the hold-out ended by the base
+ // moving, not by this card re-deciding anything. `tsc`, not vitest, is the
+ // reader of both lines below.
+ // @ts-expect-error objectui#6771 — `body` is retired; this family authors `requestBody`
+ const retired = { type: 'chatbot-enhanced', messages: [], body: CONTENT } satisfies ChatbotEnhancedSchema;
+ // CONTROL — the key an author should write instead still compiles, so the
+ // line above is a reading about `body` and not about the whole declaration.
+ const live = {
+ type: 'chatbot-enhanced',
+ messages: [],
+ requestBody: { model: 'gpt-4' },
+ } satisfies ChatbotEnhancedSchema;
+ expect([retired.type, live.type]).toEqual(['chatbot-enhanced', 'chatbot-enhanced']);
});
});
diff --git a/packages/types/src/__tests__/content-channel-per-component-8284.test.ts b/packages/types/src/__tests__/content-channel-per-component-8284.test.ts
index a38bfed22b..da736bba6c 100644
--- a/packages/types/src/__tests__/content-channel-per-component-8284.test.ts
+++ b/packages/types/src/__tests__/content-channel-per-component-8284.test.ts
@@ -31,10 +31,25 @@
* `packages/components/src/renderers/**` (114 registrations) and the read side
* is measured with the TypeScript TYPE CHECKER — each `body` / `children`
* property access is filed under the TYPE of the object it is read from, so a
- * docblock mention cannot score (the control: the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and `grep -l` counts
- * it, the checker does not; the card's own `17 / 17` figure came from that
- * query).
+ * docblock mention cannot score.
+ *
+ * ⚠️ THE CONTROL MOVED, because objectui#6771 SPENT the one that stood here.
+ * It read: the `BoxSchema` docblock in `renderers/layout/box.tsx` SAYS
+ * `schema.body` in prose and `grep -l` counts it, the checker does not — and
+ * the card's own `17 / 17` figure came from that query. This card rewrote that
+ * docblock, so `box.tsx` now answers 0 to BOTH halves and a reader replaying it
+ * gets no divergence at all. ⛔ A control that cannot fire is worse than none:
+ * it reads as evidence and is not.
+ *
+ * ⭐ The live control is `renderers/basic/span.tsx`, whose JSX comment says
+ * "This read used to be `schema.body`" and carries no such property access.
+ * Measured with the repo's own masker (`scripts/js-comment-mask.mjs`, the
+ * instrument the producer scan projects): the raw text answers 1 — what
+ * `grep -l` counts — and the comment-stripped text answers 0 — what the checker
+ * can see. ⇒ the divergence is live and replayable. The masker is controlled on
+ * the same run rather than trusted: `schema.bodyExtra` in
+ * `renderers/action/action-icon.tsx` is a REAL property access and survives
+ * stripping at 1, so the 0 above is a reading and not a blanked file.
*
* The twelve rows below are the ones where the renderer reads EXACTLY ONE
* channel, the component owns a dedicated declaration, and exactly one
@@ -106,9 +121,17 @@ const ROWS: ReadonlyArray ({ type: 'card', title: 'Parent', body: [child] });
+const nested = (child: unknown) => ({ type: 'card', title: 'Parent', children: [child] });
describe('objectui#7869 — the off-spec node gets the same verdict at both depths', () => {
it('is refused STANDING ALONE (unchanged — this half was never the defect)', () => {
@@ -142,8 +142,11 @@ describe('the late-binding wiring, read by IDENTITY on the exported wrapper', ()
});
it('that identity survives through a declared child slot', () => {
- const body = (CardSchema.shape.body as unknown as { _zod: { def: { innerType: { _zod: { def: { options: unknown[] } } } } } });
- expect(body._zod.def.innerType._zod.def.options).toContain(SchemaNodeSchema);
+ // Read off `children`: objectui#6771 retired `card`'s `body` spelling, so
+ // the declared child slot whose union must hold the recursion point is
+ // this one. `body` on this mirror is now an alias refusal with no union.
+ const slot = (CardSchema.shape.children as unknown as { _zod: { def: { innerType: { _zod: { def: { options: unknown[] } } } } } });
+ expect(slot._zod.def.innerType._zod.def.options).toContain(SchemaNodeSchema);
});
it('the holder is FILLED by importing the barrel — the module-cycle break works', () => {
@@ -282,17 +285,22 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a
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:
+ // This leg used to read `JSON.stringify(issues)` for the substring naming the
+ // parent's child slot and was VACUOUS: that slot is 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 the child's own `body`, all three satisfying the old assertion, none of
+ // them carrying an issue at that path:
//
- // nested off-spec `icon` (size: 'huge') → blob contains `"body"`: true, issues at
- // `body.0.body`: 0
+ // nested off-spec `icon` (size: 'huge') → blob contains the slot name: true,
+ // issues at `.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`
+ // nested `chatbot` with a record `body` → true / 1 ← the only one about `body`
+ //
+ // ⚠️ The parent slot is spelled `children` since objectui#6771 retired `body`;
+ // the child's own refused key is still `body`, so the two halves of the path
+ // no longer read alike and the reading is easier to see, not different.
//
// ⇒ 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
@@ -312,7 +320,7 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a
walk(issues, []);
return out;
};
- const named = at(result.error.issues, 'body.0.body');
+ const named = at(result.error.issues, 'children.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.
@@ -326,7 +334,7 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a
['`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.
+ // has nothing to do with `body`, so the leg above must find nothing at `children.0.body`.
// ⛔ 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));
@@ -341,9 +349,11 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a
for (const bucket of issue.errors ?? []) walk(bucket, here);
};
walk(result.error.issues, []);
- expect(paths).not.toContain('body.0.body');
+ expect(paths).not.toContain('children.0.body');
// and the old assertion holds anyway — which is the whole reason it was replaced.
- expect(JSON.stringify(result.error.issues)).toContain('"body"');
+ // (It reads the PARENT's slot name, which is what made it vacuous; that name is
+ // `children` since objectui#6771, and the vacuity is unchanged.)
+ expect(JSON.stringify(result.error.issues)).toContain('"children"');
});
});
@@ -365,7 +375,7 @@ describe('objectui#8344 + objectui#8498 — a refusal at depth 4 stays bounded a
const deep = (levels: number): unknown =>
levels === 0
? { type: 'badge', variant: 'not-a-variant' }
- : { type: 'card', title: 'p', body: [deep(levels - 1)] };
+ : { type: 'card', title: 'p', children: [deep(levels - 1)] };
it('refuses at every depth 0 through 4 without throwing', () => {
for (const depth of [0, 1, 2, 3, 4]) {
@@ -387,7 +397,7 @@ describe('objectui#8344 + objectui#8498 — a refusal at depth 4 stays bounded a
const legal = (levels: number): unknown =>
levels === 0
? { type: 'badge', variant: 'default' }
- : { type: 'card', title: 'p', body: [legal(levels - 1)] };
+ : { type: 'card', title: 'p', children: [legal(levels - 1)] };
expect(safeValidateSchema(legal(4)).success).toBe(true);
});
});
diff --git a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts
index 10179f5bfd..b8d4eb96c3 100644
--- a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts
+++ b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts
@@ -94,7 +94,8 @@ describe('objectui#7926 — the `page` node refuses `actions` (contract half)',
// sentence: pinning prose byte-for-byte turns every wording fix red for no
// gain (AGENTS.md — assert the named subject, not the copy).
expect(message).toContain('actions');
- expect(message).toContain('body');
+ // The remedy key, which objectui#6771 moved from `body` to `children`.
+ expect(message).toContain('children');
expect(message).toContain('page:header');
// Zod's own default for a `never` arm says none of this.
expect(message).not.toBe('Invalid input: expected never, received array');
@@ -108,11 +109,11 @@ describe('objectui#7926 — the `page` node refuses `actions` (contract half)',
expect(PageNodeSchema.safeParse(withoutActions).success).toBe(true);
});
- it('the remedy the message names actually parses — buttons as nodes in `body`', () => {
+ it('the remedy the message names actually parses — buttons as nodes in `children`', () => {
const r = PageNodeSchema.safeParse({
type: 'page',
title: 'Products',
- body: [
+ children: [
{
type: 'flex',
justify: 'end',
@@ -233,13 +234,13 @@ describe('objectui#7926 — the guide no longer authors `actions` on a `page` no
}
});
- it('every `page` node the guide teaches declares its content under `body`', () => {
+ it('every `page` node the guide teaches declares its content under `children`', () => {
// The positive form of the same fact: the passages were not merely stripped
// of `actions`, they were REWRITTEN onto the key that renders.
const pages = fences.filter((f) => isPageNode(f.doc));
for (const f of pages) {
const doc = f.doc as Record;
- expect({ line: f.line, hasBody: 'body' in doc }).toEqual({ line: f.line, hasBody: true });
+ expect({ line: f.line, hasContent: 'children' in doc }).toEqual({ line: f.line, hasContent: true });
}
});
});
diff --git a/packages/types/src/__tests__/page-body-arity-8310.test.ts b/packages/types/src/__tests__/page-body-arity-8310.test.ts
index 4851e5c904..1b9e13a445 100644
--- a/packages/types/src/__tests__/page-body-arity-8310.test.ts
+++ b/packages/types/src/__tests__/page-body-arity-8310.test.ts
@@ -7,14 +7,25 @@
*/
/**
- * `PageNodeSchema.body` admits ONE node or a list of them — on both faces
- * (objectui#8310, maintainer ruling 2026-09-07, director decision batch #2).
+ * `PageNodeSchema`'s flat content list admits ONE node or a list of them — on
+ * both faces (objectui#8310, maintainer ruling 2026-09-07, director decision
+ * batch #2).
+ *
+ * ⚠️ THE KEY MOVED, THE RULING DID NOT. objectui#8310 was decided about the
+ * `body` spelling, which was this node's flat content list at the time.
+ * objectui#6771 retired that spelling across the protocol; `FlatContent` now
+ * reads `schema.children` and nothing else, so the arity ruling governs
+ * `children` and every assertion below was moved onto it. The last block in
+ * this file is the other half: `body` is refused by name on both faces, so a
+ * page still written in the retired spelling is answered rather than silently
+ * rendered empty. Read the two together — the arity survives, the spelling did
+ * not.
*
* ## The defect this pins closed
*
- * `BaseSchema.body` is `SchemaNode | SchemaNode[]`. `PageNodeSchema` inherited
- * that channel and NARROWED it to `SchemaNode[]`, while the only reader of the
- * channel — `FlatContent`, the legacy body/children fallback inside
+ * The base declares this channel `SchemaNode | SchemaNode[]`. `PageNodeSchema`
+ * inherited it and NARROWED it to `SchemaNode[]`, while the only reader of the
+ * channel — `FlatContent`, the flat fallback inside
* `PageRenderer` (`packages/components/src/renderers/layout/page.tsx`) — went on
* accepting a bare node, normalizing it into a one-element list behind a
* `content as SchemaNode` cast. The cast was the marker: the branch was
@@ -89,7 +100,7 @@ const README = readFileSync(join(REPO_ROOT, 'README.md'), 'utf8');
/* -------------------------------------------------------------------------- */
/**
- * The `grid` node the README's flagship example hands to `body` — child list
+ * The `grid` node the README's flagship example hands to `children` — child list
* spelled `children`, the key `GridSchema` declares and `grid` reads
* (objectui#8912).
*/
@@ -106,7 +117,7 @@ const flagshipGrid: SchemaNode = {
/**
* The root `README.md` "Basic Usage" example, as a `PageNodeSchema`.
*
- * ⭐ THE pin of this card. Narrowing `body` back to `SchemaNode[]` makes this
+ * ⭐ THE pin of this card. Narrowing `children` back to `SchemaNode[]` makes this
* declaration stop compiling; that is exactly the state the repository was in
* before objectui#8310, and it is why the landing page's own example could not
* be annotated with the type of the node it is.
@@ -114,7 +125,7 @@ const flagshipGrid: SchemaNode = {
const readmeFlagshipPage: PageNodeSchema = {
type: 'page',
title: 'Dashboard',
- body: flagshipGrid,
+ children: flagshipGrid,
};
/**
@@ -126,15 +137,15 @@ const readmeFlagshipPage: PageNodeSchema = {
const listArityPage: PageNodeSchema = {
type: 'page',
title: 'Dashboard',
- body: [flagshipGrid],
+ children: [flagshipGrid],
};
/* -------------------------------------------------------------------------- */
/* Leg 2 + 3 — the zod mirror, at runtime */
/* -------------------------------------------------------------------------- */
-describe('PageNodeSchema.body arity — the declaration matches its reader (objectui#8310)', () => {
- it('parses the README flagship example, whose `body` is ONE node', () => {
+describe('PageNodeSchema flat-content arity — the declaration matches its reader (objectui#8310)', () => {
+ it('parses the README flagship example, whose `children` is ONE node', () => {
const result = ZodPageNodeSchema.safeParse(readmeFlagshipPage);
expect(result.success).toBe(true);
});
@@ -145,9 +156,9 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje
});
it('keeps the single node intact through the parse rather than wrapping it', () => {
- const parsed = ZodPageNodeSchema.parse(readmeFlagshipPage) as { body?: unknown };
- expect(Array.isArray(parsed.body)).toBe(false);
- expect((parsed.body as { type?: string } | undefined)?.type).toBe('grid');
+ const parsed = ZodPageNodeSchema.parse(readmeFlagshipPage) as { children?: unknown };
+ expect(Array.isArray(parsed.children)).toBe(false);
+ expect((parsed.children as { type?: string } | undefined)?.type).toBe('grid');
});
});
@@ -156,19 +167,19 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje
/* -------------------------------------------------------------------------- */
/**
- * REFUSAL CONTROL — what the widened `body` must still turn away.
+ * REFUSAL CONTROL — what the widened `children` must still turn away.
*
- * Legs 1-3 all assert ACCEPTANCE. A `body` arm that had degraded into
+ * Legs 1-3 all assert ACCEPTANCE. A `children` arm that had degraded into
* accept-anything (`z.any()`, or a union whose floor fell out) passes every one
* of them green, so on their own they measure the widening's DIRECTION and
* never its BOUND. This block is the missing half. It pins the refusal
- * ENVELOPE — an `invalid_union` issue at path `['body']` — rather than the bare
- * falsity of `success`, so a page refused for some unrelated reason cannot
- * stand in for a `body` that was judged and rejected.
+ * ENVELOPE — an `invalid_union` issue at path `['children']` — rather than the
+ * bare falsity of `success`, so a page refused for some unrelated reason cannot
+ * stand in for a `children` that was judged and rejected.
*
* ## ⚠️ Measured, and it is not what the obvious guess says
*
- * `body: 42` and `body: 'x'` are **LEGAL**, on both faces, and an assertion
+ * `children: 42` and `children: 'x'` are **LEGAL**, on both faces, and an assertion
* that they are refused pins a contract this repo does not have:
*
* - `../base.ts`: `type SchemaNode = BaseSchema | string | number | boolean | null | undefined`
@@ -177,8 +188,8 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje
*
* A primitive IS a `SchemaNode` here — the text-node convention — so
* `SchemaNode | SchemaNode[]` admits `42` by construction, exactly as
- * `BaseSchema.body`, `CardSchema.body` and `AspectRatioSchema.body` already
- * did before this card. ⛔ Do not "harden" this block by asserting a primitive
+ * `BaseSchema.children`, `CardSchema.children` and `AspectRatioSchema.children`
+ * already did before this card. ⛔ Do not "harden" this block by asserting a primitive
* is refused: that assertion is RED today, and if it ever went green it would
* mean the node union had lost arms it is declared to have.
*
@@ -194,30 +205,75 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje
* requires `type`, and a discriminated union cannot dispatch without it — so
* that is the value this control is built on, in either arity.
*/
-const REFUSED_BODIES: ReadonlyArray<[label: string, body: unknown]> = [
+const REFUSED_CONTENT: ReadonlyArray<[label: string, children: unknown]> = [
['an object that is not a node — it carries no `type`', {}],
['an object with node-ish keys but still no `type`', { columns: 3 }],
['the LIST arity carrying that same non-node object', [{}]],
];
-describe('PageNodeSchema.body still refuses a non-node — the widening kept its floor (objectui#8310)', () => {
- for (const [label, body] of REFUSED_BODIES) {
+describe('PageNodeSchema flat content still refuses a non-node — the widening kept its floor (objectui#8310)', () => {
+ for (const [label, children] of REFUSED_CONTENT) {
it(`refuses ${label}`, () => {
- const result = ZodPageNodeSchema.safeParse({ type: 'page', title: 'Dashboard', body });
+ const result = ZodPageNodeSchema.safeParse({ type: 'page', title: 'Dashboard', children });
expect(result.success).toBe(false);
if (result.success) return;
- const bodyIssues = result.error.issues.filter((issue) => issue.path[0] === 'body');
- expect(bodyIssues.length).toBeGreaterThan(0);
- expect(bodyIssues.map((issue) => issue.code)).toContain('invalid_union');
+ const contentIssues = result.error.issues.filter((issue) => issue.path[0] === 'children');
+ expect(contentIssues.length).toBeGreaterThan(0);
+ expect(contentIssues.map((issue) => issue.code)).toContain('invalid_union');
});
}
- it('BOUNDARY — a primitive body stays legal, because `SchemaNode` declares it', () => {
+ it('BOUNDARY — a primitive stays legal, because `SchemaNode` declares it', () => {
// ⛔ Not a defect and ⛔ not a candidate for "hardening" — see the docblock.
- expect(ZodPageNodeSchema.safeParse({ type: 'page', body: 42 }).success).toBe(true);
- expect(ZodPageNodeSchema.safeParse({ type: 'page', body: 'Hello' }).success).toBe(true);
+ expect(ZodPageNodeSchema.safeParse({ type: 'page', children: 42 }).success).toBe(true);
+ expect(ZodPageNodeSchema.safeParse({ type: 'page', children: 'Hello' }).success).toBe(true);
+ });
+});
+
+/* -------------------------------------------------------------------------- */
+/* The other half of the move — the retired spelling is ANSWERED, not ignored */
+/* -------------------------------------------------------------------------- */
+
+/**
+ * objectui#6771 retired `body`. The arity above moved onto `children`; this
+ * block pins what happened to the key it moved off.
+ *
+ * ⛔ The assertion is NOT "the old pin is gone". A deleted member would be
+ * ACCEPTED, silently: `BaseSchemaCore` ends `.passthrough()` and the TS face
+ * carries `[key: string]: any`, so dropping the declaration would leave a page
+ * written in the retired spelling parsing green and rendering nothing — the
+ * exact silence the retirement exists to end. What is pinned is the REFUSAL,
+ * at the key's own path, carrying the remedy.
+ */
+describe('`body` is refused by name on the page node (objectui#6771)', () => {
+ it('refuses the single-node form the README used to author under `body`', () => {
+ const result = ZodPageNodeSchema.safeParse({
+ type: 'page',
+ title: 'Dashboard',
+ body: flagshipGrid,
+ });
+ expect(result.success).toBe(false);
+ if (result.success) return;
+ const bodyIssues = result.error.issues.filter((issue) => issue.path[0] === 'body');
+ expect(bodyIssues.length).toBeGreaterThan(0);
+ expect(bodyIssues[0]!.message).toContain('`children`');
+ });
+
+ it('refuses the LIST arity under the retired spelling too — this is about the key', () => {
+ const result = ZodPageNodeSchema.safeParse({ type: 'page', body: [flagshipGrid] });
+ expect(result.success).toBe(false);
+ });
+
+ it('CONTROL — the identical document under `children` parses green', () => {
+ // Without this leg, a mirror that refused every page would satisfy the two
+ // assertions above.
+ expect(ZodPageNodeSchema.safeParse({
+ type: 'page',
+ title: 'Dashboard',
+ children: flagshipGrid,
+ }).success).toBe(true);
});
});
@@ -248,16 +304,16 @@ function basicUsageSchemaLiteral(): string {
throw new Error('unbalanced braces in the README "Basic Usage" schema literal');
}
-/** The first non-whitespace character of the literal's `body:` value. */
-function bodyValueOpener(literal: string): string {
- const at = literal.indexOf('body:');
+/** The first non-whitespace character of the literal's `children:` value. */
+function contentValueOpener(literal: string): string {
+ const at = literal.indexOf('children:');
expect(at).toBeGreaterThan(-1);
- return literal.slice(at + 'body:'.length).trimStart()[0];
+ return literal.slice(at + 'children:'.length).trimStart()[0];
}
describe('the root README keeps authoring the single-node form (objectui#8310)', () => {
- it('gives `body` ONE node, not a list — the shape this card made legal', () => {
- expect(bodyValueOpener(basicUsageSchemaLiteral())).toBe('{');
+ it('gives `children` ONE node, not a list — the shape this card made legal', () => {
+ expect(contentValueOpener(basicUsageSchemaLiteral())).toBe('{');
});
it('still authors the page/grid/statistic shape transcribed above', () => {
diff --git a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts
index e74b87ec07..b2c327c331 100644
--- a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts
+++ b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts
@@ -128,7 +128,8 @@ describe('objectui#8871 — the `page` node refuses `breadcrumbs` (contract half
// copy).
expect(message).toContain('breadcrumbs');
expect(message).toContain('breadcrumb');
- expect(message).toContain('body');
+ // The remedy key, which objectui#6771 moved from `body` to `children`.
+ expect(message).toContain('children');
expect(message).toContain('page:header');
// Zod's own default for a `never` arm says none of this.
expect(message).not.toBe('Invalid input: expected never, received array');
@@ -142,11 +143,11 @@ describe('objectui#8871 — the `page` node refuses `breadcrumbs` (contract half
expect(PageNodeSchema.safeParse(withoutBreadcrumbs).success).toBe(true);
});
- it('the remedy the message names actually parses — a `breadcrumb` node in `body`', () => {
+ it('the remedy the message names actually parses — a `breadcrumb` node in `children`', () => {
const r = PageNodeSchema.safeParse({
type: 'page',
title: 'Acme Corporation',
- body: [
+ children: [
{
type: 'breadcrumb',
items: [
@@ -235,7 +236,7 @@ describe('objectui#8871 — the guide teaches the node, not the key', () => {
// A deletion that leaves no trace teaches the next author nothing — they
// rewrite the key from memory. The guide must name the card and the remedy.
expect(guide).toContain('objectui#8871');
- expect(guide).toMatch(/breadcrumb.*node.*`body`|`body`.*breadcrumb.*node/s);
+ expect(guide).toMatch(/breadcrumb.*node.*`children`|`children`.*breadcrumb.*node/s);
});
});
diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts
index bcebba9b90..548244c121 100644
--- a/packages/types/src/base.ts
+++ b/packages/types/src/base.ts
@@ -299,14 +299,46 @@ export interface BaseSchema {
bind?: string;
/**
- * Child components or content.
- * Can be a single component, array of components, or primitive values.
+ * RETIRED (objectui#6771, maintainer ruling 2026-09-01, ADR-0049) — the
+ * second spelling of {@link BaseSchema.children}. Author `children`.
+ *
+ * ## Why it is refused by name rather than merely deleted
+ *
+ * `BaseSchema` carries an index signature on the TS side and
+ * `BaseSchemaCore` ends `.passthrough()` on the zod side, so DELETING a
+ * member does not refuse it — it makes it silently acceptable and
+ * silently inert, which is the state this retirement exists to leave. The
+ * `?: never` twin of the mirror's `aliasKeyRefusal` is what turns the
+ * removal into an answer: `tsc` refuses the key at the authoring site and
+ * the parse names `children` as the remedy.
+ *
+ * ## What it used to be
+ *
+ * A declared twin of `children`, and for a dozen registrations — `badge`,
+ * `alert`, the `sidebar-*` family, `tooltip` — the ONLY child-list key
+ * their renderer read, while `div` / `card` / `page` / `button` /
+ * `aspect-ratio` / the sectioning tags read `children || body` and took
+ * either. One concept with two spellings is the lenient-fallback shape
+ * AGENTS.md #0.1 names, and the parser tier only ever knew `children`, so
+ * the one spelling that resolved drew the warning a typo draws. The
+ * ruling retired the spelling rather than blessing it: the registrations
+ * converged on `children` and this repository's own corpus and teaching
+ * moved in the same change.
+ *
+ * @deprecated Retired spelling of `children` — author `children`.
*/
- body?: SchemaNode | SchemaNode[];
+ body?: never;
/**
- * Alternative name for children (React-style).
- * Some components use 'children' instead of 'body'.
+ * Child components or content — THE child-list key, and since
+ * objectui#6771 the only one. Can be a single component, an array of
+ * components, or primitive values.
+ *
+ * This docblock used to read "alternative name for children" and admit
+ * that "some components use 'children' instead of 'body'" without saying
+ * which — a sentence that was itself load-bearing evidence on three
+ * separate cards, because it told an author both spellings were live and
+ * left them to guess per component.
*/
children?: SchemaNode | SchemaNode[];
diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts
index 591f90256d..b1d08e37ea 100644
--- a/packages/types/src/data-display.ts
+++ b/packages/types/src/data-display.ts
@@ -55,26 +55,26 @@ export interface AlertSchema extends BaseSchema {
*/
onDismiss?: never;
/**
- * REFUSED BY NAME (objectui#8284, ADR-0049) — `alert` reads `body`, and no
- * renderer read consumes `children`.
+ * REFUSED BY NAME (objectui#8284, ADR-0049; INVERTED by objectui#6771) —
+ * `alert` reads `children`, and no renderer read consumes `body`.
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
- * docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.body` read in
- * `packages/components/src/renderers/data-display/alert.tsx`. The same sweep finds zero `children` reads
- * for this node type.
+ * docblock mention is not a read): the `schema.children` read in
+ * `packages/components/src/renderers/data-display/alert.tsx`. The same sweep
+ * finds zero `body` reads for this node type.
*
- * `children` is inherited-and-optional from {@link BaseSchema}, whose own
- * docblock admits "some components use `children` instead of `body`" without
- * saying which — so authoring it here type-checked, parsed green through
- * `.passthrough()`, and rendered an EMPTY element with no error and no
- * warning. Per component, the channel a renderer does not read is now
- * tombstoned on both published faces (maintainer ruling, summon #17 decision batch #2, 2026-09-07).
+ * ⚠️ THIS MEMBER SWAPPED SIDES, and the principle did not. objectui#8284
+ * tombstoned `children` here, because `body` was then the only key this
+ * renderer read — `alert` was one of the dozen registrations for which
+ * `body` was the ONLY door. objectui#6771 retired that spelling across the
+ * protocol, the renderer converged on `children`, and the tombstone follows
+ * the read: the channel a renderer does not read is refused by name, on both
+ * published faces (maintainer ruling, summon #17 decision batch #2,
+ * 2026-09-07). Which key that is changed; the rule did not.
*
- * @deprecated Not a channel `alert` reads — author `body`.
+ * @deprecated Not a channel `alert` reads — author `children`.
*/
- children?: never;
+ body?: never;
}
/**
@@ -171,26 +171,26 @@ export interface BadgeSchema extends BaseSchema {
*/
icon?: string;
/**
- * REFUSED BY NAME (objectui#8284, ADR-0049) — `badge` reads `body`, and no
- * renderer read consumes `children`.
+ * REFUSED BY NAME (objectui#8284, ADR-0049; INVERTED by objectui#6771) —
+ * `badge` reads `children`, and no renderer read consumes `body`.
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
- * docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.body` read in
- * `packages/components/src/renderers/data-display/badge.tsx`. The same sweep finds zero `children` reads
- * for this node type.
+ * docblock mention is not a read): the `schema.children` read in
+ * `packages/components/src/renderers/data-display/badge.tsx`. The same sweep
+ * finds zero `body` reads for this node type.
*
- * `children` is inherited-and-optional from {@link BaseSchema}, whose own
- * docblock admits "some components use `children` instead of `body`" without
- * saying which — so authoring it here type-checked, parsed green through
- * `.passthrough()`, and rendered an EMPTY element with no error and no
- * warning. Per component, the channel a renderer does not read is now
- * tombstoned on both published faces (maintainer ruling, summon #17 decision batch #2, 2026-09-07).
+ * ⚠️ THIS MEMBER SWAPPED SIDES, and the principle did not. objectui#8284
+ * tombstoned `children` here, because `body` was then the only key this
+ * renderer read — `badge` was one of the dozen registrations for which
+ * `body` was the ONLY door. objectui#6771 retired that spelling across the
+ * protocol, the renderer converged on `children`, and the tombstone follows
+ * the read: the channel a renderer does not read is refused by name, on both
+ * published faces (maintainer ruling, summon #17 decision batch #2,
+ * 2026-09-07). Which key that is changed; the rule did not.
*
- * @deprecated Not a channel `badge` reads — author `body`.
+ * @deprecated Not a channel `badge` reads — author `children`.
*/
- children?: never;
+ body?: never;
}
/**
diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts
index 110478cc31..a2b17b6fd6 100644
--- a/packages/types/src/form.ts
+++ b/packages/types/src/form.ts
@@ -788,8 +788,10 @@ export interface ToggleSchema extends BaseSchema {
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
* docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.children` read in
+ * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it,
+ * until objectui#6771 rewrote that docblock — the example is spent, the
+ * hazard is not, which is why the measurement is a type-checker sweep and
+ * never a grep): the `schema.children` read in
* `packages/components/src/renderers/form/toggle.tsx`. The same sweep finds zero `body` reads
* for this node type.
*
@@ -1945,8 +1947,10 @@ export interface FormSchema extends BaseSchema {
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
* docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.children` read in
+ * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it,
+ * until objectui#6771 rewrote that docblock — the example is spent, the
+ * hazard is not, which is why the measurement is a type-checker sweep and
+ * never a grep): the `schema.children` read in
* `packages/components/src/renderers/form/form.tsx`. The same sweep finds zero `body` reads
* for this node type.
*
diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts
index 01496bc198..6acdee8dac 100644
--- a/packages/types/src/layout.ts
+++ b/packages/types/src/layout.ts
@@ -60,8 +60,10 @@ export interface BoxSchema extends BaseSchema {
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
* docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.children` read in
+ * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it,
+ * until objectui#6771 rewrote that docblock — the example is spent, the
+ * hazard is not, which is why the measurement is a type-checker sweep and
+ * never a grep): the `schema.children` read in
* `packages/components/src/renderers/layout/box.tsx`. The same sweep finds zero `body` reads
* for this node type.
*
@@ -96,8 +98,10 @@ export interface TextSpanSchema extends BaseSchema {
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
* docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.children` read in
+ * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it,
+ * until objectui#6771 rewrote that docblock — the example is spent, the
+ * hazard is not, which is why the measurement is a type-checker sweep and
+ * never a grep): the `schema.children` read in
* `packages/components/src/renderers/basic/span.tsx`. The same sweep finds zero `body` reads
* for this node type.
*
@@ -543,8 +547,10 @@ export interface ContainerSchema extends BaseSchema {
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
* docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.children` read in
+ * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it,
+ * until objectui#6771 rewrote that docblock — the example is spent, the
+ * hazard is not, which is why the measurement is a type-checker sweep and
+ * never a grep): the `schema.children` read in
* `packages/components/src/renderers/layout/container.tsx`. The same sweep finds zero `body` reads
* for this node type.
*
@@ -669,8 +675,10 @@ export interface FlexSchema extends BaseSchema, FlexLayoutProps {
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
* docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.children` read in
+ * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it,
+ * until objectui#6771 rewrote that docblock — the example is spent, the
+ * hazard is not, which is why the measurement is a type-checker sweep and
+ * never a grep): the `schema.children` read in
* `packages/components/src/renderers/layout/flex.tsx`. The same sweep finds zero `body` reads
* for this node type.
*
@@ -701,8 +709,10 @@ export interface StackSchema extends BaseSchema, FlexLayoutProps {
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
* docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.children` read in
+ * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it,
+ * until objectui#6771 rewrote that docblock — the example is spent, the
+ * hazard is not, which is why the measurement is a type-checker sweep and
+ * never a grep): the `schema.children` read in
* `packages/components/src/renderers/layout/stack.tsx`. The same sweep finds zero `body` reads
* for this node type.
*
@@ -750,8 +760,10 @@ export interface GridSchema extends BaseSchema {
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
* docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.children` read in
+ * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it,
+ * until objectui#6771 rewrote that docblock — the example is spent, the
+ * hazard is not, which is why the measurement is a type-checker sweep and
+ * never a grep): the `schema.children` read in
* `packages/components/src/renderers/layout/grid.tsx`. The same sweep finds zero `body` reads
* for this node type.
*
@@ -785,11 +797,27 @@ export interface CardSchema extends BaseSchema {
*/
header?: SchemaNode | SchemaNode[];
/**
- * Card body/content (Legacy, use children)
+ * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body`
+ * child-list spelling. Author {@link CardSchema.children}.
+ *
+ * This key was already labelled "Legacy, use children" and the renderer
+ * read `renderNodeSlot(schema.children || schema.body, …)`, so `card` was
+ * one of the fallback readers whose `body` arm the ruling drops in the
+ * same change as the `body`-only registrations converge.
+ *
+ * Refused by name rather than deleted: `BaseSchema` carries
+ * `[key: string]: any` and the zod twin ends `.passthrough()`, so a
+ * deleted member is ACCEPTED silently and rendered by nothing. The
+ * `?: never` is what makes `tsc` answer at the authoring site, and the
+ * mirror's `aliasKeyRefusal` names `children` at parse time.
+ *
+ * @deprecated Retired spelling of `children` — author `children`.
*/
- body?: SchemaNode | SchemaNode[];
+ body?: never;
/**
- * Child components
+ * Card content — the card's child list, and since objectui#6771 its only
+ * spelling. READ SITE: `renderNodeSlot(schema.children, …)` in
+ * `packages/components/src/renderers/layout/card.tsx`.
*/
children?: SchemaNode | SchemaNode[];
/**
@@ -994,8 +1022,10 @@ export interface ScrollAreaSchema extends BaseSchema {
*
* READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
* docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.children` read in
+ * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it,
+ * until objectui#6771 rewrote that docblock — the example is spent, the
+ * hazard is not, which is why the measurement is a type-checker sweep and
+ * never a grep): the `schema.children` read in
* `packages/components/src/renderers/complex/scroll-area.tsx`. The same sweep finds zero `body` reads
* for this node type.
*
@@ -1135,11 +1165,25 @@ export interface AspectRatioSchema extends BaseSchema {
*/
alt?: string;
/**
- * Child components (alternative to image)
+ * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body`
+ * child-list spelling. Author {@link AspectRatioSchema.children}.
+ *
+ * `aspect-ratio` read `renderChildren(schema.children || schema.body)`
+ * whenever no `image` was set, so it is one of the fallback readers whose
+ * `body` arm drops in the same change.
+ *
+ * Refused by name rather than deleted: `BaseSchema` carries
+ * `[key: string]: any` and the zod twin ends `.passthrough()`, so a
+ * deleted member is ACCEPTED silently and rendered by nothing. The
+ * `?: never` is what makes `tsc` answer at the authoring site, and the
+ * mirror's `aliasKeyRefusal` names `children` at parse time.
+ *
+ * @deprecated Retired spelling of `children` — author `children`.
*/
- body?: SchemaNode | SchemaNode[];
+ body?: never;
/**
- * Child components (alternative syntax)
+ * Child components rendered inside the ratio box when no `image` is set —
+ * the one child-list spelling (objectui#6771).
*/
children?: SchemaNode | SchemaNode[];
}
@@ -1257,13 +1301,13 @@ export interface PageNodeSchema extends BaseSchema {
*
* `PageRenderer` has no read point for it: a `page` node carrying
* `actions: [{type:'button',label:'Add Product'}, …]` drew 0 buttons through
- * the real `SchemaRenderer`, while the SAME two buttons in {@link body} drew
+ * the real `SchemaRenderer`, while the SAME two buttons in {@link PageNodeSchema.children} drew
* 2. `BaseSchema` is `.passthrough()`, so the array was not dropped — it was
* kept, and before objectui#7933 it reached the DOM as
* `actions="[object Object],[object Object]"`.
*
* The remedy is a NODE, not a key: put a `button` (or an `action:button` with
- * a declared `actionType`) in {@link body}; on a record page declare them on a
+ * a declared `actionType`) in {@link PageNodeSchema.children}; on a record page declare them on a
* `page:header` block, whose own `actions` are ACTION IDS resolved from the
* object's metadata (objectui#7182) rather than nodes.
*
@@ -1300,7 +1344,7 @@ export interface PageNodeSchema extends BaseSchema {
*
* The remedy is a NODE that already ships: put
* `{ "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] }` in
- * {@link body}. `breadcrumb` is a registered renderer taking that exact item
+ * {@link PageNodeSchema.children}. `breadcrumb` is a registered renderer taking that exact item
* shape, plus `separator`, `maxItems` and a per-item `icon`.
* ⛔ Not the `page:header` block's `breadcrumb`: that one is SINGULAR and a
* BOOLEAN display toggle, not a list of links.
@@ -1358,21 +1402,41 @@ export interface PageNodeSchema extends BaseSchema {
// blankLayout removed — the `blank` page type has no renderer and was dropped
// from @objectstack/spec PageTypeSchema (framework#2265, enforce-or-remove).
/**
- * Main content (Legacy/Simple mode) — ONE node, or a list of them.
+ * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body`
+ * child-list spelling. Author {@link PageNodeSchema.children}.
+ *
+ * `PageRenderer`'s `FlatContent` read `schema.body || schema.children`, and
+ * the registration published `body` as the flat content list on all five
+ * page kinds. Both moved to `children` in the retirement; the ARITY ruling
+ * this key carried (objectui#8310 — one node OR a list) moved with it and
+ * is restated on `children` below, because that is now the key it governs.
+ *
+ * Refused by name rather than deleted: `BaseSchema` carries
+ * `[key: string]: any` and the zod twin ends `.passthrough()`, so a deleted
+ * member is ACCEPTED silently and rendered by nothing.
+ *
+ * @deprecated Retired spelling of `children` — author `children`.
+ */
+ body?: never;
+ /**
+ * Main content when a page declares no regions — ONE node, or a list of
+ * them.
*
* The union is the declaration catching up to its reader, not a widening for
- * convenience (objectui#8310, maintainer ruling 2026-09-07). This key read
- * `SchemaNode[]` and was the OUTLIER in this file: `CardSchema.body` and
- * `AspectRatioSchema.body` already spell the union, and so does
- * `BaseSchema.body` — the channel this interface inherits and then narrowed.
+ * convenience (objectui#8310, maintainer ruling 2026-09-07). The ruling was
+ * made about the `body` spelling this interface used to declare; objectui#6771
+ * retired that spelling, so the union lives here, on the key the same reader
+ * now takes. `CardSchema.children` and `AspectRatioSchema.children` spell the
+ * same union, and so does `BaseSchema.children`.
* `PageRenderer`'s `FlatContent` fallback
* (`packages/components/src/renderers/layout/page.tsx`) has always accepted a
* bare node, normalizing it into a one-element list; under the narrowing that
* branch was unreachable through the renderer's own declared props and stood
* only behind a `content as SchemaNode` cast, which the same ruling deletes.
*
- * The refused value is authored on this project's own landing page: the root
- * `README.md` "Basic Usage" example gives `body` a single `grid` node. It
+ * The refused value was authored on this project's own landing page: the root
+ * `README.md` "Basic Usage" example gives this key a single `grid` node (it
+ * spelled the key `body` until objectui#6771 moved the corpus). It
* type-checked only because that snippet is annotated `BaseSchema` — the
* WIDER parent — so nothing on the authoring path ever asked this key about
* its arity. Pinned by `__tests__/page-body-arity-8310.test.ts`.
@@ -1383,10 +1447,6 @@ export interface PageNodeSchema extends BaseSchema {
* absorbed by the index signature, zero diagnostics). This union repairs the
* first case only.
*/
- body?: SchemaNode | SchemaNode[];
- /**
- * Alternative content prop
- */
children?: SchemaNode | SchemaNode[];
/**
* Whether this is the default page for the object/app.
@@ -1537,8 +1597,10 @@ export interface PageSlotMap {
* that rendered correctly in the browser was refused by `objectui check`.
*
* The renderer is one factory over all seven tags: it renders
- * `renderChildren(schema.children || schema.body)` inside the tag and declares
- * exactly one authoring input, `className` (a {@link BaseSchema} member). The
+ * `renderChildren(schema.children)` inside the tag and declares exactly one
+ * authoring input, `className` (a {@link BaseSchema} member). It read
+ * `schema.children || schema.body` until objectui#6771 retired the second
+ * spelling. The
* mirror is `zod/layout.zod.ts#SemanticElementSchema`, and
* `__tests__/node-slot-registered-arms-8499.test.ts` compares the tag list below
* against `semantic.tsx`'s own `tags` array.
@@ -1546,7 +1608,8 @@ export interface PageSlotMap {
export interface SemanticElementSchema extends BaseSchema {
type: 'aside' | 'main' | 'header' | 'nav' | 'footer' | 'section' | 'article';
/**
- * Child components — read as `schema.children || schema.body`.
+ * Child components. The factory read `schema.children || schema.body` until
+ * objectui#6771 retired the second spelling; it reads `schema.children`.
*/
children?: SchemaNode | SchemaNode[];
}
@@ -1577,8 +1640,9 @@ export interface HtmlElementSchema extends BaseSchema {
| 'ul' | 'ol' | 'li' | 'dl' | 'dt' | 'dd'
| 'figure' | 'figcaption' | 'img' | 'hr' | 'br' | 'time' | 'address' | 'cite' | 'q';
/**
- * Child components — read as `schema.children ?? schema.body`; ignored for the
- * void tags `img` / `hr` / `br`.
+ * Child components; ignored for the void tags `img` / `hr` / `br`. The factory
+ * read `schema.children ?? schema.body` until objectui#6771 retired the second
+ * spelling.
*/
children?: SchemaNode | SchemaNode[];
/** `a` link target; scheme-sanitised (`javascript:` / `data:` / `vbscript:` are dropped). */
diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts
index 7fbafd07d4..ed725e1e94 100644
--- a/packages/types/src/overlay.ts
+++ b/packages/types/src/overlay.ts
@@ -688,12 +688,16 @@ export interface PopoverSchema extends BaseSchema {
* Tooltip component
*
* ⚠️ This declaration used to REQUIRE `children` and declare neither `trigger`
- * nor `body` (objectui#6939). Nothing reads `children` here — the renderer
- * reads `schema.trigger` and `schema.content || renderChildren(schema.body)`
+ * nor a rich-content slot (objectui#6939). The renderer reads `schema.trigger`
+ * and `schema.content || renderChildren(…)`
* (`packages/components/src/renderers/overlay/tooltip.tsx`), and the
- * registration's own `inputs` list `trigger` / `content` / `body` and never
- * `children`. `children` stays legal through {@link BaseSchema}, where it is
- * optional; it is no longer demanded, so nothing that type-checked before
+ * registration's own `inputs` list `trigger` / `content` / that slot. ⚠️ The
+ * slot was spelled `body` from objectui#6939 until objectui#6771 retired the
+ * spelling; it is `children` on both the read and the published `inputs` now,
+ * which is why the tombstones below face the other way round from the ones
+ * objectui#8284 first wrote. `children` was legal through {@link BaseSchema}
+ * throughout, where it is optional; it is no longer demanded, so nothing that
+ * type-checked before
* stops type-checking.
*
* ⛔ Do not move a tooltip's trigger back under `children`: `basic-tooltip`
@@ -717,38 +721,41 @@ export interface TooltipSchema extends BaseSchema {
* Tooltip content/text — the FIRST half of the content read.
*
* READ SITE: `packages/components/src/renderers/overlay/tooltip.tsx` —
- * `schema.content || renderChildren(schema.body)`. Optional because
- * {@link TooltipSchema.body} is the other half of that same read.
+ * `schema.content || renderChildren(schema.children)`. Optional because
+ * {@link TooltipSchema.children} is the other half of that same read.
*/
content?: string | SchemaNode;
/**
- * Rich tooltip content — the FALLBACK half of the same read at
- * `packages/components/src/renderers/overlay/tooltip.tsx`, listed by the
- * registration as the "Rich Content" slot.
- */
- body?: SchemaNode | SchemaNode[];
- /**
- * REFUSED BY NAME (objectui#8284, ADR-0049) — `tooltip` reads `content` and,
- * as the fallback for that same slot, `body`. No renderer read consumes
- * `children`.
- *
- * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a
- * docblock mention is not a read; the `BoxSchema` docblock in
- * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts
- * it): the `schema.body` read in
- * `packages/components/src/renderers/overlay/tooltip.tsx`. The same sweep finds zero `children` reads
- * for this node type.
+ * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body`
+ * child-list spelling. Author {@link TooltipSchema.children}.
*
- * `children` is inherited-and-optional from {@link BaseSchema}, whose own
- * docblock admits "some components use `children` instead of `body`" without
- * saying which — so authoring it here type-checked, parsed green through
- * `.passthrough()`, and rendered an EMPTY element with no error and no
- * warning. Per component, the channel a renderer does not read is now
- * tombstoned on both published faces (maintainer ruling, summon #17 decision batch #2, 2026-09-07).
+ * ⭐ `tooltip` was the ONE registration in the whole tree that ADVERTISED
+ * this spelling in its `inputs` (a `slot`, labelled "Rich Content"), which
+ * made it the most discoverable instance of the dialect on the authoring
+ * surface — and the ruled step-2 population never listed it. Leaving it out
+ * would have kept the dialect alive precisely where an author was most
+ * likely to meet it, so it converged with the rest.
*
- * @deprecated Not a channel `tooltip` reads — author `body`.
+ * @deprecated Retired spelling of `children` — author `children`.
*/
- children?: never;
+ body?: never;
+ /**
+ * Rich tooltip content — the FALLBACK half of the same read at
+ * `packages/components/src/renderers/overlay/tooltip.tsx`, and the slot the
+ * registration publishes as "Rich Content".
+ *
+ * ⚠️ THIS DECLARATION IS AN INVERSION, not a widening. objectui#8284 (ADR-0049,
+ * maintainer ruling summon #17 decision batch #2, 2026-09-07) tombstoned
+ * `children` on this node with the measured ground that the renderer read
+ * `schema.content || renderChildren(schema.body)` and no read consumed
+ * `children`. objectui#6771 changed the READ, not the principle: the same
+ * renderer now reads `schema.content || renderChildren(schema.children)`, so
+ * the channel this node does not read is `body`, and that is where the
+ * tombstone moved. The per-component rule — tombstone the channel the
+ * renderer does not read, on both published faces — is unchanged and is
+ * still what both of these members express.
+ */
+ children?: SchemaNode | SchemaNode[];
/**
* Tooltip side
* @default 'top'
diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts
index bf2529370f..ba3da6c9f7 100644
--- a/packages/types/src/zod/base.zod.ts
+++ b/packages/types/src/zod/base.zod.ts
@@ -18,7 +18,7 @@
import { z } from 'zod';
import { I18nLabelSchema } from '@objectstack/spec/ui';
-import { retirementTombstone } from './tombstone.zod.js';
+import { aliasKeyRefusal, retirementTombstone } from './tombstone.zod.js';
import { ExpressionWireSchema } from './expression.zod.js';
import type { SchemaNode } from '../base.js';
import { stripImportedDefaults } from './imported-defaults.js';
@@ -369,12 +369,24 @@ const BaseSchemaCore = z.object({
bind: z.string().optional().describe('Data-scope binding path (resolved by useDataScope)'),
/**
- * Child components or content
+ * RETIRED (objectui#6771) — mirrors `BaseSchema.body: never` (`../base.ts`).
+ *
+ * A bare deletion would be a silent accept, not a refusal: this object ends
+ * `.passthrough()`, so an undeclared `body` parses green and reaches a
+ * renderer that no longer reads it. The alias refusal keeps the key
+ * declared and unwritable, and names `children` in the issue message —
+ * the remedy, not just the rejection.
*/
- body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'),
+ body: aliasKeyRefusal(
+ 'body',
+ 'children',
+ 'this node',
+ 'The `body` child-list spelling was retired by objectui#6771 — one concept, '
+ + 'one spelling. Every registration that read it now reads `children`.',
+ ),
/**
- * Alternative children property
+ * Child components or content — the one child-list spelling (objectui#6771)
*/
children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components (React-style)'),
diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts
index 56f931cdb5..430ab45b47 100644
--- a/packages/types/src/zod/complex.zod.ts
+++ b/packages/types/src/zod/complex.zod.ts
@@ -793,6 +793,25 @@ export const ChatbotEnhancedSchema = BaseSchema.extend({
surface: z.enum(['card', 'plain']).optional()
.describe("Visual chrome for the chat surface: 'card' bordered panel (default) or 'plain' frameless full-page workspace (objectui#6687)"),
onClear: chatbotOnClearArm(),
+ // Declared HERE rather than inherited, because objectui#6771's retirement
+ // of `BaseSchema.body` refuses the key with a message naming `children` —
+ // which is the right remedy on every node except this family, where
+ // `children` is refused too and the key an author actually wants is
+ // `requestBody`. The sentence below is the one this node's `children`
+ // tombstone already asserts about BOTH channels; only the declaration was
+ // missing, and inheriting a wrong remedy is worse than inheriting none.
+ body: retirementTombstone(
+ 'REFUSED (objectui#9256, ADR-0049) — `chatbot-enhanced` reads NEITHER content channel: measured with the '
+ + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '
+ + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An '
+ + 'authored value therefore rendered NOTHING — no error, no warning, no element. '
+ + 'The chat API body params go on `requestBody`, which the registration forwards to the chat runtime. '
+ + 'What it renders instead: `api`, `assistantAvatarFallback`, `assistantAvatarUrl`, `autoResponse`, '
+ + '`autoResponseDelay`, `autoResponseText`, `conversationId`, `enableFileUpload`, `enableMarkdown`, '
+ + '`headers`, `maxHeight`, `maxToolRoundtrips`, `messages`, `model`, `onClear`, `onError`, '
+ + '`onSend`, `placeholder`, `processVisibility`, `requestBody`, `showTimestamp`, '
+ + '`streamingEnabled`, `surface`, `systemPrompt`, `userAvatarFallback`, `userAvatarUrl`.',
+ ),
children: retirementTombstone(
'REFUSED (objectui#9256, ADR-0049) — `chatbot-enhanced` reads NEITHER content channel: measured with the '
+ 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '
@@ -838,6 +857,25 @@ export const ChatbotFloatingSchema = BaseSchema.extend({
enableMarkdown: chatbotEnableMarkdownArm(),
enableFileUpload: chatbotEnableFileUploadArm(),
onClear: chatbotOnClearArm(),
+ // Declared HERE rather than inherited, because objectui#6771's retirement
+ // of `BaseSchema.body` refuses the key with a message naming `children` —
+ // which is the right remedy on every node except this family, where
+ // `children` is refused too and the key an author actually wants is
+ // `requestBody`. The sentence below is the one this node's `children`
+ // tombstone already asserts about BOTH channels; only the declaration was
+ // missing, and inheriting a wrong remedy is worse than inheriting none.
+ body: retirementTombstone(
+ 'REFUSED (objectui#9256, ADR-0049) — `chatbot-floating` reads NEITHER content channel: measured with the '
+ + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '
+ + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An '
+ + 'authored value therefore rendered NOTHING — no error, no warning, no element. '
+ + 'The chat API body params go on `requestBody`, which the registration forwards to the chat runtime. '
+ + 'What it renders instead: `api`, `assistantAvatarFallback`, `assistantAvatarUrl`, `autoResponse`, '
+ + '`autoResponseDelay`, `autoResponseText`, `conversationId`, `enableFileUpload`, `enableMarkdown`, '
+ + '`floatingConfig`, `headers`, `maxToolRoundtrips`, `messages`, `model`, `onClear`, `onError`, '
+ + '`onSend`, `placeholder`, `requestBody`, `showTimestamp`, `streamingEnabled`, `systemPrompt`, '
+ + '`userAvatarFallback`, `userAvatarUrl`.',
+ ),
children: retirementTombstone(
'REFUSED (objectui#9256, ADR-0049) — `chatbot-floating` reads NEITHER content channel: measured with the '
+ 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or '
diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts
index 9996fc9aa8..3a157b0bed 100644
--- a/packages/types/src/zod/data-display.zod.ts
+++ b/packages/types/src/zod/data-display.zod.ts
@@ -65,13 +65,16 @@ export const AlertSchema = BaseSchema.extend({
icon: z.string().optional().describe('Alert icon'),
dismissible: z.boolean().optional().describe('Whether alert can be dismissed'),
onDismiss: handlerKeyRefusal('onDismiss', 'retired', 'Dismiss handler'),
- children: aliasKeyRefusal(
- 'children',
+ // INVERTED by objectui#6771, which retired `body`: the refusal follows the
+ // read, and this renderer now reads `children`. objectui#8284's rule — the
+ // channel a renderer does not read is refused by name — is unchanged.
+ body: aliasKeyRefusal(
'body',
+ 'children',
'this alert node',
- '`alert` reads `body`, never `children` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/alert.tsx`). '
- + '`children` is inherited from `BaseSchema`, so an authored `children` parsed green here and rendered '
- + 'an EMPTY element — no error, no warning. objectui#8284.',
+ '`alert` reads `children`, never `body` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/alert.tsx`). '
+ + '`body` was this node\'s only child-list key until objectui#6771 retired the spelling; an authored `body` now parses green through '
+ + '`.passthrough()` and renders an EMPTY element — no error, no warning. objectui#8284.',
),
});
@@ -109,13 +112,16 @@ export const BadgeSchema = BaseSchema.extend({
label: z.string().optional().describe('Badge label'),
variant: z.enum(['default', 'secondary', 'destructive', 'outline']).optional().describe('Badge variant'),
icon: z.string().optional().describe('Badge icon'),
- children: aliasKeyRefusal(
- 'children',
+ // INVERTED by objectui#6771, which retired `body`: the refusal follows the
+ // read, and this renderer now reads `children`. objectui#8284's rule — the
+ // channel a renderer does not read is refused by name — is unchanged.
+ body: aliasKeyRefusal(
'body',
+ 'children',
'this badge node',
- '`badge` reads `body`, never `children` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/badge.tsx`). '
- + '`children` is inherited from `BaseSchema`, so an authored `children` parsed green here and rendered '
- + 'an EMPTY element — no error, no warning. objectui#8284.',
+ '`badge` reads `children`, never `body` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/badge.tsx`). '
+ + '`body` was this node\'s only child-list key until objectui#6771 retired the spelling; an authored `body` now parses green through '
+ + '`.passthrough()` and renders an EMPTY element — no error, no warning. objectui#8284.',
),
});
diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts
index 13efd8312a..47b0ec6578 100644
--- a/packages/types/src/zod/layout.zod.ts
+++ b/packages/types/src/zod/layout.zod.ts
@@ -405,8 +405,15 @@ export const CardSchema = BaseSchema.extend({
title: z.string().optional().describe('Card title'),
description: z.string().optional().describe('Card description'),
header: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card header content'),
- body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card body content'),
- children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'),
+ body: aliasKeyRefusal(
+ 'body',
+ 'children',
+ 'this card node',
+ '`card` read `renderNodeSlot(schema.children || schema.body, …)`, one of the fallback readers whose '
+ + '`body` arm objectui#6771 dropped in the same change as the `body`-only registrations converged. '
+ + 'The TS face declares this member `never` and the corpus moved with it.',
+ ),
+ children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card content — the one child-list spelling (objectui#6771)'),
footer: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card footer content'),
variant: z.enum(['default', 'outline', 'ghost']).optional().describe('Card variant style'),
hoverable: z.boolean().optional().describe('Whether the card is hoverable'),
@@ -520,8 +527,14 @@ export const AspectRatioSchema = BaseSchema.extend({
ratio: z.number().optional().describe('Aspect ratio (width / height)'),
image: z.string().optional().describe('Image URL to display'),
alt: z.string().optional().describe('Image alt text'),
- body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components (alternative to image)'),
- children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'),
+ body: aliasKeyRefusal(
+ 'body',
+ 'children',
+ 'this aspect-ratio node',
+ '`aspect-ratio` read `renderChildren(schema.children || schema.body)` whenever no `image` was set; '
+ + 'objectui#6771 dropped the `body` arm with the rest of the dialect.',
+ ),
+ children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components rendered when no image is set'),
});
/**
@@ -685,7 +698,7 @@ const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).sh
const PAGE_ACTIONS_REFUSAL =
'`actions` is not a key of the `page` node and never was (objectui#7926): no renderer ' +
'reads it, so an authored array drew nothing and rode `.passthrough()` onto the wrapper ' +
- 'element. Author the buttons as NODES in `body` (a `button` node, or an `action:button` ' +
+ 'element. Author the buttons as NODES in `children` (a `button` node, or an `action:button` ' +
'node with a declared `actionType`); on a record page declare them on a `page:header` ' +
'block instead, whose own `actions` are ACTION IDS resolved from the object metadata ' +
'(objectui#7182), not nodes.';
@@ -775,7 +788,7 @@ const PAGE_BREADCRUMBS_REFUSAL =
'`breadcrumbs` is not a key of the `page` node and never was (objectui#8871, ADR-0049 ' +
'enforce-or-remove): no renderer reads it, so an authored trail drew nothing and rode ' +
'`.passthrough()` through the validator as a silent accept. Author the trail as a NODE ' +
- 'in `body` instead — { "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] } ' +
+ 'in `children` instead — { "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] } ' +
'— which is a registered renderer and takes the same item shape, plus `separator` and ' +
'`maxItems`. ⛔ Not the `page:header` block\'s `breadcrumb` either: that one is SINGULAR ' +
'and a BOOLEAN display toggle, not a list of links.';
@@ -797,16 +810,24 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({
template: z.string().optional().describe('Layout template name'),
variables: z.array(PageVariableSchema).optional().describe('Local page state variables'),
regions: z.array(PageNodeRegionSchema).optional().describe('Page layout regions'),
+ body: aliasKeyRefusal(
+ 'body',
+ 'children',
+ 'this page node',
+ '`PageRenderer`\'s `FlatContent` read `schema.body || schema.children` and the registration published '
+ + '`body` as the flat content list on all five page kinds; objectui#6771 retired the spelling and '
+ + 'moved both to `children`.',
+ ),
// objectui#8310: ONE node or a list, mirroring the TS face and the runtime.
// `FlatContent` in `page.tsx` normalizes a bare node into a one-element list,
- // and the root README's flagship example authors exactly that; array-only here
- // made this the only `body` in this file that is not the union (`CardSchema`
- // and `AspectRatioSchema` already spell it, as does `BaseSchema`).
- body: z
+ // and the root README's flagship example authors exactly that. The ruling was
+ // made about the `body` spelling; objectui#6771 retired it, so the union lives
+ // on the key the same reader now takes (`CardSchema` and `AspectRatioSchema`
+ // spell the same union on `children`, as does `BaseSchema`).
+ children: z
.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])
.optional()
.describe('Main content — one node or a list of nodes'),
- children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Alternative content prop'),
isDefault: z.boolean().optional().describe('Whether this is the default page'),
assignedProfiles: z.array(z.string()).optional().describe('Profiles that can access this page'),
});
@@ -835,8 +856,9 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({
* ## Why one arm and not seven
*
* The factory in `semantic.tsx` builds ONE component over all seven tags: it
- * renders `renderChildren(schema.children || schema.body)` inside the tag and
- * declares exactly one authoring input, `className`. Seven arms would restate
+ * renders `renderChildren(schema.children)` inside the tag and declares exactly
+ * one authoring input, `className`. It read `schema.children || schema.body`
+ * until objectui#6771 retired the second spelling. Seven arms would restate
* the same shape seven times with no key to tell them apart.
*
* ## What is NOT declared here, and why
@@ -852,7 +874,7 @@ export const SemanticElementSchema = BaseSchema.extend({
type: z.enum(['aside', 'main', 'header', 'nav', 'footer', 'section', 'article'])
.describe('HTML sectioning tag — the seven `renderers/layout/semantic.tsx` registers'),
children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()
- .describe('Child components — read as `schema.children || schema.body` by the factory'),
+ .describe('Child components — read by the factory as `schema.children`; the `body` arm was retired by objectui#6771'),
});
/**
@@ -902,7 +924,7 @@ export const HtmlElementSchema = BaseSchema.extend({
'figure', 'figcaption', 'img', 'hr', 'br', 'time', 'address', 'cite', 'q',
]).describe('Safe HTML tag — the set `renderers/basic/html-elements.tsx` registers'),
children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()
- .describe('Child components — read as `schema.children ?? schema.body`; ignored for the void tags `img` / `hr` / `br`'),
+ .describe('Child components — read as `schema.children`, ignored for the void tags `img` / `hr` / `br`; the `body` arm was retired by objectui#6771'),
href: z.string().optional()
.describe('`a` link target; scheme-sanitised (`javascript:` / `data:` / `vbscript:` are dropped)'),
target: z.string().optional().describe('`a` browsing context — an internal link navigates through the SPA router unless this names another target'),
diff --git a/packages/types/src/zod/overlay.zod.ts b/packages/types/src/zod/overlay.zod.ts
index e70bde566c..9232caaec0 100644
--- a/packages/types/src/zod/overlay.zod.ts
+++ b/packages/types/src/zod/overlay.zod.ts
@@ -307,15 +307,19 @@ export const PopoverSchema = BaseSchema.extend({
/**
* Tooltip Schema - Tooltip component
*
- * ⚠️ This member used to REQUIRE `children` and declare neither `trigger` nor
- * `body` (objectui#6939). No read site has ever consumed `children` here: the
- * renderer reads `schema.trigger` (`renderers/overlay/tooltip.tsx:28`) and
- * `schema.content || renderChildren(schema.body)` (:31), and the registration's
- * own `inputs` list `trigger` / `content` / `body` and never `children`. So the
+ * ⚠️ This member used to REQUIRE `children` and declare neither `trigger` nor a
+ * rich-content slot (objectui#6939). The renderer reads `schema.trigger` and
+ * `schema.content || renderChildren(…)` (`renderers/overlay/tooltip.tsx`), and
+ * the registration's own `inputs` list `trigger` / `content` / that slot. So the
* validator refused documents the renderer draws and blessed a spelling that
* paints an empty trigger — `declared !== enforced`, with the corpus on the
* right side of it.
*
+ * ⚠️ The slot was spelled `body` from objectui#6939 until objectui#6771 retired
+ * the spelling. It is `children` now, so the refusal below faces the other way
+ * round from the one objectui#8284 first wrote — the RULE (tombstone the channel
+ * this renderer does not read) is what is preserved, not the key it named.
+ *
* `HoverCardSchema` two entries below is the settled in-repo shape for this
* pair of slots and is what `trigger` follows here.
*
@@ -334,20 +338,25 @@ export const TooltipSchema = BaseSchema.extend({
trigger: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()
.describe('Element the tooltip attaches to (objectui#6939)'),
content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()
- .describe('Tooltip content, checked before `body` — optional because `body` is the fallback for the same slot (objectui#6939)'),
- body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()
- .describe('Rich tooltip content — the fallback for `content`, listed by the registration as the "Rich Content" slot (objectui#6939)'),
+ .describe('Tooltip content, checked before `children` — optional because `children` is the fallback for the same slot (objectui#6939)'),
+ children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()
+ .describe('Rich tooltip content — the fallback for `content`, published by the registration as the "Rich Content" slot (objectui#6939, objectui#6771)'),
side: z.enum(['top', 'right', 'bottom', 'left']).optional().describe('Tooltip side'),
align: z.enum(['start', 'center', 'end']).optional().describe('Tooltip alignment'),
delayDuration: z.number().optional().describe('Delay before showing (ms)'),
- children: aliasKeyRefusal(
- 'children',
+ // AN INVERSION, not a widening. objectui#8284 tombstoned `children` here on
+ // the measured ground that the renderer read `content || body` and never
+ // `children`. objectui#6771 changed the READ, not the principle: the renderer
+ // now reads `content || children`, so the channel this node does not read is
+ // `body`, and that is where the tombstone moved.
+ body: aliasKeyRefusal(
'body',
+ 'children',
'this tooltip node',
- '`tooltip` reads `content` first and `body` as the fallback for that same slot, and never `children` '
+ '`tooltip` reads `content` first and `children` as the fallback for that same slot, and never `body` '
+ '(READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/overlay/tooltip.tsx`). '
- + '`children` is inherited from `BaseSchema`, so an authored `children` parsed green here and rendered '
- + 'an EMPTY element — no error, no warning. objectui#8284.',
+ + 'This registration was the one place in the tree that ADVERTISED `body` in its `inputs`, which is why '
+ + 'objectui#6771 brought it into the retirement rather than leaving it as the dialect\'s last discoverable home. objectui#8284.',
),
});
diff --git a/packages/vscode-extension/DESIGN.md b/packages/vscode-extension/DESIGN.md
index e8297f0856..6c2109ca9f 100644
--- a/packages/vscode-extension/DESIGN.md
+++ b/packages/vscode-extension/DESIGN.md
@@ -70,7 +70,7 @@ The Object UI VSCode extension is a comprehensive development tool designed to e
- **Component Types**: `div`, `card`, `button`, `input`, etc.
- **Properties**: 根据component type提供相关属性
-- **Common Properties**: `type`, `className`, `body`, etc.
+- **Common Properties**: `type`, `className`, `children`, etc.
- **Trigger Characters**: `"`, `:`, ` `
**实现特点**:
diff --git a/packages/vscode-extension/README.md b/packages/vscode-extension/README.md
index 855a2a0606..0731d1ef77 100644
--- a/packages/vscode-extension/README.md
+++ b/packages/vscode-extension/README.md
@@ -75,10 +75,10 @@ Create a file `app.objectui.json`:
{
"type": "div",
"className": "p-6",
- "body": {
+ "children": {
"type": "card",
"title": "Hello Object UI",
- "body": {
+ "children": {
"type": "text",
"content": "This is a schema-driven UI!"
}
diff --git a/packages/vscode-extension/schemas/objectui-schema.json b/packages/vscode-extension/schemas/objectui-schema.json
index 602526cc2e..ad1c504611 100644
--- a/packages/vscode-extension/schemas/objectui-schema.json
+++ b/packages/vscode-extension/schemas/objectui-schema.json
@@ -34,7 +34,7 @@
"type": "string",
"description": "Unique identifier for the component"
},
- "body": {
+ "children": {
"description": "Child components or content",
"oneOf": [
{
diff --git a/packages/vscode-extension/snippets/objectui.json b/packages/vscode-extension/snippets/objectui.json
index 7300e06131..34b745f1f2 100644
--- a/packages/vscode-extension/snippets/objectui.json
+++ b/packages/vscode-extension/snippets/objectui.json
@@ -5,7 +5,7 @@
"{",
" \"type\": \"${1:div}\",",
" \"className\": \"${2:p-4}\",",
- " \"body\": {",
+ " \"children\": {",
" $0",
" }",
"}"
@@ -18,13 +18,13 @@
"{",
" \"type\": \"div\",",
" \"className\": \"max-w-2xl mx-auto p-6\",",
- " \"body\": {",
+ " \"children\": {",
" \"type\": \"card\",",
" \"title\": \"${1:Form Title}\",",
- " \"body\": {",
+ " \"children\": {",
" \"type\": \"div\",",
" \"className\": \"space-y-4\",",
- " \"body\": [",
+ " \"children\": [",
" {",
" \"type\": \"input\",",
" \"label\": \"${2:Name}\",",
@@ -50,7 +50,7 @@
" \"title\": \"${1:Card Title}\",",
" \"description\": \"${2:Card description}\",",
" \"className\": \"${3:shadow-lg}\",",
- " \"body\": {",
+ " \"children\": {",
" $0",
" }",
"}"
@@ -110,7 +110,7 @@
"{",
" \"type\": \"div\",",
" \"className\": \"grid gap-${1:4} md:grid-cols-${2:2} lg:grid-cols-${3:3}\",",
- " \"body\": [",
+ " \"children\": [",
" $0",
" ]",
"}"
@@ -123,11 +123,11 @@
"{",
" \"type\": \"div\",",
" \"className\": \"min-h-screen p-6\",",
- " \"body\": [",
+ " \"children\": [",
" {",
" \"type\": \"div\",",
" \"className\": \"mb-6\",",
- " \"body\": {",
+ " \"children\": {",
" \"type\": \"text\",",
" \"content\": \"${1:Dashboard}\",",
" \"className\": \"text-3xl font-bold\"",
@@ -136,11 +136,11 @@
" {",
" \"type\": \"div\",",
" \"className\": \"grid gap-4 md:grid-cols-3\",",
- " \"body\": [",
+ " \"children\": [",
" {",
" \"type\": \"card\",",
" \"title\": \"${2:Metric 1}\",",
- " \"body\": {",
+ " \"children\": {",
" \"type\": \"text\",",
" \"content\": \"${3:1,234}\",",
" \"className\": \"text-2xl font-bold\"",
@@ -169,7 +169,7 @@
"{",
" \"type\": \"div\",",
" \"className\": \"${1|container mx-auto,max-w-2xl mx-auto,max-w-4xl mx-auto,max-w-6xl mx-auto|} ${2:p-6}\",",
- " \"body\": [",
+ " \"children\": [",
" $0",
" ]",
"}"
@@ -182,7 +182,7 @@
"{",
" \"type\": \"div\",",
" \"className\": \"flex ${1|flex-row,flex-col|} ${2|items-center,items-start,items-end|} ${3|justify-between,justify-center,justify-start,justify-end|} gap-${4:4}\",",
- " \"body\": [",
+ " \"children\": [",
" $0",
" ]",
"}"
diff --git a/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts b/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts
index 10bf22d5af..8a3f662cdd 100644
--- a/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts
+++ b/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts
@@ -10,11 +10,55 @@
* objectui#7181 — the two extension-host readers honour `children`, the
* spelling the rest of the platform declares.
*
+ * ⚠️ INVERTED BY objectui#6771, AND THE INVERSION IS THE POINT — read this before
+ * reading a red in this file as a regression.
+ *
+ * objectui#7181 added the `children` arm and deliberately KEPT `body`, so this
+ * file is TWO-SIDED: every case below pins the live spelling AND the retired one.
+ * objectui#6771's ruling then ordered the second arm dropped — verbatim, "the
+ * wider `children || body` readers drop the `body` arm in the same change" — and
+ * the two sources this file pins deferred that drop TO THAT CARD BY NUMBER, in
+ * their own words: `SchemaValidator.ts` "The `body` arm is deliberately KEPT:
+ * dropping it is objectui#6771 step 2, not this change", and `PreviewProvider.ts`
+ * "The body arm stays; removing it is objectui#6771 step 2, not this change."
+ * ⇒ the `body` legs were recording a PRE-RETIREMENT state, not guarding a live
+ * contract, so they are inverted rather than deleted.
+ *
+ * ⭐ AND THE REFUSAL MUST NOT GO QUIET — which took two goes to get right, so the
+ * correction is recorded rather than smoothed over.
+ *
+ * The first answer to that objection cited the BUILT CLI: same document both
+ * spellings, `body` exits 1 with `Unrecognized key(s) on this node: body. Did you
+ * mean body → children?` at `Path: body`, `children` exits 0. That reading is
+ * TRUE and it is about the WRONG TIER. It measures `@object-ui/types`' zod mirror
+ * through `@object-ui/cli`. ⛔ This file pins neither: the extension host imports
+ * no zod, builds no manifest, and its own JSON schema sets
+ * `additionalProperties: true` (`schemas/objectui-schema.json`). ⇒ dropping the
+ * `body` arm here, on its own, took a document that drew one diagnostic down to
+ * ZERO — a refusal genuinely going quiet, in the one tool whose job is teaching
+ * the format.
+ *
+ * So the arm did not just drop. `SchemaValidator` answers the retired key BY
+ * NAME, and the leg below is a POSITIVE assertion about that answer rather than
+ * an assertion that nothing happens. ⚠️ A measurement taken on one tier is not
+ * evidence about another, however true it is — that is the whole of the lesson
+ * here, and it is why the leg names the message it expects.
+ *
+ * ⛔ Every inverted leg keeps its ABSOLUTE numbers rather than comparing the two
+ * spellings to each other. ⚠️ That is NOT the same as "a reader that broke
+ * ENTIRELY satisfies neither half", which this header claimed until ablation
+ * falsified it. What the legs do and do not catch is set out under "What these
+ * pins actually catch" below, measured in both directions — and that reading,
+ * not the symmetry, is why the numbers are spelled out.
+ *
* ## The defect these pins were built for
*
* `children` is declared on four faces: the TypeScript declaration and the zod
* mirror in `@object-ui/types`, `validateSchema` in `@object-ui/core` (which
- * reads `schema.children || schema.body`, children FIRST), and the manifest
+ * READ `schema.children || schema.body`, children FIRST, until objectui#6771
+ * dropped that second arm — the past tense is load-bearing, because a reader
+ * replaying this sentence against `@object-ui/core` today finds one spelling),
+ * and the manifest
* tier's `BASE_PROPS` in `@object-ui/sdui-parser` (which carries `children` and
* does NOT carry `body`). Both readers in this package guarded their child-list
* recursion on `schema.body` ALONE.
@@ -52,11 +96,77 @@
* that the string `schema.children` appears would pass against a `children` arm
* that is present but unreachable, which is precisely the failure being pinned.
*
- * ## The `body` arm is expected to keep working
+ * ## The `body` arm is GONE — and each pin's second half now says so
+ *
+ * ⚠️ INVERTED, and the old text is quoted rather than dropped so the reversal is
+ * legible: this section read "the `body` arm is expected to keep working …
+ * Removing the `body` arm is objectui#6771 step 2, not this change". This IS
+ * that change. The sentence is therefore reversed, not deleted — a
+ * `body`-spelled document that renders or validates as it did before is now the
+ * regression, and these pins are what catch it.
+ *
+ * ## ⭐ What these pins actually catch — MEASURED, in both directions
+ *
+ * ⚠️ This section replaces three sentences that were WRONG. They said that each
+ * pin still asserts BOTH spellings, that the `body` leg carries zero beside its
+ * twin, and that a reader which broke entirely satisfies neither half. The first
+ * two are contradicted by this file's own contents; the third by ablation. ⛔ The
+ * pins were NOT widened to rescue the prose — the prose was re-derived from what
+ * the pins already do.
+ *
+ * ⭐ Not every pin is two-sided. Of the EIGHT behavioural pins, THREE assert both
+ * spellings — `card`, the generic container (`div`) and the bare-node arity.
+ * THREE assert `children` alone: nested descent, the validator's `children`
+ * recursion, and the well-formed-tree pin, whose `body` half objectui#6771
+ * DELETED as non-discriminating (its own comment says so). TWO assert `body`
+ * alone: the diagnostic-by-name pin and the `record:alert` carve-out.
+ *
+ * ⭐ Nor do the `body` legs carry the same number. `card`'s asserts ONE, not
+ * zero, because the title element survives when the child list does not. `div`'s
+ * and the bare node's assert zero. That difference turns out to be the whole of
+ * what follows.
+ *
+ * Two ablations on `PreviewProvider.ts`, each mutated ON DISK and restored to the
+ * HEAD blob:
*
- * Each pin asserts BOTH spellings. Removing the `body` arm is objectui#6771
- * step 2, not this change, so a `body`-spelled document rendering or validating
- * differently than before is a regression these pins must also catch.
+ * COLLAPSE — an early `return element;` after `document.createElement`, so
+ * nothing is appended under EITHER spelling. Every `children` leg reds, and so
+ * does `card`'s `body` leg, because 0 is not the 1 it asserts. ⚠️ But `div`'s
+ * and the bare node's `body` legs are SATISFIED: a reader that renders nothing
+ * produces exactly the zero they demand.
+ *
+ * RESURRECTION — `schema.children || schema.body` restored in both branches.
+ * Every `children` leg holds, and EXACTLY the three `body` legs red.
+ *
+ * ⇒ THE READING: a zero-valued `body` leg catches the retired arm COMING BACK,
+ * ⛔ not the reader falling over. What catches a collapse is the `children` leg
+ * beside it — which is why the pair is kept together even though the two halves
+ * answer different questions. ⭐ And `card`'s `body` leg, alone among the three,
+ * reds in BOTH directions, because it asserts a NON-ZERO absolute. Where the
+ * shape affords a non-zero number, that is the leg worth having.
+ *
+ * ⭐ For the validator the `body` leg is POSITIVE rather than silent — it asserts
+ * the diagnostic that NAMES `children` — because dropping the arm without that
+ * assertion took this host from one diagnostic to zero, the correction recorded
+ * at the top of this header. That too is measured, on `SchemaValidator.ts`:
+ *
+ * SILENCE — the retirement push gated off. The diagnostic-by-name pin reds,
+ * AND so does the carve-out pin's twin leg, which exists to prove the carve-out
+ * is a carve-out rather than a validator that stopped working.
+ *
+ * RESURRECTION — the recursion given its `body` arm back. The "does not descend
+ * it" leg reds, on the skipped child's own diagnostic surfacing.
+ *
+ * ⇒ the validator's two `body` legs DO catch the refusal going quiet, which the
+ * preview's zero-valued legs cannot do for the preview. ⛔ Do not carry either
+ * reading over to the other reader: they were measured separately and they
+ * differ.
+ *
+ * ⛔ One exception, and it has its own leg: a node type that declares its own
+ * `body` input is not writing the retired spelling. `record:alert` is the single
+ * such registration in the tree, it is carved out of the diagnostic, and the leg
+ * that pins the carve-out asserts both points — `record:alert` silent, `card`
+ * still warned.
*/
import { readFileSync } from 'node:fs';
@@ -149,11 +259,11 @@ function loadShippedPreviewRenderer(): RendererFn {
)(document) as RendererFn;
}
-describe('objectui#7181 — the VS Code preview renders `children`, not only `body`', () => {
+describe('the VS Code preview renders `children` — and, since objectui#6771, only `children`', () => {
const render = loadShippedPreviewRenderer();
const child = { type: 'text', content: 'Hello' };
- it('renders a `card` node the same whichever child-list spelling it uses', () => {
+ it('renders a `card` node\'s `children`, and draws nothing for the retired `body`', () => {
const viaBody = countDescendants(
render({ type: 'card', title: 'T', body: [child] })
);
@@ -161,20 +271,35 @@ describe('objectui#7181 — the VS Code preview renders `children`, not only `bo
render({ type: 'card', title: 'T', children: [child] })
);
- // The title element plus the one rendered child. Asserted absolutely, so a
- // renderer that dropped BOTH spellings could not satisfy this by symmetry.
- expect(viaBody).toBe(2);
+ // Both halves ABSOLUTE, exactly as before the inversion: the title element
+ // plus the one rendered child under the live spelling, and the title ALONE
+ // under the retired one. A renderer that dropped both could satisfy neither,
+ // which is why the numbers are spelled out rather than compared.
+ //
+ // ⭐ AND THIS PIN IS THE EXCEPTION — measured, see "What these pins actually
+ // catch". Because the title survives, this `body` leg asserts ONE rather than
+ // zero, so it reds under BOTH ablations: the collapse (0, not 1) and the
+ // resurrection (2, not 1). ⛔ The sibling `div` and bare-node `body` legs do
+ // NOT share that reach — their zero is satisfied by a reader that renders
+ // nothing — so this sentence is about THIS pin and ⛔ does not generalise.
expect(viaChildren).toBe(2);
+ expect(viaBody).toBe(1);
});
- it('renders a generic container node the same whichever spelling it uses', () => {
+ it('renders a generic container node\'s `children`, and nothing for the retired `body`', () => {
const viaBody = countDescendants(render({ type: 'div', body: [child] }));
const viaChildren = countDescendants(
render({ type: 'div', children: [child] })
);
- expect(viaBody).toBe(1);
+ // ⚠️ The reach of these two legs is NOT the same, and the difference is
+ // measured. `viaChildren` is what catches a renderer that stopped appending:
+ // under the collapse ablation it reds. `viaBody` does NOT — a renderer that
+ // renders nothing produces the 0 asserted here and the leg passes. What it
+ // catches is the retired arm being RESURRECTED, which reds it at 1. ⛔ Read
+ // it as a collapse guard and you are reading a guard that is not there.
expect(viaChildren).toBe(1);
+ expect(viaBody).toBe(0);
});
it('renders nested `children` all the way down', () => {
@@ -186,9 +311,15 @@ describe('objectui#7181 — the VS Code preview renders `children`, not only `bo
expect(countDescendants(render(tree))).toBe(3);
});
- it('still honours a single non-array child node under either spelling', () => {
- expect(countDescendants(render({ type: 'div', body: child }))).toBe(1);
+ it('still honours a single non-array child node — under the one spelling left', () => {
+ // The bare-node ARITY is what this case is about and it is untouched by the
+ // retirement; only which key carries it moved.
+ //
+ // ⚠️ Same asymmetry as the `div` pin above, same measurement: the `children`
+ // leg catches a collapse, the `body` leg catches a resurrection and ⛔ not a
+ // collapse — its 0 is satisfied by a reader that renders nothing at all.
expect(countDescendants(render({ type: 'div', children: child }))).toBe(1);
+ expect(countDescendants(render({ type: 'div', body: child }))).toBe(0);
});
});
@@ -280,7 +411,7 @@ function validateWithShippedValidator(schema: unknown): CapturedDiagnostic[] {
return published;
}
-describe('objectui#7181 — the VS Code validator recurses into `children`, not only `body`', () => {
+describe('the VS Code validator recurses into `children` — and, since objectui#6771, only `children`', () => {
// A child missing its required `type` is the observable: the validator warns
// about it ONLY if the recursion actually reached that child.
const untypedChild = { className: 'p-4' };
@@ -296,25 +427,120 @@ describe('objectui#7181 — the VS Code validator recurses into `children`, not
);
});
- it('still reaches a child spelled `body` and reports its missing `type`', () => {
+ it('ANSWERS a child list spelled `body` by name, and does not descend it', () => {
+ // ⛔ INVERTED, NOT DELETED, and ⛔ not weakened into a non-assertion. Both
+ // halves are positive: the retired key draws a diagnostic that NAMES the
+ // replacement, and the recursion does not walk into it (so the child's own
+ // problem is not reported at an address the document does not contain).
+ //
+ // ⚠️ The first half is the one that matters, and it is here because dropping
+ // the arm WITHOUT it took this host from one diagnostic to zero — see the
+ // header. A `not.toContain` alone would have been satisfied by exactly that
+ // silence.
const diagnostics = validateWithShippedValidator({
type: 'card',
body: [untypedChild],
});
- expect(diagnostics.map((d) => d.message)).toContain(
+ expect(
+ diagnostics.some(
+ (d) => d.message.includes('"body"') && d.message.includes('"children"')
+ ),
+ 'the retired spelling drew no diagnostic naming its replacement'
+ ).toBe(true);
+ expect(diagnostics.map((d) => d.message)).not.toContain(
'Missing required property "type"'
);
});
- it('reports nothing for a well-formed tree under either spelling', () => {
+ it('does NOT warn on a node type that declares its own `body` input', () => {
+ // ⭐ THE PIN THE CARVE-OUT DID NOT HAVE. `TYPES_DECLARING_OWN_BODY` was added
+ // to close a false positive, published in the changeset as behaviour of a
+ // released package, and then nothing could fail on it: emptying the set to
+ // `new Set([])` left this file passing every leg. A behaviour a test cannot
+ // break is a behaviour nobody is holding.
+ //
+ // `record:alert` is the real subject — the one registration in the tree that
+ // declares an input named `body`, whose value is an inline translation map
+ // rather than a child list. Warning about it would be the false diagnostic
+ // objectui#6771 exists to remove, reintroduced one host over.
+ const carvedOut = validateWithShippedValidator({
+ type: 'record:alert',
+ body: 'Updated the deal stage to Negotiation.',
+ });
+ expect(
+ carvedOut.some((d) => d.message.includes('"body"')),
+ '`record:alert` drew a retirement warning for its own declared `body`'
+ ).toBe(false);
+
+ // THE TWIN, and it is what makes the line above a reading rather than a
+ // validator that stopped working: the same key on a type that does NOT
+ // declare it still draws the warning, naming the replacement.
+ const notCarvedOut = validateWithShippedValidator({
+ type: 'card',
+ body: 'Updated the deal stage to Negotiation.',
+ });
+ expect(
+ notCarvedOut.some(
+ (d) => d.message.includes('"body"') && d.message.includes('"children"')
+ ),
+ 'the retirement warning stopped firing for types outside the carve-out'
+ ).toBe(true);
+ });
+
+ it('reports nothing for a well-formed tree under the live spelling', () => {
+ // The `body` half of this pair was never discriminating — a well-formed tree
+ // draws no diagnostic whether the recursion walks it or skips it — so it is
+ // dropped rather than inverted into an assertion that cannot fail.
const typedChild = { type: 'text', content: 'Hello' };
expect(
validateWithShippedValidator({ type: 'card', children: [typedChild] })
).toHaveLength(0);
- expect(
- validateWithShippedValidator({ type: 'card', body: [typedChild] })
- ).toHaveLength(0);
+ });
+});
+
+/* ------------------------------------------------------------------ *
+ * The two spellings of one constant *
+ * ------------------------------------------------------------------ */
+
+/**
+ * `SchemaValidator.ts` says of its local `RETIRED_CHILD_LIST_KEY` that this file
+ * "pins the two together" with `sdui-parser`'s export. That sentence was written
+ * before the pin existed — a claim about a check that was not there, which is the
+ * class this card keeps meeting. This block is the check.
+ *
+ * ⛔ Read off SOURCE TEXT, not by importing: this package ships to the extension
+ * host with no `@object-ui/*` runtime dependency, and importing `sdui-parser`
+ * here to satisfy a pin would put a dependency in the tree that the product does
+ * not have. Both literals are read from disk instead, rooted at THIS FILE the way
+ * `check-test-path-roots.mjs` requires.
+ */
+describe('the two spellings of the retired key cannot drift (objectui#6771)', () => {
+ const literalOf = (source: string, constName: string): string => {
+ const m = new RegExp(`${constName}\\s*(?::[^=]*)?=\\s*'([^']+)'`).exec(source);
+ expect(m, `\`${constName}\` is not declared as a single-quoted literal any more`).toBeTruthy();
+ return m![1];
+ };
+
+ it('the extension and `sdui-parser` name the same key', () => {
+ const parserSource = readFileSync(
+ resolve(HERE, '../../../sdui-parser/src/body-dialect.ts'),
+ 'utf8'
+ );
+
+ const extension = literalOf(VALIDATOR_SOURCE, 'const RETIRED_CHILD_LIST_KEY');
+ const parser = literalOf(parserSource, 'export const RETIRED_CHILD_LIST_KEY');
+
+ // Asserted ABSOLUTELY on both sides, not just as equal to each other: two
+ // constants that drifted to the same WRONG value would satisfy equality.
+ expect(extension).toBe('body');
+ expect(parser).toBe('body');
+ expect(extension).toBe(parser);
+ });
+
+ it('the diagnostic the extension pushes names the replacement', () => {
+ // The constant is only half of it — the message is what an author reads.
+ expect(VALIDATOR_SOURCE).toContain('"children"');
});
});
diff --git a/packages/vscode-extension/src/providers/CompletionProvider.ts b/packages/vscode-extension/src/providers/CompletionProvider.ts
index 9052ee0cbc..8055749626 100644
--- a/packages/vscode-extension/src/providers/CompletionProvider.ts
+++ b/packages/vscode-extension/src/providers/CompletionProvider.ts
@@ -105,7 +105,7 @@ export class CompletionProvider
card: [
{ name: 'title', desc: 'Card title' },
{ name: 'description', desc: 'Card description' },
- { name: 'body', desc: 'Card content' },
+ { name: 'children', desc: 'Card content' },
],
text: [{ name: 'content', desc: 'Text content' }],
};
@@ -130,7 +130,7 @@ export class CompletionProvider
const common = [
{ name: 'type', desc: 'Component type' },
{ name: 'className', desc: 'Tailwind CSS classes' },
- { name: 'body', desc: 'Child components or content' },
+ { name: 'children', desc: 'Child components or content' },
{ name: 'id', desc: 'Component ID' },
{ name: 'visible', desc: 'Visibility condition' },
];
@@ -142,8 +142,8 @@ export class CompletionProvider
);
item.detail = prop.desc;
- if (prop.name === 'body') {
- item.insertText = new vscode.SnippetString('"body": {\n $0\n}');
+ if (prop.name === 'children') {
+ item.insertText = new vscode.SnippetString('"children": {\n $0\n}');
} else if (prop.name === 'className') {
item.insertText = new vscode.SnippetString('"className": "$1"');
} else {
diff --git a/packages/vscode-extension/src/providers/HoverProvider.ts b/packages/vscode-extension/src/providers/HoverProvider.ts
index 96d98ead3d..c095c9e5d2 100644
--- a/packages/vscode-extension/src/providers/HoverProvider.ts
+++ b/packages/vscode-extension/src/providers/HoverProvider.ts
@@ -69,7 +69,7 @@ Example:
[Tailwind CSS Documentation](https://tailwindcss.com)`,
- body: `**body** (object | array)
+ children: `**children** (object | array)
Child components or content.
@@ -80,7 +80,7 @@ Can be:
Example:
\`\`\`json
-"body": [
+"children": [
{ "type": "text", "content": "Hello" },
{ "type": "button", "label": "Click me" }
]
@@ -182,7 +182,7 @@ Example:
{
"type": "div",
"className": "flex gap-4 p-6",
- "body": [...]
+ "children": [...]
}
\`\`\``,
@@ -196,7 +196,7 @@ Example:
"type": "card",
"title": "Card Title",
"description": "Card description",
- "body": {...}
+ "children": {...}
}
\`\`\``,
diff --git a/packages/vscode-extension/src/providers/PreviewProvider.ts b/packages/vscode-extension/src/providers/PreviewProvider.ts
index befde17787..1de2da0fc1 100644
--- a/packages/vscode-extension/src/providers/PreviewProvider.ts
+++ b/packages/vscode-extension/src/providers/PreviewProvider.ts
@@ -223,8 +223,9 @@ export class PreviewProvider {
desc.textContent = schema.description;
element.appendChild(desc);
}
- // children first, body kept -- see the container branch below.
- const cardChildren = schema.children || schema.body;
+ // children is the one child-list spelling -- see the container
+ // branch below.
+ const cardChildren = schema.children;
if (cardChildren) {
const body = Array.isArray(cardChildren) ? cardChildren : [cardChildren];
body.forEach(child => {
@@ -240,18 +241,18 @@ export class PreviewProvider {
default:
// Handle generic div/container.
//
- // children is read FIRST, body is kept as a second arm. This guard
- // used to name schema.body alone, so a node spelling its child list
- // children -- the spelling the TypeScript declaration, the zod
- // mirror, core's validator and the manifest tier all bless --
- // rendered as an EMPTY element: no error, no diagnostic, just a
- // blank preview (objectui#7181). The body arm stays; removing it is
- // objectui#6771 step 2, not this change.
+ // children is the one child-list spelling. This guard used to name
+ // schema.body alone, so a node spelling its child list children --
+ // the spelling the TypeScript declaration, the zod mirror, core's
+ // validator and the manifest tier all bless -- rendered as an EMPTY
+ // element: no error, no diagnostic, just a blank preview
+ // (objectui#7181). objectui#6771 retired body, so the second arm is
+ // gone and the preview shows what the runtime will show.
//
// NOTE: this comment carries no backticks on purpose. It lives
// INSIDE the webview's template literal, where an unescaped backtick
// ends the string and the file stops parsing.
- const containerChildren = schema.children || schema.body;
+ const containerChildren = schema.children;
if (containerChildren) {
const body = Array.isArray(containerChildren) ? containerChildren : [containerChildren];
body.forEach(child => {
diff --git a/packages/vscode-extension/src/providers/SchemaValidator.ts b/packages/vscode-extension/src/providers/SchemaValidator.ts
index 9fc641b8c4..73e2be4763 100644
--- a/packages/vscode-extension/src/providers/SchemaValidator.ts
+++ b/packages/vscode-extension/src/providers/SchemaValidator.ts
@@ -8,6 +8,41 @@
import * as vscode from 'vscode';
+/**
+ * The child-list spelling objectui#6771 retired. Spelled here rather than
+ * imported: this package ships to the extension host with no `@object-ui/*`
+ * runtime dependency, which is the same reason it has no zod tier to lean on.
+ * `sdui-parser` exports the same constant as `RETIRED_CHILD_LIST_KEY`, and
+ * `__tests__/body-dialect-children-arm-7181.test.ts` pins the two together by
+ * reading both literals off disk — see its `the two spellings cannot drift`
+ * case. ⛔ Do not change this string without that test going red.
+ */
+const RETIRED_CHILD_LIST_KEY = 'body';
+
+/**
+ * Node types that declare `body` as their OWN input, where the key is not the
+ * retired child-list spelling and a retirement warning would be a FALSE
+ * POSITIVE.
+ *
+ * Measured, not guessed — grep the registrations under every package `src`
+ * directory for a declared input named `body`, excluding tests. ⛔ The glob is
+ * spelled in words on purpose: written literally it contains the two characters
+ * that END a block comment, which silently truncates this docblock and leaves the
+ * rest of the file as stray tokens — caught here by the pin that evaluates this
+ * very source. The only survivor after objectui#6771 moved
+ * `tooltip` and `page` to `children` is `record:alert`, whose `body` is a text
+ * field taking an inline translation map (`plugin-detail`, registered under the
+ * `record` namespace). This is the same carve-out the parser tier gets for free
+ * by asking inside its `!input` branch; this host has no manifest, so the set is
+ * spelled out.
+ *
+ * ⚠️ A SNAPSHOT WITH NO GATE BEHIND IT. Nothing re-derives this set, so a
+ * registration that starts declaring `body` will draw a false warning here until
+ * someone adds it. That is stated rather than left to be discovered, and it is
+ * the reason the set is kept to what was measured rather than widened on a guess.
+ */
+const TYPES_DECLARING_OWN_BODY: ReadonlySet = new Set(['record:alert']);
+
/**
* Validates Object UI schemas
*/
@@ -98,34 +133,59 @@ export class SchemaValidator {
this.validateTypeSpecificProps(schema, diagnostics, document, path);
}
- // Recursively validate children.
- //
- // `children` is read FIRST and `body` is kept as a second arm, matching
- // `schema.children || schema.body` in `@object-ui/core`'s `validateSchema`
- // and in the `div` / `card` renderers (objectui#7181).
+ // The retired `body` child-list spelling, answered BY NAME.
//
- // This guard used to name `schema.body` alone, which made the extension a
- // `body`-ONLY reader while the TypeScript declaration, the zod mirror,
- // core's validator and the manifest tier all declare `children`. An author
- // who wrote the declared spelling therefore had every child silently
- // skipped by this recursion — no diagnostic, no refusal, just an unvisited
- // subtree — while the preview rendered the same document blank.
+ // ⚠️ THIS DIAGNOSTIC EXISTS BECAUSE THIS HOST HAS NO OTHER TIER. Everywhere
+ // else, dropping the `body` arm moves the answer UP a level and makes it
+ // louder: `@object-ui/types`' zod mirror refuses the key by name, so
+ // `objectui validate` exits 1 naming `children`. The extension host imports
+ // no zod, builds no manifest, and its JSON schema sets
+ // `additionalProperties: true` — so without the push below, retiring the arm
+ // would take a document that USED to draw a diagnostic here down to ZERO,
+ // which is a refusal going quiet in the one tool whose job is teaching the
+ // format. ⛔ That is the opposite of what objectui#6771 is for.
+ const retired = schema[RETIRED_CHILD_LIST_KEY];
+ if (retired !== undefined && !TYPES_DECLARING_OWN_BODY.has(schema.type)) {
+ // The message does not over-describe the value, the same discipline
+ // `sdui-parser/src/body-dialect.ts` keeps: calling a scalar `body` a
+ // "child-list spelling" names a shape the author did not write.
+ const isChildList = typeof retired === 'object' && retired !== null;
+ diagnostics.push(
+ new vscode.Diagnostic(
+ this.findPropertyRange(document, path, RETIRED_CHILD_LIST_KEY),
+ isChildList
+ ? `"${RETIRED_CHILD_LIST_KEY}" is a retired child-list spelling — author "children" instead (objectui#6771)`
+ : `"${RETIRED_CHILD_LIST_KEY}" is a retired key — the child-list key is "children" (objectui#6771)`,
+ vscode.DiagnosticSeverity.Warning
+ )
+ );
+ }
+
+ // The push above is GATED on `TYPES_DECLARING_OWN_BODY` rather than firing
+ // on every node: a `record:alert` authored with its declared translation-map
+ // `body` is not writing the retired spelling, and warning about it would be
+ // the false diagnostic objectui#6771 exists to remove, reintroduced one host
+ // over. ⚠️ The set is a measured snapshot with nothing re-deriving it — see
+ // its own docblock.
+
+ // Recursively validate children.
//
- // The `body` arm is deliberately KEPT: dropping it is objectui#6771 step 2,
- // not this change, and every `body`-spelled document must keep working.
- const childList = schema.children || schema.body;
+ // `children` is the one child-list spelling. This guard once named
+ // `schema.body` ALONE, so an author writing the declared spelling had
+ // every child silently skipped by this recursion — no diagnostic, no
+ // refusal, just an unvisited subtree (objectui#7181 added the `children`
+ // arm). objectui#6771 then retired `body` itself, so the second arm went
+ // with it: a `body`-spelled document has no child list for this recursion
+ // to walk, and the push above is what keeps that from being silent.
+ const childList = schema.children;
if (childList) {
- // The diagnostic path has to name the key this document actually used,
- // or a `children`-spelled node reports its problems at a `.body[...]`
- // address that does not occur anywhere in the file being validated.
- const childKey = schema.children ? 'children' : 'body';
const children = Array.isArray(childList) ? childList : [childList];
children.forEach((child: any, index: number) => {
this.validateSchema(
child,
diagnostics,
document,
- `${path}.${childKey}[${index}]`
+ `${path}.children[${index}]`
);
});
}
diff --git a/packages/vscode-extension/syntaxes/objectui.tmLanguage.json b/packages/vscode-extension/syntaxes/objectui.tmLanguage.json
index 022e1cd8bd..000bb5fccf 100644
--- a/packages/vscode-extension/syntaxes/objectui.tmLanguage.json
+++ b/packages/vscode-extension/syntaxes/objectui.tmLanguage.json
@@ -5,7 +5,7 @@
"include": "source.json"
},
{
- "match": "\"(type|className|body|label|placeholder|content|title|description)\"",
+ "match": "\"(type|className|children|label|placeholder|content|title|description)\"",
"name": "keyword.other.objectui"
}
]
diff --git a/scripts/__tests__/body-dialect-census.test.ts b/scripts/__tests__/body-dialect-census.test.ts
index 13bff3ef3c..86d3c9dd7f 100644
--- a/scripts/__tests__/body-dialect-census.test.ts
+++ b/scripts/__tests__/body-dialect-census.test.ts
@@ -44,7 +44,7 @@ import {
RULED_BUT_NOT_A_READER,
BODY_ONLY_UNRULED,
} from '../body-dialect-census.mjs';
-import { scan, producersOf } from '../body-dialect-producer-scan.mjs';
+import { scan, producersOf, unchannelledOf } from '../body-dialect-producer-scan.mjs';
const REPO_ROOT = join(__dirname, '..', '..');
const read = (p: string) => readFileSync(join(REPO_ROOT, p), 'utf8');
@@ -131,9 +131,18 @@ describe('the scanner sees the shapes authored metadata actually uses', () => {
it('CONTROL — a corpus-shaped input returns hits, so a zero elsewhere is readable', () => {
// Without this, every "0" in the census is indistinguishable from a
// scanner that resolved nothing at all.
+ //
+ // ⚠️ RE-POINTED at `children` by objectui#6771. This file was the densest
+ // `body` corpus in the tree — 14 nodes — and the retirement migrated it, so
+ // a `body` control here now measures the migration instead of the scanner.
+ // The control's job is unchanged: prove the scanner resolves NODES and
+ // their child key on a real corpus file, so a zero read somewhere else is a
+ // reading rather than a blind walk.
const nodes = scanNodes(read('examples/schema-catalog/src/schemas/components-basic-sidebar/basic-sidebar.json'));
expect(nodes.length).toBeGreaterThan(0);
- expect(nodes.filter((n: { keys: Set }) => n.keys.has('body')).length).toBeGreaterThan(0);
+ expect(nodes.filter((n: { keys: Set }) => n.keys.has('children')).length).toBeGreaterThan(0);
+ // And the retired spelling is gone from it, which is the migration half.
+ expect(nodes.filter((n: { keys: Set }) => n.keys.has('body')).length).toBe(0);
});
});
@@ -155,14 +164,21 @@ describe('the measured population is read off the renderers, not off the card bo
expect(BODY_ONLY.filter((k) => k.startsWith('sidebar')).length).toBe(10);
});
- it('`badge` and `alert` read `body` and never `children`', () => {
+ it('`badge` and `alert` read `children` and never `body` — CONVERGED by objectui#6771', () => {
+ // ⚠️ INVERTED. This pin held the ground the whole census rested on: these
+ // two rendered `renderChildren(schema.body)` and never touched
+ // `schema.children`, which is what made `body` their ONLY door and made
+ // retiring it a reject-direction change on published contract. The ruling
+ // was executed; the door is `children` now, and the reading the census
+ // produced is what cleared it (`badge` and `alert` carried ZERO authored
+ // `body` in both populations, so the published cost measured zero).
for (const [file, type] of [
['packages/components/src/renderers/data-display/badge.tsx', 'badge'],
['packages/components/src/renderers/data-display/alert.tsx', 'alert'],
] as const) {
const src = read(file);
- expect(src, `${type} stopped reading schema.body`).toContain('renderChildren(schema.body)');
- expect(src, `${type} now reads schema.children — it is no longer body-only`).not.toContain('schema.children');
+ expect(src, `${type} does not read schema.children`).toContain('renderChildren(schema.children)');
+ expect(src, `${type} still reads the retired schema.body`).not.toContain('schema.body');
}
});
@@ -175,24 +191,32 @@ describe('the measured population is read off the renderers, not off the card bo
// 11 `sidebar-*` registrations. The one without is `sidebar-trigger`, whose
// renderer never receives `schema` at all.
expect(src.match(/ComponentRegistry\.register\('sidebar/g)?.length).toBe(11);
- expect(src.match(/renderChildren\(schema\.body\)/g)?.length).toBe(10);
+ // Spelled `children` since objectui#6771; the COUNT is the load-bearing
+ // half and it did not move — ten reads across eleven registrations.
+ expect(src.match(/renderChildren\(schema\.children\)/g)?.length).toBe(10);
+ expect(src.match(/renderChildren\(schema\.body\)/g)?.length ?? 0).toBe(0);
const trigger = src.slice(src.indexOf("register('sidebar-trigger'"));
expect(trigger).not.toContain('schema.body');
expect(trigger).not.toContain('schema.children');
});
- it('⚠️ `tooltip` is a `body`-only reader that the ruled 13 does NOT include', () => {
- // Measured, and it matters: `tooltip` renders `renderChildren(schema.body)`
- // and never `schema.children`, so direction B removes its only rich-content
- // key too — yet it is absent from the ruling's step 2 list. It is also the
- // ONE registration in the tree that DECLARES `body` as an authorable input,
- // which makes it the most discoverable spelling of `body` on the whole
- // authoring surface.
+ it('⚠️ `tooltip` was the `body`-only reader the ruled 13 omitted — and it converged with the rest', () => {
+ // ⚠️ INVERTED, and this one was a SCOPE call before it was an edit. The
+ // census measured `tooltip` rendering `renderChildren(schema.body)` and
+ // never `schema.children` — a `body`-only reader absent from the ruling's
+ // step 2 list — and, decisively, the ONE registration in the tree that
+ // DECLARED `body` as an authorable input, which made it the most
+ // discoverable spelling of the dialect on the whole authoring surface.
+ // Seat 1 read the ruling rather than filing for it (2026-09-17) and triage
+ // recorded the answer as already given: step 2's two clauses leave no third
+ // class, so `tooltip` is IN. It is, and its declared input moved too — the
+ // half that would otherwise have left the dialect advertised.
expect(BODY_ONLY_UNRULED).toEqual(['tooltip']);
const src = read('packages/components/src/renderers/overlay/tooltip.tsx');
- expect(src).toContain('renderChildren(schema.body)');
- expect(src).not.toContain('schema.children');
- expect(src).toMatch(/name:\s*'body'/);
+ expect(src).toContain('renderChildren(schema.children)');
+ expect(src).not.toContain('schema.body');
+ expect(src).toMatch(/name:\s*'children'/);
+ expect(src).not.toMatch(/name:\s*'body'/);
});
it('the two published keys are exactly `badge` and `alert`', () => {
@@ -223,7 +247,7 @@ describe('the census stays re-runnable', () => {
});
describe('the `body` consumers the ruling does not enumerate', () => {
- it('three generic readers outside the renderer tree still resolve `body`', () => {
+ it('three generic readers outside the renderer tree read `children` ALONE — step 2 reached them', () => {
// Recorded because ruling steps 2-5 name renderers, the published type and
// the tier — and these three are none of those. They read `body` for ANY
// node type, so they outlive the per-registration convergence and would
@@ -238,15 +262,26 @@ describe('the `body` consumers the ruling does not enumerate', () => {
// objectui#7181 converged both onto the core spelling, adding the
// `children` arm and KEEPING `body`. Nothing was retired; that is step 2.
//
- // ⇒ all three now read the same way, which is what these three assertions
- // pin. The `body` arm surviving in each is the half that says step 2 is
- // still outstanding.
+ // ⇒ all three then read the same way, and the `body` arm surviving in each
+ // was the half that said step 2 was still outstanding.
+ //
+ // ⚠️ INVERTED — step 2 landed (objectui#6771). These three are the reason
+ // the block exists: a per-registration convergence would have left them
+ // resolving `body` for ANY node type and the dialect alive underneath it,
+ // so the retirement is only real if all three dropped the arm. Both vscode
+ // readers even deferred it to that card BY NUMBER in their own comments.
+ // The lit control stays FIRST, because "does not contain `body`" holds just
+ // as well over a file that was renamed, emptied or moved.
for (const reader of [
'packages/core/src/validation/schema-validator.ts',
'packages/vscode-extension/src/providers/SchemaValidator.ts',
'packages/vscode-extension/src/providers/PreviewProvider.ts',
]) {
- expect(readCode(reader), reader).toContain('schema.children || schema.body');
+ const text = readCode(reader);
+ expect(text, `${reader} — lit control: this reader still resolves a child list`)
+ .toMatch(/=\s*schema\.children;/);
+ expect(text, `${reader} still resolves the retired \`body\``)
+ .not.toContain('schema.children || schema.body');
}
});
@@ -311,11 +346,17 @@ describe('the `body` consumers the ruling does not enumerate', () => {
// ⛔ That zero is NOT the answer to "is step 4 landable", and reading it as
// one is the trap this half exists to close. The census states its own
- // limits and TWO of them hide a producer: it scores a child list only on an
- // object that also carries a string-LITERAL `type` (a `tabs` ITEM carries
+ // limits and TWO of them CAN hide a producer: it scores a child list only on
+ // an object that also carries a string-LITERAL `type` (a `tabs` ITEM carries
// `label`/`value`/`body` and no `type`), and it never reads inside a string
// or a template literal (a VS Code completion snippet is a string).
//
+ // ⚠️ PAST-TENSED where objectui#9871 wrote that both still DO. Only the
+ // first still hides a live producer. The second's only subjects were the
+ // `CompletionProvider` snippets, and objectui#6771 step 5 migrated them —
+ // which is the B2 inversion below, measured rather than assumed. The limit
+ // itself is unchanged and still structural; what emptied is its occupancy.
+ //
// ⭐ objectui#9871 REPLACED the enumeration that used to stand here. Two
// named sites were an enumeration with the census's blind spots written
// into it by hand — the next producer in a third blind direction would have
@@ -324,8 +365,17 @@ describe('the `body` consumers the ruling does not enumerate', () => {
// · C2 resolution · C3 carrier) and this block reads its table.
//
// ⭐ When the table empties, THAT is the handoff, and the message on the
- // assertion says so in these terms — it is the day step 4 becomes landable.
- // ⛔ Do not delete the claim to make the block green.
+ // assertion says so in these terms. ⚠️ CORRECTED where objectui#9871 wrote
+ // "the day step 4 becomes landable": step 4 lands in objectui#6771 with this
+ // table NON-empty, and measurement is why. The tier refuses `body` inside
+ // `Object.entries(node)` on a node whose `type` resolves to a registration;
+ // every site left in the table is carried on a `tabs` ITEM, which is the
+ // value of a declared `items` input and never a node in that walk. Probed
+ // rather than reasoned, both legs, against the built parser: a `card` node
+ // carrying `body` draws `unknown-prop: has no prop "body" — the
+ // child-list key is "children"`, and a `tabs` item carrying the same key
+ // draws ZERO diagnostics. ⇒ an empty table is objectui#9590's finish line,
+ // ⛔ not step 4's gate. ⛔ Do not delete the claim to make the block green.
const producerScan = scan(REPO_ROOT);
// Two lit controls, because an empty table is the assertion's own shape: a
@@ -342,7 +392,9 @@ describe('the `body` consumers the ruling does not enumerate', () => {
expect(
producers.length,
'the producer table is EMPTY — under objectui#9871\'s criterion nothing in shipped source ' +
- 'emits the dialect any more, which is the day objectui#6771 step 4 becomes landable. ' +
+ 'emits the dialect any more. ⚠️ That is objectui#9590\'s finish line, ⛔ NOT step 4\'s gate: ' +
+ 'step 4 landed in objectui#6771 with this table non-empty, because everything left in it ' +
+ 'is item-carried and outside the ruled family. ' +
'Re-point this block and say so in those terms; ⛔ do not delete the claim.'
).toBeGreaterThan(0);
@@ -353,15 +405,85 @@ describe('the `body` consumers the ruling does not enumerate', () => {
producers.filter((hit: { carrier: string }) => hit.carrier === 'item').length,
'no item-carried producer — the B1 shape (a `body` on an object with no `type`)'
).toBeGreaterThan(0);
+ // ⚠️ B2 IS INVERTED BY THIS CARD, and the inversion is what the merge with
+ // `main` actually found rather than a tidy-up. objectui#9871 asserted a
+ // string-carried producer EXISTED, and its only subjects were the three VS
+ // Code completion snippets in `CompletionProvider.ts` — which objectui#6771
+ // step 5 migrated. The two changes were written without knowledge of each
+ // other and landed on DISJOINT files, so the text merged clean and this
+ // assertion is the one place they actually meet. ⛔ Not deleted, per
+ // objectui#9871's own instruction: re-pointed, with the reason named.
+ //
+ // ⭐ THE ZERO NEEDS A LIT CONTROL OR IT IS A BLIND WALK, and the control is
+ // in TWO parts, because `source !== 'code-key'` is a conjunction of two
+ // independent capabilities and a single control would leave one untested:
+ // - B2 reads inside literals AT ALL — `scanSource`'s literal projection
+ // still resolves the key in strings and templates somewhere in the tree;
+ // - a literal-carried hit still PASSES C2 — some derived reader resolves
+ // it into a child list, so the zero below is about C1 EMISSION alone.
+ // Without both, `toBe(0)` would hold just as well over a scan that had gone
+ // blind to literals — the exact failure objectui#9871 built B2 to end.
+ const literalCarried = producerScan.hits.filter(
+ (hit: { source: string }) => hit.source !== 'code-key'
+ );
expect(
- producers.filter((hit: { source: string }) => hit.source !== 'code-key').length,
- 'no string-carried producer — the B2 shape (the key spelled inside a literal)'
+ literalCarried.length,
+ 'lit control — the scan resolved NO literal-carried `body` anywhere, so B2 has gone blind ' +
+ 'and the zero below would be measuring the instrument rather than the tree'
).toBeGreaterThan(0);
+ expect(
+ unchannelledOf(producerScan.hits, producerScan.readers).filter(
+ (hit: { source: string }) => hit.source !== 'code-key'
+ ).length,
+ 'lit control — no literal-carried hit passes C2 any more, so the zero below would be ' +
+ 'measuring C2 rather than the emission channel it claims to measure'
+ ).toBeGreaterThan(0);
+
+ // ⇒ AND THE READING ITSELF, with both controls lit: no literal-carried site
+ // reaches an emission channel. The B2 shape is REACHABLE and UNOCCUPIED —
+ // which is a different sentence from the one objectui#9871 could write, and
+ // only this merge could have produced it.
+ expect(
+ producers.filter((hit: { source: string }) => hit.source !== 'code-key').length,
+ 'a string-carried producer is BACK — objectui#6771 step 5 migrated the last of them ' +
+ '(the `CompletionProvider` snippets). A new one means a tool started writing the retired ' +
+ 'spelling into an author document again; ⛔ do not relax this to a range.'
+ ).toBe(0);
+
+ // ⭐ THE SUBJECT THAT ZERO IS ABOUT, pinned BY NAME — because a shape
+ // assertion cannot say WHICH site left, and naming it is the handoff
+ // objectui#9871's prose asks for. A completion snippet is the most direct
+ // form a producer takes: it is inserted into the author's own document the
+ // moment the completion is accepted, so the author never types the spelling.
+ // The lit control stays and stays FIRST: "does not contain" is satisfied by
+ // a file that was renamed, emptied or moved.
+ const completion = readCode('packages/vscode-extension/src/providers/CompletionProvider.ts');
+ expect(completion, 'lit control — the snippet table is being read at all').toContain(
+ '"className": "$1"'
+ );
+ expect(completion).not.toContain('"body": {');
+ expect(completion).toContain('"children": {');
// ⚠️ And the family question stays OPEN in the instrument rather than being
// settled by it: an item-carried `body` is filed `unruled` and is never
// folded into objectui#6771's ruled total. objectui#9871 handed that
// question back rather than extending a ruled family from a dev seat.
+ //
+ // ⭐ AND THE CLAIM IN THIS TEST'S NAME, restated in the terms BOTH
+ // instruments now agree on — which is the other half of what this merge
+ // found. What still ships the spelling is the `tabs` ITEM, and that is a
+ // DIFFERENT KEY from the one step 4 refuses (measured above the scan call,
+ // both legs). objectui#9871 reaches the same disposition from its own side
+ // and files those sites `unruled:item-carrier` — ⛔ not objectui#6771's ruled
+ // family. ⇒ the consumer-side retirement and the producer-side scan do not
+ // contradict each other on the substance; they disagreed on ONE liveness
+ // assertion, B2's, and that is re-pointed above rather than deleted.
+ // ⇒ step 4 refuses nothing the platform still ships, which is exactly what
+ // step 5's ordering rule asks, and the item-level dialect is objectui#9590's
+ // card, ⛔ neither refused nor migrated here. ⚠️ That card's BODY names only
+ // `list`'s `items[].body`; `tabs` items and the `dashboard` widget key are
+ // recorded on it by comment 5733850974, so this pointer resolves to a record
+ // that actually carries the two shapes named here.
for (const hit of producers.filter((h: { carrier: string }) => h.carrier === 'item')) {
expect(hit.disposition).toBe('unruled:item-carrier');
}
diff --git a/scripts/__tests__/body-dialect-producer-scan.test.ts b/scripts/__tests__/body-dialect-producer-scan.test.ts
index bc4ebea62c..00ed54e894 100644
--- a/scripts/__tests__/body-dialect-producer-scan.test.ts
+++ b/scripts/__tests__/body-dialect-producer-scan.test.ts
@@ -346,8 +346,10 @@ export const ListSchema = z.object({}).describe(
expect(verdict.refusal?.join(' ')).toContain('Zero readers derived');
// Lit control, and the point of the whole card: a real ZERO over a real
- // corpus with a real reader is a LEGITIMATE reading and is NOT refused —
- // it is the day objectui#6771 step 4 becomes landable.
+ // corpus with a real reader is a LEGITIMATE reading and is NOT refused.
+ // ⚠️ It is NOT "the day objectui#6771 step 4 becomes landable", as first
+ // written — step 4 landed with the live table non-empty and item-carried.
+ // A zero is objectui#9590's finish line; see the LIVE-tree block below.
const clean = scan(plant({}));
expect(producersOf(clean.hits, clean.readers)).toEqual([]);
expect(finalVerdict({ filesScanned: clean.filesScanned, readers: clean.readers }).exit).toBe(0);
@@ -552,17 +554,61 @@ describe('the LIVE tree, read through the criterion', () => {
// producer arriving in a file nobody named appears in `producers` without
// anyone extending anything, which is the whole point of this card.
expect(producers.filter((p: { carrier: string }) => p.carrier === 'item').length).toBeGreaterThan(0);
- expect(producers.filter((p: { source: string }) => p.source !== 'code-key').length).toBeGreaterThan(0);
expect(files.size).toBeGreaterThan(0);
- // ⭐ And the claim that outlives every one of them: while the table is
- // non-empty, objectui#6771 step 4 is not landable. ⛔ When this reds
- // because the table emptied, that is the HANDOFF — say so here in those
- // terms rather than deleting the assertion.
+ // ⚠️ B2 INVERTED BY objectui#6771, and this is the SECOND copy of the claim
+ // — the census block carries the first. ⭐ Neither file was in the merge's
+ // text intersection with this branch, which is the lesson: a one-file
+ // overlap said the text merged, and the two scans still met HERE.
+ // objectui#9871 asserted a string-carried producer EXISTED; its only
+ // subjects were the three `CompletionProvider` snippets, which step 5
+ // migrated. ⛔ Not deleted, per this file's own instruction: re-pointed.
+ //
+ // ⭐ The zero carries the same two lit controls as the census copy, because
+ // `source !== 'code-key'` is a conjunction and one control would leave half
+ // of it untested: B2 still reads inside literals AT ALL, and a
+ // literal-carried hit still PASSES C2. Without both, `toBe(0)` would hold
+ // over a scan gone blind to literals — the failure B2 exists to end.
+ expect(
+ run.hits.filter((h: { source: string }) => h.source !== 'code-key').length,
+ 'lit control — no literal-carried `body` resolved anywhere, so B2 has gone blind',
+ ).toBeGreaterThan(0);
+ expect(
+ unchannelledOf(run.hits, run.readers).filter(
+ (h: { source: string }) => h.source !== 'code-key',
+ ).length,
+ 'lit control — no literal-carried hit passes C2, so the zero below would measure C2 ' +
+ 'rather than the emission channel it claims to measure',
+ ).toBeGreaterThan(0);
+ expect(
+ producers.filter((p: { source: string }) => p.source !== 'code-key').length,
+ 'a string-carried producer is BACK — objectui#6771 step 5 migrated the last of them. ' +
+ 'A new one means a tool started writing the retired spelling into an author document ' +
+ 'again; ⛔ do not relax this to a range.',
+ ).toBe(0);
+
+ // ⭐ And the claim that outlives every one of them — ⚠️ CORRECTED, because
+ // objectui#9871 wrote it as "while the table is non-empty, objectui#6771
+ // step 4 is not landable" and that blanket does not survive measurement.
+ // This file files every surviving producer `unruled:item-carrier` and says
+ // in as many words that whether an item-carried `body` belongs to
+ // objectui#6771 is UNRULED — so using the same rows to block that card's
+ // step 4 reads the question back as an answer.
+ //
+ // Probed rather than reasoned, both legs, against the built parser: the
+ // tier refuses `body` inside `Object.entries(node)` on a node whose `type`
+ // resolves to a registration, so a `card` NODE carrying `body` draws
+ // `unknown-prop: has no prop "body" — the child-list key is
+ // "children"`, and a `tabs` ITEM carrying the same key draws ZERO
+ // diagnostics. ⇒ step 4 refuses nothing the platform still ships.
+ // An empty table is objectui#9590's finish line, ⛔ not step 4's gate.
+ // ⛔ When this reds because the table emptied, that is still the HANDOFF —
+ // say so here in those terms rather than deleting the assertion.
expect(
producers.length,
- 'the producer table is EMPTY — under this criterion nothing ships the dialect, which is ' +
- 'the day objectui#6771 step 4 becomes landable. Re-point this block, do not delete it.',
+ 'the producer table is EMPTY — under this criterion nothing ships the dialect. That is ' +
+ 'objectui#9590\'s finish line, ⛔ NOT objectui#6771 step 4\'s gate: step 4 landed with ' +
+ 'this table non-empty and item-carried. Re-point this block, do not delete it.',
).toBeGreaterThan(0);
// ⏱ Explicit — one tree-wide scan over 5,136 files, measured 4.0s here; the
// CI shard is at least 1.9x slower (objectui#9871's timeout reading), and
diff --git a/scripts/body-dialect-census.mjs b/scripts/body-dialect-census.mjs
index 4a4468557d..32392eaa4b 100644
--- a/scripts/body-dialect-census.mjs
+++ b/scripts/body-dialect-census.mjs
@@ -82,26 +82,45 @@ import { isEntrypoint } from './invoked-as.mjs';
// ── The measured population ────────────────────────────────────────────────
//
-// BODY_ONLY: registrations whose renderer reads `renderChildren(schema.body)`
-// and never `schema.children`. For these, `body` is the ONLY door — retiring it
-// removes the sole child-list key.
+// ⚠️ THIS BLOCK IS IN THE PAST TENSE ON PURPOSE. It records the population as
+// it was when step 1 of objectui#6771 built this instrument; step 2 then
+// converged every registration below onto `children`. The keys are what the
+// census LOOKS AT, not what it expects to find — its job now is to report that
+// the reading has gone to zero, so a present-tense sentence here would be a
+// claim the instrument's own output contradicts. For what is true TODAY read
+// `__tests__/body-dialect-census.test.ts`, which pins the converged state.
+//
+// BODY_ONLY: registrations whose renderer read `renderChildren(schema.body)`
+// and never `schema.children`. For those, `body` was the ONLY door — retiring
+// it removed the sole child-list key, which is why they are the population.
//
// ⚠️ Derived from the renderer sources, NOT from objectui#6771's card body
-// (which lists 10) and NOT verbatim from the ratchet pin's `bodyReaders` array
-// (which is `['badge','alert',...sidebar*]` = 13). The pin's array is a
-// convenience construction for a containment assertion — it is correct that all
-// 13 are non-containers, but two of its members are not `body` readers at all:
+// (which lists 10) and NOT verbatim from the ratchet pin's `bodyReaders` array.
+// That array — `['badge','alert',...sidebar*]` = 13 — lived in
+// `container-declaration-ratchet.test.tsx` until objectui#6771 removed it along
+// with the reads it named, so do not go looking for it. It was a convenience
+// construction for a containment assertion: correct that all 13 were
+// non-containers, but two of its members were not `body` readers at all:
+//
+// - `sidebar-trigger` (navigation/sidebar.tsx) renders a `SidebarTrigger` and
+// takes no `schema` at all — it read NEITHER `body` NOR `children`.
+// - `tooltip` (overlay/tooltip.tsx) DID read `schema.body` and never
+// `schema.children`, and was the one registration in the tree that DECLARED
+// `body` as an input (`type: 'slot'`, label "Rich Content") — yet it was
+// absent from the ruled 13. objectui#6771 moved both halves: it renders
+// `renderChildren(schema.children)` and declares `{ name: 'children' }`.
//
-// - `sidebar-trigger` (navigation/sidebar.tsx:202) renders ``
-// and takes no `schema` at all — it reads NEITHER `body` NOR `children`.
-// - `tooltip` (overlay/tooltip.tsx:31) DOES read `schema.body` and never
-// `schema.children`, and is the one registration in the tree that DECLARES
-// `body` as an input (`type: 'slot'`, label "Rich Content") — yet it is
-// absent from the ruled 13.
+// ⚠️ That last title is NOT vacant, and leaving this note in the present tense
+// is what let two files in one tree answer the same question differently. The
+// one registration declaring an input named `body` is now `record:alert`
+// (`plugin-detail/src/index.tsx`), whose `body` is a declared translation map
+// and never a child list. `packages/sdui-parser/src/body-dialect.ts` carries
+// that reading and the carve-outs that depend on it; it is the file to keep in
+// step, not this one.
//
-// So the body-reading population is 12 of the ruled 13, plus `tooltip` = 13
-// readers, which is the same NUMBER by coincidence and a different SET.
-// Both facts are pinned in `__tests__/body-dialect-census.test.mjs`.
+// So the body-reading population WAS 12 of the ruled 13, plus `tooltip` = 13
+// readers, the same NUMBER by coincidence and a different SET.
+// Both facts are pinned in `__tests__/body-dialect-census.test.ts`.
export const BODY_ONLY = [
'alert',
'badge',
@@ -117,10 +136,14 @@ export const BODY_ONLY = [
'sidebar-provider',
];
-/** Ruled by objectui#6771 but reads no child list at all — retiring `body` costs it nothing. */
+/** Ruled by objectui#6771 but read no child list at all — retiring `body` cost it nothing. */
export const RULED_BUT_NOT_A_READER = ['sidebar-trigger'];
-/** Reads `schema.body` only, and is NOT in the ruled 13. See the note above. */
+/**
+ * Read `schema.body` only, and was NOT in the ruled 13. Past tense: objectui#6771
+ * converged it, and `__tests__/body-dialect-census.test.ts` pins that. See the
+ * note above for why the name stays in the population regardless.
+ */
export const BODY_ONLY_UNRULED = ['tooltip'];
// FALLBACK_READERS: `schema.children || schema.body` (or `body || children`).
@@ -695,9 +718,9 @@ function main(argv = process.argv.slice(2)) {
const inPopulation = (list) => list.filter((key) => population.keys.includes(key));
const tables = [
- ['Group 1 — `body`-only registrations (retiring `body` removes their ONLY child-list key)', inPopulation(BODY_ONLY)],
- ['Group 1b — ruled by #6771 but reads NO child list (retirement costs it nothing)', inPopulation(RULED_BUT_NOT_A_READER)],
- ['Group 1c — `body`-only reader NOT in the ruled 13 (⚠️ declares `body` as an input)', inPopulation(BODY_ONLY_UNRULED)],
+ ['Group 1 — former `body`-only registrations (`body` was their ONLY child-list key)', inPopulation(BODY_ONLY)],
+ ['Group 1b — ruled by #6771 but read NO child list (retirement cost it nothing)', inPopulation(RULED_BUT_NOT_A_READER)],
+ ['Group 1c — former `body`-only reader outside the ruled 13 (it also DECLARED `body`; that title now belongs to `record:alert`)', inPopulation(BODY_ONLY_UNRULED)],
];
for (const [title, keys] of tables) {
if (keys.length > 0) printTable(title, group(keys, hits));
diff --git a/scripts/body-dialect-producer-scan.mjs b/scripts/body-dialect-producer-scan.mjs
index 035c746541..b51f13aaa9 100644
--- a/scripts/body-dialect-producer-scan.mjs
+++ b/scripts/body-dialect-producer-scan.mjs
@@ -814,9 +814,14 @@ export const EXIT_UNREADABLE = 2;
/**
* Whether a run may report a table at all.
*
- * ⭐ A zero PRODUCER count is a legitimate reading and is NOT refused — it is
- * the day objectui#6771 step 4 becomes landable, and refusing it would delete
- * the one answer this scan exists to be able to give. What IS refused is a run
+ * ⭐ A zero PRODUCER count is a legitimate reading and is NOT refused —
+ * refusing it would delete the one answer this scan exists to be able to give.
+ * ⚠️ It is NOT, as first written here, "the day objectui#6771 step 4 becomes
+ * landable": step 4 landed with this table non-empty, because every row left in
+ * it is `unruled:item-carrier` and the tier refuses `body` only on a node whose
+ * `type` resolves to a registration — a `tabs` item is the value of a declared
+ * `items` input and is never walked as one. A zero here is objectui#9590's
+ * finish line instead. What IS refused is a run
* that could not have found anything: a blind walk, or a C2 instrument that
* derived no readers, in which case every `body` fails C2 for a reason that has
* nothing to do with the corpus.
diff --git a/scripts/container-declaration-baseline.json b/scripts/container-declaration-baseline.json
index 562c9780b9..9773108609 100644
--- a/scripts/container-declaration-baseline.json
+++ b/scripts/container-declaration-baseline.json
@@ -74,52 +74,234 @@
"reason": "PERMANENT, RULED EXCEPTION -- not an oversight, not pending anything. Admitted 2026-08-29 as provisional by objectui#6779, which deferred the question to a separate card; that card is objectui#6804 and the maintainer ruled it 2026-08-30: `button` does NOT declare `isContainer`. Three grounds, all re-derived on this tree. (1) SEMANTICS -- `isContainer` means LAYOUT CONTAINMENT, not `this tag renders children`. `button` reads `schema.children` only as a fallback for `schema.label` (packages/components/src/renderers/form/button.tsx, `schema.label || renderChildren(...)`), so it is not a container; declaring it would make one predicate mean two different things. (2) COST -- `button` is the only public-tier member of the 45, and `renderers/layout/react-page.tsx` skips containers when it builds the JSX scope of every `kind:'react'` page, so declaring the flag DELETES the `Button` identifier from all of them. That is an immediate, user-visible capability loss. (3) PULL -- zero measured: nothing records anyone wanting to nest layout content inside a button, so the trade is a certain cost against a hypothetical gain. ⛔ Do NOT `fix` this entry by declaring the flag, and do NOT move `button` into `undeclared` -- both override the ruling. What keeps the exception honest is the pin, which asserts all three facts it rests on (still renders children, still undeclared, still public); if any one of them changes this goes RED and the exception must be RE-RULED, not quietly re-based. Scope note for the next hand: this card deliberately did not edit that `button.tsx` line -- the `schema.body || schema.children` expression on it belongs to objectui#6771 (retirement of the `body` dialect).",
"issue": "objectui#6804",
"since": "2026-08-29"
+ },
+ "alert": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a content fallback behind `description`, not a layout slot, so declaring it would make one predicate mean two things. ⚠️ PUBLIC TIER: this tag is in `PUBLIC_BLOCKS`, so declaring the flag would also delete its identifier from the JSX scope of every `kind:'react'` page — the same immediate, user-visible capability loss that made `button` an exclusion rather than a ratchet line. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
+ },
+ "badge": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a label fallback behind `label`, the same shape `button` was excluded for, so declaring it would make one predicate mean two things. ⚠️ PUBLIC TIER: this tag is in `PUBLIC_BLOCKS`, so declaring the flag would also delete its identifier from the JSX scope of every `kind:'react'` page — the same immediate, user-visible capability loss that made `button` an exclusion rather than a ratchet line. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
+ },
+ "sidebar-content": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
+ },
+ "sidebar-footer": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
+ },
+ "sidebar-group": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
+ },
+ "sidebar-header": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
+ },
+ "sidebar-inset": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
+ },
+ "sidebar-menu": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
+ },
+ "sidebar-menu-item": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
+ },
+ "sidebar-provider": {
+ "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.",
+ "issue": "objectui#6804",
+ "since": "2026-09-18"
}
},
"undeclared": {
- "a": { "since": "2026-08-29", "issue": "objectui#6779" },
- "abbr": { "since": "2026-08-29", "issue": "objectui#6779" },
- "address": { "since": "2026-08-29", "issue": "objectui#6779" },
- "app": { "since": "2026-08-29", "issue": "objectui#6779" },
- "b": { "since": "2026-08-29", "issue": "objectui#6779" },
- "blockquote": { "since": "2026-08-29", "issue": "objectui#6779" },
- "cite": { "since": "2026-08-29", "issue": "objectui#6779" },
- "dd": { "since": "2026-08-29", "issue": "objectui#6779" },
- "del": { "since": "2026-08-29", "issue": "objectui#6779" },
- "div": { "since": "2026-08-29", "issue": "objectui#6779" },
- "dl": { "since": "2026-08-29", "issue": "objectui#6779" },
- "dt": { "since": "2026-08-29", "issue": "objectui#6779" },
- "em": { "since": "2026-08-29", "issue": "objectui#6779" },
- "figcaption": { "since": "2026-08-29", "issue": "objectui#6779" },
- "figure": { "since": "2026-08-29", "issue": "objectui#6779" },
- "form": { "since": "2026-08-29", "issue": "objectui#6779" },
- "h1": { "since": "2026-08-29", "issue": "objectui#6779" },
- "h2": { "since": "2026-08-29", "issue": "objectui#6779" },
- "h3": { "since": "2026-08-29", "issue": "objectui#6779" },
- "h4": { "since": "2026-08-29", "issue": "objectui#6779" },
- "h5": { "since": "2026-08-29", "issue": "objectui#6779" },
- "h6": { "since": "2026-08-29", "issue": "objectui#6779" },
- "home": { "since": "2026-08-29", "issue": "objectui#6779" },
- "i": { "since": "2026-08-29", "issue": "objectui#6779" },
- "ins": { "since": "2026-08-29", "issue": "objectui#6779" },
- "li": { "since": "2026-08-29", "issue": "objectui#6779" },
- "mark": { "since": "2026-08-29", "issue": "objectui#6779" },
- "ol": { "since": "2026-08-29", "issue": "objectui#6779" },
- "p": { "since": "2026-08-29", "issue": "objectui#6779" },
- "page": { "since": "2026-08-29", "issue": "objectui#6779" },
- "pre": { "since": "2026-08-29", "issue": "objectui#6779" },
- "q": { "since": "2026-08-29", "issue": "objectui#6779" },
- "record": { "since": "2026-08-29", "issue": "objectui#6779" },
- "scroll-area": { "since": "2026-08-29", "issue": "objectui#6779" },
- "small": { "since": "2026-08-29", "issue": "objectui#6779" },
- "span": { "since": "2026-08-29", "issue": "objectui#6779" },
- "strong": { "since": "2026-08-29", "issue": "objectui#6779" },
- "sub": { "since": "2026-08-29", "issue": "objectui#6779" },
- "sup": { "since": "2026-08-29", "issue": "objectui#6779" },
- "time": { "since": "2026-08-29", "issue": "objectui#6779" },
- "toggle": { "since": "2026-08-29", "issue": "objectui#6779" },
- "u": { "since": "2026-08-29", "issue": "objectui#6779" },
- "ul": { "since": "2026-08-29", "issue": "objectui#6779" },
- "utility": { "since": "2026-08-29", "issue": "objectui#6779" }
+ "a": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "abbr": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "address": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "app": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "b": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "blockquote": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "cite": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "dd": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "del": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "div": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "dl": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "dt": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "em": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "figcaption": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "figure": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "form": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "h1": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "h2": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "h3": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "h4": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "h5": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "h6": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "home": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "i": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "ins": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "li": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "mark": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "ol": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "p": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "page": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "pre": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "q": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "record": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "scroll-area": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "small": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "span": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "strong": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "sub": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "sup": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "time": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "toggle": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "u": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "ul": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ },
+ "utility": {
+ "since": "2026-08-29",
+ "issue": "objectui#6779"
+ }
}
}