Found while implementing #14124 (defineStack refuses two actions that resolve to one scope-qualified runtime key). Measured on main @ 2aa8456cf with that refusal already in place in packages/spec/src/stack.zod.ts.
What was measured
composeStacks never routes its output through validateCrossReferences — it merges the collections (actions: 'concat' in the collection strategy table) and returns mergeActionsIntoObjects(composed) directly. Each input has passed defineStack on its own, so each is duplicate-free WITHIN itself, but the composition is not checked:
// two packages, each legal on its own, each declaring a global action named `shared_refresh`
composeStacks([a, b]) : ACCEPTED
out.actions = ["global:shared_refresh", "global:shared_refresh"]
The runtime registers and dispatches every action by the exact key objectName:name (global for an object-less action), so the composed artifact collapses the two handlers to one key — the same dead-button outcome #14124 refuses at defineStack, arriving one composition step later. examples/app-multi-package is the shipped shape that goes through this seam (composeStacks([ordersStack, coreStack], { manifest: 'preserve' })); it carries no colliding key today (corpus pass in the #14124 PR).
Adjacent precedent
composeStacks already owns a duplicate policy for OBJECT names (objectConflict: 'error' | 'override' | 'merge', default 'error'). Actions have no equivalent: 'concat' is the whole policy.
Suggested direction
Apply the same scope-qualified key check to the composed action set (naming the two source stacks by manifest id), or route the composed output through the walk defineStack runs. Whether a cross-package collision should be an error by default or governed by a strategy like objectConflict is the decision to make. #14124's triage deliberately kept composeStacks out of that card's scope, so this is recorded here rather than widened there.
Filing unassigned for triage.
Generated by Claude Code
Found while implementing #14124 (
defineStackrefuses two actions that resolve to one scope-qualified runtime key). Measured onmain@2aa8456cfwith that refusal already in place inpackages/spec/src/stack.zod.ts.What was measured
composeStacksnever routes its output throughvalidateCrossReferences— it merges the collections (actions: 'concat'in the collection strategy table) and returnsmergeActionsIntoObjects(composed)directly. Each input has passeddefineStackon its own, so each is duplicate-free WITHIN itself, but the composition is not checked:The runtime registers and dispatches every action by the exact key
objectName:name(globalfor an object-less action), so the composed artifact collapses the two handlers to one key — the same dead-button outcome #14124 refuses atdefineStack, arriving one composition step later.examples/app-multi-packageis the shipped shape that goes through this seam (composeStacks([ordersStack, coreStack], { manifest: 'preserve' })); it carries no colliding key today (corpus pass in the #14124 PR).Adjacent precedent
composeStacksalready owns a duplicate policy for OBJECT names (objectConflict: 'error' | 'override' | 'merge', default'error'). Actions have no equivalent:'concat'is the whole policy.Suggested direction
Apply the same scope-qualified key check to the composed action set (naming the two source stacks by manifest id), or route the composed output through the walk
defineStackruns. Whether a cross-package collision should be an error by default or governed by a strategy likeobjectConflictis the decision to make. #14124's triage deliberately keptcomposeStacksout of that card's scope, so this is recorded here rather than widened there.Filing unassigned for triage.
Generated by Claude Code