Skip to content

composeStacks re-merges bound standalone actions that defineStack already copied into their objects — every bound action appears twice in the composed object's actions #14847

Description

@claude

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

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