Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .changeset/compose-merge-refuses-object-collections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
"@objectstack/spec": minor
---

feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses object pairs whose object-level collections cannot be merged (#14848)

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is renamed, retired or re-typed: every object key, every `composeStacks` option and the `ConflictStrategySchema` enum (`'error' | 'override' | 'merge'`) parse exactly as before, so `objectstack migrate meta` has nothing to rewrite. What narrows is the ACCEPT SET of one option value at composition time — two stacks whose same-name objects both declare an object-level collection with different values are now refused under `'merge'` where they used to compose with the earlier stack's entries silently dropped. The refusal text carries the whole prescription (declare the collection in one stack, make the declarations identical, or use `'override'`), and the repository measures zero non-test call sites passing `objectConflict` at all (see below), so there is no stored artifact and no authored file for a migration to act on. -->

**BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })`
— shipped as `minor` under the repo's launch-window convention for breaking
changes. Maintainer ruling 2026-09-04 on #14848 (director decision batch #38
item 5, verbatim 「同意」): option 4, `'merge'` **refuses** what it cannot merge
instead of dropping it.

**What changed.** `'merge'` was implemented as
`{ ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } }`:
`fields` was the only key merged, and every other key the later object carried
— `actions`, `indexes`, `listViews`, `validations`, … — replaced the earlier
package's value wholesale, with nothing at compose, build or boot saying so.
Two packages each embedding an action on one shared object composed to the
later package's array alone; the earlier package's action was gone.

Now, when both objects declare an object-level **collection** other than
`fields` with different values, `composeStacks` throws — the refusal shape
`'error'` uses — naming the object, the colliding collection and both stacks
by manifest id:

```
composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1).
objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged: the later declaration would replace the earlier one wholesale, silently dropping every entry 'com.example.a' (stack #0) wrote.
Fix: declare 'actions' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack.
```

The refusal set is **derived from `ObjectSchema`'s shape** — every key whose
declared type is an array or a record (through optional/default wrappers and
into a union's members), except `fields` — not hand-listed, so a collection key
added to the object schema joins the refusal without an edit to the composer.
Today that set is `actions`, `activityMilestones`, `fieldGroups`,
`highlightFields`, `indexes`, `listViews`, `requiredPermissions`,
`searchableFields`, `validations`.

**What did not change.**

- `fields` keeps its documented shallow merge (later fields win, earlier
fields kept).
- **Identical** declarations on both sides pass through and are carried once
— the same reading `composeStacks` already gives identical top-level values
— so two built stacks that each bind one standalone action to the same
object (identical copies) still reach the cross-stack action-key check
(#14662) and are refused there, by name, as before.
- A scalar or fixed-shape config object the later object declares (`label`,
`sharingModel`, `enable`, `access`, …) still replaces the earlier one: the
ruling narrows collections only, and the docblock now says so.
- The default `'error'` and `'override'` are untouched, message for message.
- An explicit `undefined` on the later object is read as no declaration — it
neither counts as a differing value nor erases what the earlier stack
declared (the bare spread used to let it).

**Who is affected.** Measured on `origin/main` @ `53cbad9f7`: **zero** non-test
call sites in `packages/**`, `examples/**`, `apps/**` pass `objectConflict` at
all — every real caller takes the default `'error'`. An external author who
opted into `'merge'` and relied on the later package's collection winning
silently now gets the refusal above; the fix is the one it names.

The `ConflictStrategySchema` docblock for `'merge'` states the rule.
42 changes: 30 additions & 12 deletions packages/spec/src/compose-stacks-action-echo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
* defineStack(a) (a lone built input) : REFUSED 'a_item:dup_x' is declared twice ← #14686's landed pin
* ```
*
* The three-stack `merge` row above is a refusal since #14848: `'merge'` no
* longer hands `actions` to the later object when both declare it differently
* (the lost `emb1` / `emb2` were exactly that), so that arm pins the refusal
* and the echo-once reading stays on `'override'`, whose semantics did not
* move.
*
* The discriminator is IDENTITY against the standalone list, not equality
* (the triage ruling on the card): the only way an entry of `stack.actions` is
* the very same object as an entry of `object.actions` is that a previous merge
Expand Down Expand Up @@ -116,18 +122,30 @@ describe('composeStacks - a bound standalone action appears once in the composed
actions: [act(`b${n}`, { objectName: 'shared' })],
});

it.each(['override', 'merge'] as const)(
"with three stacks under objectConflict: '%s', the surviving object carries the surviving stack's declared actions plus each concatenated standalone once",
(objectConflict) => {
const out = composeStacks([s(1), s(2), s(3)], { objectConflict });
expect(keysOf(out).top).toEqual(['shared:b1', 'shared:b2', 'shared:b3']);
// s3's object survives with its built array as-is (embedded `emb3`, then
// the echo of its own `b3`); s1's and s2's bound actions join it once
// each, in concatenation order. The lost `emb1` / `emb2` are the object
// strategy's own semantics, not this merge's. Before: `b3/BOUND` twice.
expect(keysOf(out).embedded).toEqual({ shared: ['emb3/EMB', 'b3/BOUND', 'b1/BOUND', 'b2/BOUND'] });
},
);
it("with three stacks under objectConflict: 'override', the surviving object carries the surviving stack's declared actions plus each concatenated standalone once", () => {
const out = composeStacks([s(1), s(2), s(3)], { objectConflict: 'override' });
expect(keysOf(out).top).toEqual(['shared:b1', 'shared:b2', 'shared:b3']);
// s3's object survives with its built array as-is (embedded `emb3`, then
// the echo of its own `b3`); s1's and s2's bound actions join it once
// each, in concatenation order. The lost `emb1` / `emb2` are the object
// strategy's own semantics, not this merge's. Before: `b3/BOUND` twice.
expect(keysOf(out).embedded).toEqual({ shared: ['emb3/EMB', 'b3/BOUND', 'b1/BOUND', 'b2/BOUND'] });
});

it("with three stacks under objectConflict: 'merge', the composition is refused at the object merge — each built object carries a different `actions` array (#14848)", () => {
// s1's `shared` carries [emb1, b1/BOUND], s2's [emb2, b2/BOUND]: two
// declarations `'merge'` used to resolve by replacement, dropping `emb1`.
let msg: string | null = null;
try {
composeStacks([s(1), s(2), s(3)], { objectConflict: 'merge' });
} catch (e) {
msg = (e as Error).message;
}
expect(msg).toContain(
"composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared " +
"with different values by 'com.example.s1' (stack #0) and 'com.example.s2' (stack #1).",
);
});

it("still binds another stack's standalone to an object it does not own — the add-on shape — once", () => {
const core = defineStack({ manifest: mf('com.example.core'), objects: [obj('core_item', [act('archive')])] });
Expand Down
109 changes: 77 additions & 32 deletions packages/spec/src/compose-stacks-action-key-collision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@
* P3 embedded(A on shared) + embedded(B on shared) : merge/override ACCEPTED — B's array REPLACES A's
* ```
*
* Since #14848 `objectConflict: 'merge'` refuses two objects that declare
* DIFFERENT `actions` (any object-level collection) at `mergeObjects`, before
* this check runs — so the `'merge'` rows above that relied on B's array
* replacing A's (P3, P5b with A last, and its reverse) are refusals of THAT
* rule now, pinned here as such and in full in
* `compose-stacks-merge-collection-refusal.test.ts`; only `'override'` still
* hands the object wholesale to the later stack. IDENTICAL arrays — the built
* copies two stacks each binding one standalone to the same object carry (P4)
* — pass the object merge and reach this check as before.
*
* Every refusal case pins the full line — the key, both manifest ids, and
* where each declaration sits — rather than `toThrow()` alone: a bare throw
* cannot tell "refused for the right reason" from "refused because the fixture
Expand Down Expand Up @@ -160,9 +170,11 @@ describe('composeStacks - two stacks declaring one global action key', () => {

describe('composeStacks - object-scoped keys across stacks, judged on what the composition carries', () => {
// Both stacks declare object `shared` and bind a standalone `dup_s` to it.
// `objectConflict: 'merge'` / `'override'` keep the later stack's object
// (whose built copy of its own bound action is the echo in `objects[...]`),
// and both standalone declarations concatenate — two stacks, one key.
// `'override'` keeps the later stack's object (whose built copy of its own
// bound action is the echo in `objects[...]`); under `'merge'` the two built
// copies are IDENTICAL, so the object merge passes them (#14848) and the
// composed object carries one. Both standalone declarations concatenate
// either way — two stacks, one key, and this check is what refuses.
const boundA = () => defineStack({ manifest: mf('com.example.a'), objects: [obj('shared')], actions: [act('dup_s', { objectName: 'shared' })] });
const boundB = () => defineStack({ manifest: mf('com.example.b'), objects: [obj('shared')], actions: [act('dup_s', { objectName: 'shared' })] });

Expand All @@ -189,42 +201,75 @@ describe('composeStacks - object-scoped keys across stacks, judged on what the c
const embeddedA = () => defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', [act('dup_m')])] });
const boundToSharedB = () => defineStack({ manifest: mf('com.example.b'), objects: [obj('shared')], actions: [act('dup_m', { objectName: 'shared' })] });

it.each(['merge', 'override'] as const)(
"refuses an embedded action the composed object carries from one stack beside the other stack's standalone bound to it (objectConflict: %s, embedding stack last)",
(objectConflict) => {
// B first, A last: A's object wins the merge / override, so the composed
// `shared` carries A's embedded `dup_m` (B's built copy of its own bound
// action is NOT carried — B's object lost); B's standalone joins it at
// `mergeActionsIntoObjects` — two handlers, one key.
const msg = refusal(() => composeStacks([boundToSharedB(), embeddedA()], { objectConflict }));
expect(msg).toContain(ENVELOPE_ONE);
it("refuses an embedded action the composed object carries from one stack beside the other stack's standalone bound to it (objectConflict: 'override', embedding stack last)", () => {
// B first, A last: A's object wins the override, so the composed `shared`
// carries A's embedded `dup_m` (B's built copy of its own bound action is
// NOT carried — B's object lost); B's standalone joins it at
// `mergeActionsIntoObjects` — two handlers, one key.
const msg = refusal(() => composeStacks([boundToSharedB(), embeddedA()], { objectConflict: 'override' }));
expect(msg).toContain(ENVELOPE_ONE);
expect(msg).toContain(
" ✗ Action key 'shared:dup_m' is declared by 2 stacks: " +
"'com.example.b' (stack #0) at stack.actions[0] and " +
"'com.example.a' (stack #1) at objects['shared'].actions[0].",
);
});

it.each([
['embedding stack last', () => [boundToSharedB(), embeddedA()], "'com.example.b' (stack #0)", "'com.example.a' (stack #1)"],
['embedding stack first', () => [embeddedA(), boundToSharedB()], "'com.example.a' (stack #0)", "'com.example.b' (stack #1)"],
] as const)(
"under 'merge' the same pair is refused one step earlier, at the object merge: both objects declare a DIFFERENT `actions` (%s) — #14848, not a collision",
(_order, stacks, holder, later) => {
// B's built object carries its bound copy (`dup_m/BOUND`), A's carries
// the embedded declaration (`dup_m/EMB`): two different arrays on one
// object, which `'merge'` no longer resolves by replacement.
const msg = refusal(() => composeStacks(stacks(), { objectConflict: 'merge' }));
expect(msg).toContain(
" ✗ Action key 'shared:dup_m' is declared by 2 stacks: " +
"'com.example.b' (stack #0) at stack.actions[0] and " +
"'com.example.a' (stack #1) at objects['shared'].actions[0].",
"composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared " +
`with different values by ${holder} and ${later}.`,
);
expect(msg).not.toContain('action key');
},
);

it.each(['merge', 'override'] as const)(
"accepts the same pair the other way round: the strategy hands `shared` to B, A's embedded action is not carried, one handler remains (objectConflict: %s)",
(objectConflict) => {
// A first, B last: B's built object carries `actions` (the echo of its
// own bound action), so both `'override'` and the `'merge'` spread hand
// the composed object's `actions` to B. A's embedded `dup_m` is not in
// the artifact — the object strategy's own loss, not a collision — so
// only B's handler reaches the runtime key.
const out = composeStacks([embeddedA(), boundToSharedB()], { objectConflict });
const shared = (out.objects ?? []).find((o) => o.name === 'shared');
expect(keysOf(out).top).toEqual(['shared:dup_m']);
// No embedded (object-less) entry survives: every carried declaration is B's bound one.
expect((shared?.actions ?? []).every((a) => a.objectName === 'shared')).toBe(true);
expect((shared?.actions ?? []).length).toBeGreaterThan(0);
},
);
it("accepts the same pair the other way round under 'override': the strategy hands `shared` to B, A's embedded action is not carried, one handler remains", () => {
// A first, B last: B's built object carries `actions` (the echo of its
// own bound action), so `'override'` hands the composed object's `actions`
// to B. A's embedded `dup_m` is not in the artifact — the object
// strategy's own loss, not a collision — so only B's handler reaches the
// runtime key. (`'merge'` refuses this pair — pinned above.)
const out = composeStacks([embeddedA(), boundToSharedB()], { objectConflict: 'override' });
const shared = (out.objects ?? []).find((o) => o.name === 'shared');
expect(keysOf(out).top).toEqual(['shared:dup_m']);
// No embedded (object-less) entry survives: every carried declaration is B's bound one.
expect((shared?.actions ?? []).every((a) => a.objectName === 'shared')).toBe(true);
expect((shared?.actions ?? []).length).toBeGreaterThan(0);
});

// P3 — two stacks each EMBEDDING the same name on one object, as two
// DIFFERENT declarations (the labels differ).
const embedA = () => defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', [act('dup_e', { label: 'Dup E (a)' })])] });
const embedB = () => defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', [act('dup_e', { label: 'Dup E (b)' })])] });

it("accepts two stacks each EMBEDDING the same name on one object under 'override' — the later object's array replaces the earlier (measured), one handler reaches the artifact", () => {
const out = composeStacks([embedA(), embedB()], { objectConflict: 'override' });
expect(keysOf(out).embedded).toEqual({ shared: ['dup_e/EMB'] });
expect(keysOf(out).top).toEqual([]);
expect((out.objects ?? []).find((o) => o.name === 'shared')?.actions?.[0]?.label).toBe('Dup E (b)');
});

it("refuses the same pair under 'merge' — two different `actions` arrays on one object are no longer resolved by replacement (#14848; formerly the P3 acceptance pin)", () => {
const msg = refusal(() => composeStacks([embedA(), embedB()], { objectConflict: 'merge' }));
expect(msg).toContain(
"composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared " +
"with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1).",
);
expect(msg).not.toContain('action key');
});

it.each(['merge', 'override'] as const)(
"accepts two stacks each EMBEDDING the same name on one object — the later object's array replaces the earlier (measured), one handler reaches the artifact (objectConflict: %s)",
"accepts two stacks embedding the IDENTICAL declaration on one object — one declaration, carried once, one handler (objectConflict: %s)",
(objectConflict) => {
const a = defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', [act('dup_e')])] });
const b = defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', [act('dup_e')])] });
Expand Down
Loading
Loading