Skip to content

composeStacks objectConflict: 'merge' merges fields only — the later object's actions (and every other key) replace the earlier package's wholesale, silently dropping its embedded actions #14848

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 mergeObjects.

What was measured

objectConflict: 'merge' is implemented as

const merged = { ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } };

fields is the only key merged. Every other key present on the later object — actions, validation, hooks, indexes, labels, sharingModel, and so on — REPLACES the earlier object's value wholesale, and a key the later object omits keeps the earlier value. For the collections this is silent loss:

a = defineStack({ manifest: { id: 'com.example.a' }, objects: [{ name: 'shared', ..., actions: [approve] }] })
b = defineStack({ manifest: { id: 'com.example.b' }, objects: [{ name: 'shared', ..., actions: [archive] }] })
composeStacks([a, b], { objectConflict: 'merge' }).objects[shared].actions = [archive]   // `approve` is gone, no warning

ConflictStrategySchema documents 'merge' as "Shallow-merge items with the same name (later fields win)", which reads as a description of fields, not as "and the earlier package's actions are discarded". Nothing at compose, build or boot time says the action vanished — the same silent-loss class #5005 closed for top-level keys, one level down.

Why it is recorded now

#14662's collision check judges what the composition CARRIES: an embedded action that 'merge' did not carry into the composed object cannot collide, so this loss is invisible to it by design (documented in collectComposedActionKeyCollisions). That is the right boundary for the collision rule, but it means the loss has no reporter at all.

Options (for triage, not decided here)

  1. 'merge' merges the object-level collections by name (actions first, since it is the one with a runtime key) and throws on a same-name pair — the shape composeFunctions already uses for handlers; a same-key pair then surfaces through composeStacks concatenates actions across packages with no same-scope duplicate-key check — two packages each declaring one global:NAME compose into one collapsed handler key #14662's check instead of being dropped.
  2. Keep the shallow semantics and WARN once per dropped collection entry, the way warnMalformedCollectionKey reports a dropped key.
  3. Keep as is and make the describe text say it plainly.

Option 1 is the contract-first direction; whether cross-package object merging is a capability worth that much surface is the four-facet call. Filing unassigned for triage.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions