Skip to content

feat(types,cli,core,sdui-parser): declare per-type node slots once and walk them in the gate, core validation and the SDUI parser (objectui#11170) - #11637

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-11170-node-slot-keys
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-11170-node-slot-keys

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11170
Clause-②: yes (narrowing)

One published declaration of the per-type NODE SLOTS on @object-ui/types, beside BaseSchema.children, and three readers that walk it: the objectui check unevaluated-expression gate (findUnbindableTextExpressions), core's validateChildren and the SDUI parser's validateTree. None keeps a slot list of its own.

Premise readings, on main at 9dfaca65

The slot list, re-measured. Every renderer read that hands authored nodes back to SchemaRenderer through a key other than children — the census grep for renderChildren(, renderNodeSlot(, renderTriggerSlot( and a direct SchemaRenderer schema= across packages/components, every plugin-*, layout, react and app-shell — gives, per registry key:

  • overlays: alert-dialog trigger/content; context-menu trigger; dialog trigger/content/footer; drawer trigger/content; dropdown-menu trigger; hover-card trigger/content; popover trigger/content; sheet trigger/content/footer; tooltip trigger only (its content is placed RAW in a React child position, text only — objectui#10295); collapsible trigger/content;
  • direct: card header/footer; table footer; data-table emptyAction; empty action; header-bar actions/rightContent;
  • panel lists: tabs items[].content; accordion items[].content; list items[].content; resizable panels[].content; carousel items[] (the renderer hands EACH item to renderChildren);
  • the page document (page / app / utility / home / record, one PageRenderer): regions[].components;
  • the page:* family: page:tabs and page:accordion items[].children; page:card footer and the retired body (schema.body ?? schema.children); page:section, page:footer, page:sidebar the retired body (children || body). page:header reads no node slot (its actions are action ids);
  • plugins: detail / detail-view (one DetailView) header, footer, actions, tabs[].content, sections[].fields[].render, fields[].render; detail-section fields[].render; report-viewer report.sections[].content; plugin-timeline:timeline items[].content (the bare timeline is view:timeline, no slot); view-switcher views[].schema; dashboard widgets[].component.

The card's list was a starting point and is superseded: page:card's body is a retired read, not a live slot; carousel has no trigger/header/footer and reads items[] directly; panels[].content is resizable's, not a tab's; and twenty-odd further positions were not on it.

The spec export. @objectstack/spec 17.6.0's ./ui subpath exports pageComponentSlotPositions() and PageComponentSlotPosition ({ key, panelKey?, retired }): derived from the ComponentPropsMap rows that declare a slot, it answers four positions — children, body (retired), footer, items[].children — by SHAPE over the whole page:* family, not per type. It exists identically in objectstack main (component.zod.ts). Nothing in the installed spec declares a slot for any non-page type, and COMPONENT_CHILD_KEYS / mapComponentTree are not on any exported subpath.

The registrations. Thirty-five type: 'slot' inputs other than children exist (the overlays' trigger/content/footer, collapsible, empty.action, page:card.footer), and ComponentInput has no vocabulary for a nested position: items[].content, regions[].components, sections[].fields[].render cannot be declared as inputs. isKnownSchemaType is a generated snapshot from the registration calls because the CLI cannot ask the registry (the header of scripts/regenerate-known-schema-types.mjs).

The three walks, as they stood. The gate: visit judged the node then followed node.children (array or single node). Core: validateChildren recursed through schema.children only. The parser: visit descended node.children only; CHILD_LIST_KEY is children, and RETIRED_CHILD_LIST_KEY (body) is answered by name in the prop walk.

Derivation route: (c), with (a) by reference for the page family

  • (a) ruled out as THE source: the export covers four positions of one family, by shape; the measured list has about thirty positions over thirty-odd types outside it. It IS the source for the page:* rows — they are placed on the types whose renderer reads each position, and node-slots-11170.test.ts holds them against pageComponentSlotPositions() in both directions (every row position is a spec position with the spec's retirement flag; every non-children spec position is placed on at least one page:* type).
  • (b) ruled out: the registrations cannot spell the nested positions (above), and a generated snapshot from them would miss every panel-list slot plus the direct slots no registration declares (card.header, table.footer, data-table.emptyAction, header-bar.actions, every plugin slot). The registrations are instead one PIN direction: every registered slot input has a row (node-slot-declaration-11170.test.tsx, direction 2).
  • (c) chosen: NODE_SLOT_DECLARATIONS in packages/types/src/node-slots.ts, one row per renderer under every registry spelling that resolves to it, positions spelled as key paths (trigger, items[].content, items[], regions[].components, report.sections[].content), nodeSlotsFor(type) the verbatim lookup, nodeSlotValues(node, path) the one walk. Pinned both ways against the renderers (below). body is never a generic key: it enters only on the four page:* types measured reading it, marked retired; the parser's refusals of the spelling are verbatim.

The readers

  • Gate (packages/cli): after children, visit walks nodeSlotsFor(node.type) through nodeSlotValues; a retired position is walked too (the renderer paints it). Path printed as the command spells it: items → 0 → content → value.
  • Core: validateChildren recurses through the declared positions after children, path spelled schema.items[0].content.
  • Parser (zero runtime dependencies, so it cannot import the declaration): ManifestComponent.slots?: readonly string[], projected by manifestFromConfigs(configs, { slotsFor: nodeSlotsFor }) — non-retired positions only, as the spec's authoring walks skip tombstoned spellings — and validateTree descends them with a dependency-free walk of the same grammar. Producers wired: getJsxManifest in page.tsx (kind:'html' pages), packages/sdui-parser/scripts/gen-manifest.ts (the shipped sdui.manifest.json), apps/console/dev/manifest-dump.tsx. A manifest built without the option serialises byte-identically. @object-ui/types joins the parser's devDependencies for the generator script.

Before/after over the repository's own schemas (node packages/cli/dist/cli.js check at the root, the CLI built from main and then from this branch; validateSchema and findUnbindableTextExpressions over the 479 JSON documents with a string root type under examples/, apps/, packages/, content/, docs/, and over the 237 JSON fences in content/docs, docs, package READMEs that parse as node documents): identical readings — 287 validated, 146 recognised but not validated, 0 did not validate, 0 refusals, 0 warnings before and after; core 0 findings before and after; fences 0 and 0. No example needed a fix, and no false refusal appeared. The parser's in-repo authored corpus is the one kind:'html' preview source in apps/console/src/sdui-tiers-preview.tsx; the console html-tier suites pass on the merged head.

Tests and ablations

  • packages/types/src/__tests__/node-slots-11170.test.ts — the table's shape, the grammar and the walk, the page:* rows ⇔ pageComponentSlotPositions() both ways.
  • packages/components/src/renderers/__tests__/node-slot-declaration-11170.test.tsx — over the live registry: (1) a node authored at every declared position of every key registered here reaches the DOM through SchemaRenderer, in the context the renderer needs; (2) every registered slot input is a declared position; (3) registry twins answer one list and every listed key is registered; (4) the projected manifest carries the positions and validateTree's reach equals nodeSlotValues' at every position. Controls: a key with no row renders children and not an undeclared key.
  • nodeSlotDeclaration-11170.test.tsx in plugin-detail, plugin-report, plugin-timeline, plugin-view, plugin-dashboard — the plugin rows the same way, each asserting the row lists exactly the positions it exercises.
  • Reader pins: the gate suite check-unbindable-text-expression-4795.test.ts (new block; the ablation-2 rows fields[], columns[], { "type": "multiple" } stay verbatim and green beside it), packages/core/src/validation/__tests__/node-slot-walk-11170.test.ts, packages/sdui-parser/src/__tests__/node-slot-walk-11170.test.ts.
  • Ablation A — drop content from the dialog row (committed tree, node scripts-free anchored replace, on-disk anchor counts 1/0 → 0/1): gate pin, two core pins and two census directions red (5 failed, 3 files); restore from HEAD, blob 6798a9ab… equal before and after, 49 passed. Ablation B — turn the parser's slot visit into a no-op: three parser pins and census direction 4 red (4 failed, 2 files); restore, blob 72a08ad1… equal, 15 passed.

Verification on head 7f5eb1af (merged with origin/main 1c2e2c46)

  • type-check (both legs) for types, cli, core, sdui-parser, components, plugin-detail, plugin-report, plugin-timeline, plugin-view, plugin-dashboard: all exit 0.
  • vitest: packages/types/ packages/sdui-parser/ packages/cli/ packages/core/src/validation/ → 413 files, 10328 tests passed; the console manifest / html-tier suites, packages/components/src/renderers/__tests__/ (ratchet included), the six census suites and the four reader pins on the merged head → 23 files, 342 tests passed.
  • pnpm exec eslint over every touched file: 0 errors (33 pre-existing no-explicit-any warnings in base.ts / schema-validator.ts, none on an added line).
  • Gates, each exit 0: check:doc-snippets (777/777 blocks compile against the built tree), check:new-line-citations (0 new), check:control-bytes, check-changeset-presence, changeset:check, check:changeset-claims, check:readme-exports, check:component-surface-parity, check:phantom-deps, check:unused-deps, check:unreferenced-sources, check:self-import, check:test-path-roots, check:doc-types, check:doc-fences, check:esm-specifiers, check:pending-changeset-literals.

Acceptance notes

  • The properties bag is a stated boundary, unchanged. All three walks read a slot off the node, after SchemaRenderer's hoist; a document authored in the spec's page-component shape ({ type, properties: { children } }) is not walked into by any of them today, as before.
  • carousel: renderer and contract disagree. The renderer hands each items[] entry to renderChildren (and its own defaultProps author lists of nodes per item), while CarouselItemSchema and the TypeScript face declare items[].content. The row follows the measured read; the disagreement is reported for a card of its own, not fixed here.
  • Not a slot, by measurement: tooltip.content (raw text); dashboard widget entries on the component arm; app-schema-renderer's schema (a stored page document, rendered structurally); report's unwrapped report object (routed to data renderers, no authored node). Each is asserted where a reader could have guessed otherwise.
  • Reverse reach is pinned by two instruments, not three. A renderer adding a read of a NEW key is caught when the key is a registered slot input (direction 2) or when the row is exercised by a census; a read added with neither is outside this PR's instruments, and the contract chain (check:component-surface-parity → the TS face → the zod mirror) is the place it surfaces first.

Session: session_01CPvhwGcirXqBGEdPSb72TZ.


Generated by Claude Code

claude added 2 commits October 5, 2026 02:22
…d walk them in all three readers (objectui#11170)

`NODE_SLOT_DECLARATIONS` / `nodeSlotsFor` on `@object-ui/types`, beside
`BaseSchema.children`: the keys other than `children` through which a renderer
hands authored nodes back to `SchemaRenderer`, per registry type, spelled as
key paths (`trigger`, `items[].content`, `regions[].components`). The `page:*`
rows are the spec's `pageComponentSlotPositions()` by reference, pinned both
ways; every other row is pinned against the live renderer. `body` stays retired
and enters only on the four `page:*` types measured still painting it.

The `objectui check` gate, core's `validateChildren` and the SDUI parser (via
`ManifestComponent.slots`, projected by `manifestFromConfigs`'s `slotsFor`)
walk the declaration; none keeps a list of its own.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPvhwGcirXqBGEdPSb72TZ
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 331 chunks) 3315.7 KB 3330.4 KB
Main entry chunk (gzip) 151.6 KB 350 KB
Entry file index-dsOw_Cuc.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 17.22KB 6.37KB
app-shell (runtime-config.js) 22.52KB 7.86KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.11KB 7.97KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.28KB 2.60KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.50KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 573.76KB 137.62KB
core (index.js) 10.00KB 3.96KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 232.57KB 64.51KB
fields (index.js) 262.75KB 66.62KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.35KB 12.88KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 35.66KB 9.49KB
i18n (useSafeTranslation.js) 7.14KB 2.92KB
layout (index.js) 39.47KB 11.25KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.86KB 5.00KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.52KB 2.26KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.04KB 3.92KB
plugin-calendar (index.js) 53.17KB 15.46KB
plugin-charts (index.js) 84.26KB 23.05KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 143.54KB 38.79KB
plugin-designer (index.js) 231.41KB 48.84KB
plugin-detail (index.js) 245.97KB 64.67KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 177.09KB 45.89KB
plugin-gantt (index.js) 179.16KB 45.06KB
plugin-grid (index.js) 235.92KB 64.87KB
plugin-kanban (index.js) 50.06KB 15.74KB
plugin-list (index.js) 116.72KB 29.10KB
plugin-map (index.js) 25.60KB 8.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.12KB 12.29KB
plugin-timeline (index.js) 38.80KB 11.71KB
plugin-tree (index.js) 14.51KB 5.15KB
plugin-view (index.js) 90.23KB 22.73KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 120.63KB 39.56KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.31KB 2.07KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.50KB 1.99KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 7.30KB 3.12KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 23.87KB 7.83KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 4.12KB 1.61KB
types (authoring-nodes.js) 0.20KB 0.19KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (cloud.js) 0.20KB 0.18KB
types (complex.js) 4.44KB 2.07KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (dashboard-widget-layout.js) 2.06KB 0.96KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 1.13KB 0.65KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 5.78KB 2.70KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (node-slots.js) 7.18KB 2.34KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 4.99KB 1.96KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.93KB 7.25KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7f5eb1afdda0bcc0528120fa2471eb062784acfc
Local-runs: none

At-tier review for the domain:spec @ objectui seat (card objectui#11170). Inputs: the card body and its four comments (5982800719, 5983259102, 5986449264, 5987232226); PR #11637's body, 27-file list and the net diff against main at the head; PR #11126 (its body and the director seat's record 5903644098) for the gate's original contract and ablation 2; the trees at origin/main and the PR branch in /home/user/objectui through git show / git grep / git diff only; @objectstack/spec at the @objectstack/spec@17.6.0 tag and at objectstack origin/main (0a348031) through git show; the head's check-runs. Nothing checked out, built, run or re-run; the Clause-② line was read with the fleet's one reader (readClause2Line), which is a read.

The merge. 7f5eb1af has parents 323e87b0 (the implementation) and 1c2e2c46 (origin/main, objectui#11624). git show --cc of the merge prints no hunk at all. git diff from 1c2e2c46 to the head is byte-identical to git diff from the merge base 9dfaca65 to 323e87b0 (100,713 bytes, 27 files). The six files main moved between the merge base and 1c2e2c46 (plugin-form, the registry-inputs parity test, one packages/types test, one changeset) are not in the branch's diff. Clean.

Check-runs on the head, six readings. 2026-10-05T02:50Z: 44 runs, 27 success, 3 skipped, 14 in progress (the eight test shards, Type Check, Lint, Build Docs, Doc Snippet Type Check, Bundle Analysis, Spec Main Shape Gate). 2026-10-05T02:54Z: 8 in progress, 0 failures. 2026-10-05T02:58Z: 7 in progress, 0 failures. 2026-10-05T02:59Z: 1 in progress (shard 1); Spec Main Shape Gate completed success at 2026-10-05T02:58:34Z, Type Check success at 2026-10-05T02:58:40Z, Lint success at 2026-10-05T02:52:05Z. 2026-10-05T03:01Z: 45 runs (the Test rollup joined), 0 in progress, 42 success, 3 skipped (the coverage matrix placeholders and dependabot), 0 failure, cancelled or timed out. 2026-10-05T03:02Z: the full table re-read, unchanged. Lint carries this repository's own pnpm check step, so the corpus is green under the new reach on this head.

① Derived judgments

  • The route — the measurements that ruled out (a) and (b) are sound; (c) with the page rows held to (a) is RIGHT. (a): at the @objectstack/spec@17.6.0 tag, pageComponentSlotPositions() is derived from the ComponentPropsMap rows marked as slots, exported through the ./ui barrel, and its own test pins exactly four entries: children, body (retired), footer, items with panelKey: children. It is by shape over the page family and names no non-page type; COMPONENT_CHILD_KEYS / mapComponentTree sit in conversions/walk.ts, which the package's exports map does not expose. Four positions of one family cannot be THE source for about thirty positions over thirty-odd types. (b): ComponentInput has no nested vocabulary, so items[].content, regions[].components and sections[].fields[].render have no input spelling; several direct slots have no slot input either (card.header, table.footer, data-table.emptyAction, header-bar.actions, every plugin slot); and the CLI's registration snapshot (isKnownSchemaType) exists because the CLI cannot ask the live registry. A derivation from registrations would miss every panel-list slot. (c): one table in packages/types/src/node-slots.ts, one row per renderer under every registry spelling, with the page:* rows carrying the spec's four positions placed on the types whose renderer reads them and held to the export in both directions by node-slots-11170.test.ts (every page-row position is a spec position with the spec's retirement flag; every non-children spec position is placed on at least one page:* type). One wording note: the module header says the page positions are "read by reference rather than copied" — they are transcribed into the table and PINNED to the export; the same sentence goes on to say exactly that, so a reader is not misled about the mechanism, but the phrase should be tightened on the next touch. Non-blocking.

  • Pinned both ways? Direction 1 is; direction 2 is against the registrations, not the renderers. Accepted residual, with the reason. Direction 1 (declared ⇒ rendered) renders a marked node at every declared position of every key the components package registers through the real SchemaRenderer, overlays held open and tabs selected, and each plugin suite does the same per row and asserts the row equals exactly the positions it exercises. Direction 2 is "every registered slot input other than children is a declared position"; direction 3 is twins agree and every listed key is registered; direction 4 is the parser's projection and validateTree's reach equal nodeSlotValues at every position. A renderer that begins handing back a NEW nested slot with no registration input is caught by none of these — a gap against a literal reading of the ruling's "pinned in both directions against what the renderers actually hand back". Can it be closed mechanically in this PR? A source-side census is measured unbuildable in this repository: the four reasons objectui#6779 recorded in container-declaration-ratchet.test.tsx (computed keys and loop-variable registrations, file granularity, import-order liveness, the children-or-body distinction) apply unchanged to a reverse census. A behavioural reverse sweep can enumerate only DIRECT keys (a registration's inputs and defaults), would catch a new renderChildren(schema.sidebar) but not the nested read the question names, and needs per-type render contexts so that a marked object placed on a non-slot key does not throw. So: accepted residual, stated by the dev in the PR body ("reverse reach is pinned by two instruments, not three") with the contract chain (check:component-surface-parity → the TS face → the zod mirror) as the place a new read surfaces first. Recommended carrier: a direct-key reverse sweep over every known type, which narrows the residual to nested positions. A second, smaller residual: the plugin suites enumerate their twins rather than sweeping them, so a plugin spelling registered later without a row is caught only if a suite lists it. Neither moves the verdict; the first belongs in the report's deviations (③).

  • The table against the renderers — re-censused at the head, every row RIGHT. My own grep for renderChildren(, renderNodeSlot(, renderTriggerSlot( and direct SchemaRenderer schema= across packages/*/src (tests excluded) finds no read of an authored node key without a row. The corrected card entries: page:card (containers.tsx, registered card in namespace page with skipFallback) reads schema.body ?? schema.children and schema.footer — row footer plus retired body; the thin PageCard div in @object-ui/layout is a separate key, layout:page:card with skipFallback, reads no node key, and is correctly absent. carousel maps schema.items and hands EACH item to renderChildren — row items[], not items[].content (③). resizable reads panel.content per panel — panels[].content is its row, and tabs / accordion / list read item.content — items[].content. page:section / page:footer / page:sidebar read schema.children || schema.body — retired body; page:tabs / page:accordion read item.children — items[].children; the registered page:header renderer reads no node key through any of the helpers — no row (the layout PageHeader component, which wraps schema.actions into a record:quick_actions node, is not a registry key). Overlays: alert-dialog, dialog (plus footer), drawer, hover-card, popover, sheet (plus footer), collapsible read trigger and content; context-menu and dropdown-menu read trigger only (their items are menu entries); tooltip reads trigger and places content raw as a React child. Direct: card header / footer (its children goes through renderNodeSlot, the composition key), table footer, data-table emptyAction, empty action, header-bar actions (each mapped) and rightContent. The page document: RegionContent renders region.components, FlatContent renders schema.children; all five keys (page, app, utility, home, record) register PageRenderer under namespace ui, so the ten spellings are one row. Plugins: DetailView reads header / footer through renderNodeSlot, maps actions, DetailTabs reads tab.content (list or single), DetailSection reads field.render for both fields[] and sections[].fields[]; ReportViewer destructures report from the node and reads each section.content (list or single); the presentational timeline reads item.content and is registered plugin-timeline:timeline with skipFallback, so the bare timeline stays view:timeline (no slot); ViewSwitcher reads the active view's schema; the dashboard's entryComponent(widget) is widget.component on the component arm. Reads that construct their own schema (plugin-form's form schemas, plugin-grid, plugin-list's viewComponentSchema, plugin-charts' table, plugin-dashboard's dataset, drill-down and object-data-table, plugin-detail's related list, the chatbot's chart) hand no authored node and need no row; SchemaRenderer itself has no self-recursion on a node key; apps/console registers nothing that reads a node key through these helpers. Every registry spelling carries its row: for the components package mechanically (direction 3 groups the known keys by renderer and asserts one answer per renderer, and every listed key must be registered — the ui: twins come from the registry's fallback rule); for the plugins by enumeration, and the spellings enumerated (detail-view / plugin-detail:detail-view, detail / view:detail, detail-section / plugin-detail:detail-section, report-viewer / plugin-report:report-viewer, view-switcher / view:view-switcher, dashboard / plugin-dashboard:dashboard, plugin-timeline:timeline) are the ones the registrations I read produce (namespaces plugin-detail, view, plugin-report, plugin-timeline with skipFallback, plugin-dashboard).

  • objectui#6771 — coherent, and nothing reopens body. body appears on exactly four rows, all page:*, all retired: true; the types suite pins that body rows are page-family only and that nothing else is retired. nodeSlotsFor('badge') is empty, and the gate and core pins show body on a non-page type is not descended while children on the same type is. The parser's RETIRED_CHILD_LIST_KEY refusal lives in body-dialect.ts, which the diff does not touch (the sdui-parser diff is validate.ts, index.ts, types.ts, package.json, the generator script and the new test), and the parser pin shows page:card.body still draws unknown-prop "retired by objectui#6771" with no unknown-component beneath it. The split is coherent: the gate and core judge what the renderer paints, and page:card's renderer paints a stored document's body, so a ${…} or an invalid node there reaches the user; the parser is the authoring tier, where the spelling is refused by name, and its projection drops retired positions the way the spec's own authoring walks filter retired (the 17.6.0 i18n-resolver does exactly that). schema-reference.md keeps the body row "refused by name" and the children row's "since objectui#6771 the only one" stays true: a slot is a per-type position, not a second child-list spelling.

  • Three readers, one declaration — RIGHT; the parser's projection is a reading, not a copy. The gate's visit walks children, then nodeSlotsFor(node.type) through nodeSlotValues, visiting each component-shaped value with the segments appended to the path; core's validateChildren makes the same two calls after its children recursion and spells the path with spellSlotPath (schema.items[0].content), mirroring the children[i] spelling above it; the parser carries ManifestComponent.slots, written only when a producer hands manifestFromConfigs a slotsFor resolver (non-retired paths), and validateTree reads comp.slots off the manifest entry and descends with slotElements. That last function is a second implementation of the path GRAMMAR, not a second key list — forced by the package's zero runtime dependencies, stated in its docblock, and held to nodeSlotValues by census direction 4 over every registered position. All three producers of a live manifest pass nodeSlotsFor (getJsxManifest in page.tsx, packages/sdui-parser/scripts/gen-manifest.ts, apps/console/dev/manifest-dump.tsx); a manifest built without the option writes no slots key, pinned byte-identical. No generated sdui.manifest.json is checked into the tree, so no stale artefact is owed. @object-ui/types as a devDependency of sdui-parser is right: only scripts/gen-manifest.ts imports it, src/ still imports nothing at runtime (dependencies stays empty), the cli, core and components packages already carry @object-ui/types as a runtime dependency for their new imports, apps/console carries it as a devDependency, and the lockfile hunk is exactly the one link:../types importer entry under the parser's devDependencies. Lint (check:unused-deps, check:phantom-deps) is green on the head.

  • The corpus reading and the ablation-2 rows. The before/after numbers (287 validated, 479 node documents, 237 fences, 0 new findings per reader) are the dev's and were not re-run; they are consistent with what the diff can do, and Lint's pnpm check on this head is CI's measurement of the corpus under the new gate reach: green. PR feat(cli): objectui check refuses a ${…} on a text key its node never evaluates (objectui#4795) #11126's ablation-2 rows are kept verbatim: the original form-field pin ("fields[] entries are not component nodes") is untouched in the gate suite, the new block repeats fields[], columns[] and { "type": "multiple" } beside a control that content on text is not walked, and the core pins repeat form.fields[] and grid.columns[]. Reach is decided by the declaration, never by the shape of a value — the sentence the card asked for.

  • The tests bear weight. Types: shape, grammar and walk with population controls (more than forty keys; the spec's four positions present) and the page rows ⇔ spec in both directions. Components census: four directions, each with a control that can fail (button renders children and not footer; card.footer renders, tabs.footer does not). Plugin suites: each row equals exactly the exercised positions, every twin rendered, a negative probe per type, and the report / view:timeline non-rows asserted. Gate block: the printed path for a direct slot, a panel list and a page region, a slot under a child under a slot, the retired body on page:card and not on badge, a type with no row. Core and parser pins: reach by position with negatives and primitives skipped. Ablations A and B are the dev's; reading the pins, dropping content from the dialog row reddens the gate pin's toContain('content'), both core dialog pins, direction 2 (the dialog's content slot input becomes undeclared) and direction 4's explicit ['trigger', 'content', 'footer'] — the five the report counts; making the parser's slot visit a no-op reddens the three parser reach cases and direction 4's equality — the four it counts. The predictions match the instruments.

  • Sentences — each true at the head. cli.mdx's "Component nodes only" rule now states the root, children and the declared slots, names the declaration, says the check keeps no list, and keeps the page root's title exemption; the objectui validate paragraph's "A slot that walk does not descend is not judged here" describes the zod face's nested validation at the spec walk's positions — validate calls safeValidateSchema from @object-ui/types/zod, not core's walker — so it stays true. The gate docblock, the validateChildren comment, the parser header and the ManifestComponent docblock, the schema-reference.md children row and the types README entry each say what the code does. The PR body's "page:header reads no node slot" holds for the registered renderer. The docs that name validateSchema (schema-rendering.md, troubleshooting.md) describe where it runs, not its reach. The one imprecision is the "read by reference" phrase above.

  • Spec Main Shape Gate — holds. The only compile-time spec import the diff adds is pageComponentSlotPositions / PageComponentSlotPosition in the types test. At objectstack origin/main (0a348031) both are exported from component.zod.ts through the ./ui barrel with the same three fields (key, panelKey?, retired). The gate compiles this repository against the spec built from objectstack main; its run on this head concluded success at 2026-10-05T02:58:34Z. I did not read its log for the objectstack sha it resolved; the conclusion is the reading.

② Semver level

Clause-②: yes (narrowing) on both carriers, read by readClause2Line as declared / yes / narrowing on each. That arm's documented meaning is exactly this diff: yes because the public face widens — NODE_SLOT_DECLARATIONS, nodeSlotsFor, nodeSlotPathSegments, nodeSlotValues and four types on @object-ui/types, manifestFromConfigs's slotsFor option, ManifestComponent.slots and NodeSlotDeclarationLike on @object-ui/sdui-parser — and (narrowing) because three accept sets narrow (objectui check, validateSchema, validateTree wherever a producer passes the option) plus the html-tier compile in @object-ui/components through getJsxManifest. A plain yes would have hidden the break; no (narrowing) would have denied the exports. RIGHT.

.changeset/11170-node-slot-keys.md bumps minor for types, cli, core, sdui-parser and components, with FROM → TO per reader. Under this repository's rule (AGENTS.md §版本号策略: no major in a changeset, objectui's own breaking changes ship as minor with the breaking semantics in the body, enforced by check-changeset-no-major — Changeset Bump Policy green) that is the correct level and the body carries the break. The ADR-0087 disposition marker objectstack's AGENTS.md asks of a breaking changeset is objectstack's gate; objectui's AGENTS.md has no such rule and no such gate, so none is owed here. The zero-new-findings corpus reading is consistent with a narrowing declaration: narrowing is a property of the accept set, the corpus shows no in-repo document trips it, and a consumer's document can. The five plugin packages change tests only and owe nothing; the fixed group carries them. Changeset Declaration (check-changeset-presence) accepted the one changeset against 19 source files in 10 released packages; Changeset Fixed Group Check green. RIGHT.

③ Boundary flags

  • deviations and open_questions empty — one item should have been a deviation. The reverse-reach residual (① second bullet) is a deviation from the ruling's "pinned in both directions against what the renderers actually hand back": direction 2 is against the registrations. The PR body states it in Acceptance notes; the report's deviations does not. Recorded here for the seat's ledger; it does not move the verdict, because the residual is named, bounded, and the only mechanical closures are partial.
  • Carousel items[].content versus items[] — class (b) is right; it is a filing-gate category ① defect and should get a carrier. The published TS face and CarouselItemSchema declare items[].content; the renderer hands each item whole to renderChildren; the catalog's five carousel examples follow the renderer. A consumer who writes the contract's shape gets a blank slide, which is the declared-contract violation (b) names. Reach is measured on the contract face and the corpus, and the public-door wrong answer is not exercised — under the filing rules a finding short of reach: goes to priority:p3 in a close-out or sweep card, not to Acceptance notes with "carrier: none". The seat should file it or fold it in. Whichever side moves, the census pin for carousel → items[] goes red, so this PR's row cannot silently outlive the fix. Not blocking.
  • The spec page-component shape (properties.children) not walked — observation, pre-existing, stated as a boundary in the PR body; objectui validate already judges those positions through the zod face; zero measured pull; no card. Agreed.
  • Zod-typed keys no renderer hands back (tooltip.content) — observation; each needs its own runtime reading; objectui#10295 already carries tooltip. Agreed.
  • One further boundary, mine: app-shell's action modal (useActionModal) renders a ModalDescriptor.content node through SchemaRenderer. That is authored node content under the ACTION vocabulary, not under a registry type's key, so none of the three walks reaches it and this declaration is the wrong instrument for it — the same class as the properties boundary. Named so it is not later mistaken for a missing row.
  • The merge — clean (header): empty combined diff, net diff equal to the implementation diff, no shared file with objectui#11624.
  • PR feat(cli): objectui validate and objectui check judge through the strict authoring face (objectui#5250, slice A) #11069 (a stale draft touching check.ts and cli.mdx) — not a hold; whichever lands later merges main.

Implemented-by: claude/issue-11170-node-slot-keys
Reviewed-by: session_01CPvhwGcirXqBGEdPSb72TZ

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment