Skip to content

contract: should AppAction.items be re-typed to the overlay MenuItem? — the runner reads two overlay fields through as any today #6854

Description

@os-sam

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.actionsapp.ts:444). Its reads of each items element:

Field read Line Declared by app.ts MenuItem (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).

Activity

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

Metadata

Metadata

Assignees

Labels

domain:specobjectui spec stream: fix lands on packages/types, schema corpus or spec pin coupling — spec lanepriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions