Found while implementing #14662 (the cross-stack action-key check in composeStacks); out of scope there and recorded here instead. Measured on main @ f3ae441fa, packages/spec/src/stack.zod.ts, two defineStack outputs composed — the shape examples/app-multi-package composes.
What was measured
defineStack ends with mergeActionsIntoObjects(data): every standalone action carrying objectName is COPIED into that object's actions on the way out, and the standalone stays in stack.actions. composeStacks concatenates actions across its inputs and ends with mergeActionsIntoObjects(composed) again — so each bound standalone action is appended to its object a SECOND time, beside the copy the input's own build already put there:
a = defineStack({ manifest: { id: 'com.example.a', ... }, objects: [a_item], actions: [{ name: 'dup_x', objectName: 'a_item', ... }] })
a.objects[a_item].actions = ["dup_x/BOUND"] // one copy: the build's own merge
composeStacks([a, b]).objects[a_item].actions = ["dup_x/BOUND", "dup_x/BOUND"] // two copies of one declaration
Same with three stacks under objectConflict: 'merge' / 'override' (the surviving object's copy plus every concatenated standalone): three entries for two declarations. manifest: 'preserve' inherits it. examples/app-multi-package declares no action, so the shipped fixture does not show it.
Why it matters
The runtime note recorded in the header of packages/spec/src/stack-duplicate-action-key.test.ts (#14686): collectActionDeclarations pushes every embedded entry and dedupes only a standalone against an embedded one, MCP listActions lists both, and bare-name resolveActionByName refuses the ambiguity. A composed artifact therefore lists every bound action twice and cannot resolve it by bare name — and a composed artifact fed back into defineStack is refused by #14686's same-key rule (two embedded twins under one key), so composeStacks output is not round-trippable through the door that built its inputs.
os build does not hit the refusal today because compile.ts validates the lowered stack with ObjectStackDefinitionSchema.safeParse (schema only), not defineStack; the doubled entries reach dist/objectstack.json unremarked.
Suggested direction (for triage, not decided here)
Either mergeActionsIntoObjects becomes idempotent — skip a standalone whose copy is already embedded on the object (identity or structural equality; #14686 deliberately counts an identical hand-written twin as a duplicate, so the skip must be scoped to the build's own echo, e.g. by marking or by comparing against the standalone list rather than treating any equal pair as an echo) — or composeStacks composes the authored shape (standalone actions stripped of their build echo) before merging once. Which of the two is right is a contract question for domain:spec.
#14662's check deliberately counts DISTINCT stacks per key, not sites, precisely so this echo never reads as a collision; that decision is documented in collectComposedActionKeyCollisions and does not fix the doubling.
Filing unassigned for triage.
Generated by Claude Code
Found while implementing #14662 (the cross-stack action-key check in
composeStacks); out of scope there and recorded here instead. Measured onmain@f3ae441fa,packages/spec/src/stack.zod.ts, twodefineStackoutputs composed — the shapeexamples/app-multi-packagecomposes.What was measured
defineStackends withmergeActionsIntoObjects(data): every standalone action carryingobjectNameis COPIED into that object'sactionson the way out, and the standalone stays instack.actions.composeStacksconcatenatesactionsacross its inputs and ends withmergeActionsIntoObjects(composed)again — so each bound standalone action is appended to its object a SECOND time, beside the copy the input's own build already put there:Same with three stacks under
objectConflict: 'merge'/'override'(the surviving object's copy plus every concatenated standalone): three entries for two declarations.manifest: 'preserve'inherits it.examples/app-multi-packagedeclares no action, so the shipped fixture does not show it.Why it matters
The runtime note recorded in the header of
packages/spec/src/stack-duplicate-action-key.test.ts(#14686):collectActionDeclarationspushes every embedded entry and dedupes only a standalone against an embedded one, MCPlistActionslists both, and bare-nameresolveActionByNamerefuses the ambiguity. A composed artifact therefore lists every bound action twice and cannot resolve it by bare name — and a composed artifact fed back intodefineStackis refused by #14686's same-key rule (two embedded twins under one key), socomposeStacksoutput is not round-trippable through the door that built its inputs.os builddoes not hit the refusal today becausecompile.tsvalidates the lowered stack withObjectStackDefinitionSchema.safeParse(schema only), notdefineStack; the doubled entries reachdist/objectstack.jsonunremarked.Suggested direction (for triage, not decided here)
Either
mergeActionsIntoObjectsbecomes idempotent — skip a standalone whose copy is already embedded on the object (identity or structural equality; #14686 deliberately counts an identical hand-written twin as a duplicate, so the skip must be scoped to the build's own echo, e.g. by marking or by comparing against the standalone list rather than treating any equal pair as an echo) — orcomposeStackscomposes the authored shape (standalone actions stripped of their build echo) before merging once. Which of the two is right is a contract question fordomain:spec.#14662's check deliberately counts DISTINCT stacks per key, not sites, precisely so this echo never reads as a collision; that decision is documented in
collectComposedActionKeyCollisionsand does not fix the doubling.Filing unassigned for triage.
Generated by Claude Code