Found while working #6739, via a probe against defineStack. Filed separately — #6739 is one showcase corpus line; this is the validator's reach.
Blocked-by: #6739 — the content of the widened rule depends on #6739's contract answer (may a modal target name an object, or only a page?). The hole below is real under either answer.
What
packages/spec/src/stack.zod.ts:972-1006 validates action cross-references by iterating config.actions — the registered action list — only. Actions authored inline on a page element (element:button → properties.action, an InlineActionSchema) are never walked, so none of their targets are checked.
Measured
Probe: five minimal stacks, one defineStack call each, same object (probe_task) and page (probe_home) in all five.
A registered modal -> object : REJECTED -> Action 'probe_new_task' references page 'probe_task' (via modal target) which is not defined in pages.
B registered modal -> page : ACCEPTED
C registered modal -> nothing : REJECTED -> Action 'probe_new_task' references page 'probe_nowhere' (via modal target) which is not defined in pages.
D inline modal -> object : ACCEPTED
E inline modal -> nothing : ACCEPTED
Row E is the defect on its own terms: target: 'probe_nowhere' names neither a page nor an object nor anything else in the stack. Authored on a registered action it is a build error (row C). Authored inline on a button it builds clean, ships, and fails only when a user clicks — objectui's useActionModal modalHandler answers
Modal target "probe_nowhere" matches no page or object — a modal action's `target` names the page to open.
…i.e. a silent-until-clicked dead button, which is exactly the class row C exists to prevent.
The D/A split is the second half, and is #6739's question rather than this one's: the same type + the same target string gets opposite verdicts depending only on where the action was declared.
Reproduction
Self-contained, no repo fixtures — defineStack from @objectstack/spec with a manifest, one object, one page, and the action authored each way. The inline shape is the one PR #6737 shipped for the showcase home CTA (examples/app-showcase/src/ui/pages/index.ts:95).
Why it matters
Inline actions are the shape the AI-authoring path emits most readily — a button with its behaviour written right there, no separate registry entry. That is precisely the surface that most needs authoring-time rejection, and it is the one surface the cross-reference walk does not visit. "Declared = enforced" currently holds for registered actions and not for inline ones.
flow targets have the same hole (the same loop checks action.type === 'flow'), so an inline flow action naming a nonexistent flow is also unvalidated.
Direction
Walk page/region/component trees for inline actions and subject them to the same cross-reference checks as registered ones — same rule, same message, one more traversal. What the modal branch should accept comes from #6739.
Found while working #6739, via a probe against
defineStack. Filed separately — #6739 is one showcase corpus line; this is the validator's reach.Blocked-by: #6739 — the content of the widened rule depends on #6739's contract answer (may a modal
targetname an object, or only a page?). The hole below is real under either answer.What
packages/spec/src/stack.zod.ts:972-1006validates action cross-references by iteratingconfig.actions— the registered action list — only. Actions authored inline on a page element (element:button→properties.action, anInlineActionSchema) are never walked, so none of their targets are checked.Measured
Probe: five minimal stacks, one
defineStackcall each, same object (probe_task) and page (probe_home) in all five.Row E is the defect on its own terms:
target: 'probe_nowhere'names neither a page nor an object nor anything else in the stack. Authored on a registered action it is a build error (row C). Authored inline on a button it builds clean, ships, and fails only when a user clicks — objectui'suseActionModalmodalHandleranswers…i.e. a silent-until-clicked dead button, which is exactly the class row C exists to prevent.
The D/A split is the second half, and is #6739's question rather than this one's: the same
type+ the sametargetstring gets opposite verdicts depending only on where the action was declared.Reproduction
Self-contained, no repo fixtures —
defineStackfrom@objectstack/specwith a manifest, one object, one page, and the action authored each way. The inline shape is the one PR #6737 shipped for the showcase home CTA (examples/app-showcase/src/ui/pages/index.ts:95).Why it matters
Inline actions are the shape the AI-authoring path emits most readily — a button with its behaviour written right there, no separate registry entry. That is precisely the surface that most needs authoring-time rejection, and it is the one surface the cross-reference walk does not visit. "Declared = enforced" currently holds for registered actions and not for inline ones.
flowtargets have the same hole (the same loop checksaction.type === 'flow'), so an inline flow action naming a nonexistent flow is also unvalidated.Direction
Walk page/region/component trees for inline actions and subject them to the same cross-reference checks as registered ones — same rule, same message, one more traversal. What the modal branch should accept comes from #6739.