Skip to content

composeStacks objectConflict: 'merge' — a fixed-shape config object (enable, access, protection, tenancy, lifecycle, userActions, publicSharing, external) the later object declares still replaces the earlier one wholesale (residue of the #14848 collection-only ruling) #16075

Description

@claude

Found while implementing #14848 (the objectConflict: 'merge' collection refusal); out of scope there by the ruling's own boundary and recorded here instead. Measured on origin/main @ c463d03e0, packages/spec/src/stack.zod.ts mergeObjects.

What was measured

The #14848 ruling (comment 5542636547, option 4) narrows objectConflict: 'merge' for object-level COLLECTIONS only: every key of ObjectSchema whose declared type is an array or a record (actions, indexes, listViews, validations, ... — fields excepted) is refused when both objects declare it with different values. The PR landing it derives that set from the shape and states, in the docblock, that everything else keeps later-wins.

"Everything else" includes eight FIXED-SHAPE CONFIG OBJECTS on ObjectSchema (measured over ObjectSchema.shape, wrapper-stripped type object): enable, access, protection, tenancy, lifecycle, userActions, publicSharing, external. Under 'merge' a later object that declares one replaces the earlier declaration wholesale — member by member gone — with nothing said:

a = defineStack({ manifest: { id: 'com.example.a' }, objects: [{ name: 'shared', ..., enable: { trackHistory: true } }] })
b = defineStack({ manifest: { id: 'com.example.b' }, objects: [{ name: 'shared', ..., enable: { apiEnabled: true } }] })
composeStacks([a, b], { objectConflict: 'merge' }).objects[shared].enable   // { apiEnabled: true } — trackHistory is gone, no warning

For access (ADR-0066 D2 exposure posture) and protection this is the security-downgrade shape composeSingleValue refuses at the top level for api / server: an add-on package switches off a core package's posture by declaring its own.

Why it is recorded separately

The ruling's text and its four-facet analysis speak of collections ("actions, validation, hooks, indexes, ..."), and the dispatch clause for the fix says explicitly that scalars and labels stay later-wins — "the ruling narrows collections only". A config object is neither a collection of authored entries nor a scalar; treating it as a collection would widen the ruling, treating it as a scalar leaves the loss. The fix keeps to the ruling and names the boundary in the docblock; whether the boundary should move is a decision, not an implementation detail.

Reach

Zero non-test call sites pass objectConflict in this tree (measured for #14848 over packages/**, examples/**, apps/**); the default 'error' refuses the pair outright. Reachable only by an author who opts into 'merge'.

Options (for triage, not decided here)

  1. Extend the 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 refusal to fixed-shape config objects both objects declare differently (the derivation would count wrapper-stripped object types too; fields stays the one merge). Same message shape, same identical-passes reading.
  2. Leave later-wins and say so in the 'merge' describe text (the 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 PR already says it for scalars and config objects).

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

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions