Filed unassigned, as the rider on #6692's 2026-08-29 ruling requires: option A (docs-only) landed there; this card carries option B — should AppAction.items mean the overlay MenuItem instead of app.ts's legacy navigation-item type — together with the read census the ruling asked for. ⛔ Not decided here.
Ruling text this comes from (#6692, 2026-08-29): "the same PR records a read census of the consumer of AppAction.items — if the renderer is measured reading overlay-shaped fields rather than the nav-item shape, option B is filed as its own contract card with that census as evidence".
The census, measured at 26896c6
First correction: the consumer named in #6692 is the wrong file.packages/components/src/renderers/navigation/header-bar.tsx never sees AppAction. It consumes HeaderBarSchema (packages/types/src/navigation.ts:57), whose actions?: SchemaNode[] is an unrelated field, and it renders those through SchemaRenderer without reading .items at all.
The real and only consumer is packages/runner/src/LayoutRenderer.tsx:301-315 (reached via AppComponentSchema.actions → app.ts:444). Its reads of each items element:
| Field read | Line | Declared by app.tsMenuItem (what items IS) | Declared by overlay MenuItem |
|---|
item.type === 'separator' | 302 | yes — type?: 'item' | 'group' | 'separator' | no — type?: never on both arms |
item.label | 307 | yes | yes (command arm) |
(item as any).onClick | 304 | no | yes |
(item as any).shortcut | 309 | no | yes |
Not read at all: path, href, badge, hidden, children.
Verdict: mixed, and the mixture is the finding. The renderer branches on the legacy type: 'separator' spelling, then reaches two overlay-shaped fields — onClick and shortcut — through as any casts, i.e. past the declared type, which does not have them. So the renderer is not cleanly on either side: it is written against a shape that is the union of the two, and the casts are what let that compile.
Note the collision with #6523: the overlay union deliberately declares type?: never on both arms because reading an undeclared item.type === 'separator' was exactly the spelling retired there (dividers are { separator: true }). The runner still reads that spelling — legal against the type items actually has, and refused by the type option B would give it.
Why this needs a maintainer, not a dev
Both declarations agree today, so this is a real published contract, not an oversight:
- TS:
AppAction.items?: MenuItem[] — packages/types/src/app.ts:728, resolving to app.ts:461. - Zod mirror:
AppActionSchema.items: z.array(MenuItemSchema) — packages/types/src/zod/app.zod.ts:192, and that MenuItemSchema is the one headed "Legacy MenuItem Schema (backward compat)" at app.zod.ts:167.
Re-typing the field is therefore a published-contract change with an authoring-surface consequence: app metadata that authors path / href / badge / type on a type: 'user' or type: 'dropdown' action's items would start being refused, and the divider spelling would move from { "type": "separator" } to { "separator": true }. #6692's triage recorded the routing consequence too: option B's patch lands in packages/types/src/app.ts, so the card would be re-domained to domain:spec, not domain:ui.
The options
- B1 — re-type
AppAction.items to the overlay MenuItem. Matches what the runner is visibly reaching for (onClick, shortcut) and deletes the two as any casts. Costs a breaking change to authored app metadata and a divider-spelling migration; needs a census of real app metadata in the wild first. - B2 — keep the legacy type and delete the casts. The runner stops reading
onClick/shortcut, which are undeclared there — declared = enforced, per ADR-0049. Cheapest, and honest, but it removes behaviour some app may be relying on today (worth measuring: those casts may be dead already, since app.ts's MenuItem gives authors no way to write an onClick). - B3 — leave both, do nothing. The status quo: a field whose consumer casts past its own declared type. ⛔ Not recommended — this is the shape the
as any hides.
⚠️B2 deserves a hard look before B1.AppAction.items is typed as app.ts's MenuItem, and the zod mirror parses it that way, so an author has no declared route to supply onClick or shortcut at all — which suggests the two casts may already be reading fields that never arrive. If that is confirmed, B1 is adding a capability rather than fixing a break, and the startup-scope floor applies to it very differently. That measurement is cheap and should come first.
Refs: #6692 (option A, landed) · #6523 (the overlay union split that made type?: never explicit).
Filed unassigned, as the rider on #6692's 2026-08-29 ruling requires: option A (docs-only) landed there; this card carries option B — should
AppAction.itemsmean the overlayMenuIteminstead ofapp.ts's legacy navigation-item type — together with the read census the ruling asked for. ⛔ Not decided here.Ruling text this comes from (#6692, 2026-08-29): "the same PR records a read census of the consumer of
AppAction.items— if the renderer is measured reading overlay-shaped fields rather than the nav-item shape, option B is filed as its own contract card with that census as evidence".The census, measured at
26896c6First correction: the consumer named in #6692 is the wrong file.
packages/components/src/renderers/navigation/header-bar.tsxnever seesAppAction. It consumesHeaderBarSchema(packages/types/src/navigation.ts:57), whoseactions?: SchemaNode[]is an unrelated field, and it renders those throughSchemaRendererwithout reading.itemsat all.The real and only consumer is
packages/runner/src/LayoutRenderer.tsx:301-315(reached viaAppComponentSchema.actions→app.ts:444). Its reads of eachitemselement:app.tsMenuItem(whatitemsIS)MenuItemitem.type === 'separator'type?: 'item' | 'group' | 'separator'type?: neveron both armsitem.label(item as any).onClick(item as any).shortcutNot read at all:
path,href,badge,hidden,children.Verdict: mixed, and the mixture is the finding. The renderer branches on the legacy
type: 'separator'spelling, then reaches two overlay-shaped fields —onClickandshortcut— throughas anycasts, i.e. past the declared type, which does not have them. So the renderer is not cleanly on either side: it is written against a shape that is the union of the two, and the casts are what let that compile.Note the collision with #6523: the overlay union deliberately declares
type?: neveron both arms because reading an undeclareditem.type === 'separator'was exactly the spelling retired there (dividers are{ separator: true }). The runner still reads that spelling — legal against the typeitemsactually has, and refused by the type option B would give it.Why this needs a maintainer, not a dev
Both declarations agree today, so this is a real published contract, not an oversight:
AppAction.items?: MenuItem[]—packages/types/src/app.ts:728, resolving toapp.ts:461.AppActionSchema.items: z.array(MenuItemSchema)—packages/types/src/zod/app.zod.ts:192, and thatMenuItemSchemais the one headed "Legacy MenuItem Schema (backward compat)" atapp.zod.ts:167.Re-typing the field is therefore a published-contract change with an authoring-surface consequence: app metadata that authors
path/href/badge/typeon atype: 'user'ortype: 'dropdown'action'sitemswould start being refused, and the divider spelling would move from{ "type": "separator" }to{ "separator": true }. #6692's triage recorded the routing consequence too: option B's patch lands inpackages/types/src/app.ts, so the card would be re-domained todomain:spec, notdomain:ui.The options
AppAction.itemsto the overlayMenuItem. Matches what the runner is visibly reaching for (onClick,shortcut) and deletes the twoas anycasts. Costs a breaking change to authored app metadata and a divider-spelling migration; needs a census of real app metadata in the wild first.onClick/shortcut, which are undeclared there — declared = enforced, per ADR-0049. Cheapest, and honest, but it removes behaviour some app may be relying on today (worth measuring: those casts may be dead already, sinceapp.ts'sMenuItemgives authors no way to write anonClick).as anyhides.AppAction.itemsis typed asapp.ts'sMenuItem, and the zod mirror parses it that way, so an author has no declared route to supplyonClickorshortcutat all — which suggests the two casts may already be reading fields that never arrive. If that is confirmed, B1 is adding a capability rather than fixing a break, and the startup-scope floor applies to it very differently. That measurement is cheap and should come first.Refs: #6692 (option A, landed) · #6523 (the overlay union split that made
type?: neverexplicit).