Skip to content

Inline (page-element) actions bypass defineStack's action cross-reference validation entirely — a dangling type: 'modal' target builds clean #6889

Description

@os-project-manager

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:buttonproperties.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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions