Skip to content

Commit f7a3495

Browse files
feat(spec)!: composeStacks objectConflict 'merge' refuses a fixed-shape config object both objects declare differently (#16075) (#19915)
Fixes #16075 Clause-②: yes Executes ruling `5563452716` on #16075 (director decision batch #61, **option 1**, maintainer reply verbatim 「同意」): under `composeStacks({ objectConflict: 'merge' })`, a **fixed-shape config object** on `ObjectSchema` that both objects declare with different values is **refused**, with the same message shape and the same identical-passes reading #14848 uses for collections. `fields` keeps its merge semantics exactly as #14848 ruled. ## What changed `packages/spec/src/stack.zod.ts`, `mergeObjects`' derivation only: - New `declaresConfigObject(schema)`: a wrapper-stripped `object` type, read through a `lazy` and a `pipe`'s authored side (the same `pipeAuthorableSide` rule the collection walk uses), and deliberately **not** into a union. - `objectCollectionKeys()` became `objectUnmergeableKeys()`: one pass over `ObjectSchema.shape` that maps each key to `'collection'` (the #14848 walk, unchanged) or, failing that, `'config object'`. Still derived, never hand-listed; `fields` still excluded by name. - `refuseUnmergeableCollections` (name kept: one raise site, one code) iterates both kinds. The envelope is unchanged: `STACK_COMPOSE_COLLECTION_CONFLICT`, `status: 422`, `issues: [finding]`. The first line (finding) and the third line (fix) are byte-identical to #14848's. The middle line now lists both kinds and names what is dropped per kind: ``` objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged, and neither is a fixed-shape config object (userActions, external, tenancy, access, lifecycle, enable, publicSharing, protection): the later declaration would replace the earlier one wholesale, silently dropping every member 'com.example.a' (stack #0) set. ``` - Docblocks brought true: the `'merge'` describe text on `ConflictStrategySchema`, `StackComposeCollectionConflictError`, `declaresCollection`, the wrapper set, `pipeAuthorableSide`, `mergeObjects` (which said config objects stay later-wins) and `composeStacks` (prose and `@example`). - `collectComposedActionKeyCollisions` (sibling PR #19903's region) is **not touched**. ## Measured first, on `origin/main` @ `44ce049a8` **Today's behaviour, each of the eight, two stacks, `'merge'`:** every one ACCEPTED, composed to the later declaration wholesale. | key | earlier | later | composed | |:--|:--|:--|:--| | `enable` (strict parse) | `trackHistory: true` + defaults | `apiEnabled: true` (so `trackHistory: false` by default) | later's object, `trackHistory: false` | | `access` (strict parse) | `{ default: 'private' }` | `{ default: 'public' }` | `{ default: 'public' }` | | `enable`, `access`, `protection`, `tenancy`, `lifecycle`, `userActions`, `publicSharing`, `external` (`strict: false`) | `{ left_member: 1 }` | `{ right_member: 2 }` | `{ right_member: 2 }` for all eight | **Derived fixed-shape set vs the ruling's eight:** a runtime walk of `ObjectSchema.shape` (43 keys), wrapper-stripped, finds exactly eight keys whose type is `object`: `userActions`, `external`, `tenancy`, `access`, `lifecycle`, `enable`, `publicSharing`, `protection`. **Equal to the ruling's eight; nothing added or removed since 2026-09-07.** Three further keys carry an `object` only as a union member: `requiredPermissions` (array or object, already a collection), `systemFields` (`false` or an options object) and `titleFormat` (template string or expression object). The last two are not fixed shapes and are outside the ruling's eight, so they stay on later-wins (pinned as the boundary; see Acceptance notes). **Non-test callers passing `objectConflict: 'merge'`:** `git grep objectConflict` over `packages/`, `examples/`, `apps/` at `44ce049a8`: every non-test hit is a doc comment, a message string or the ledger comment. The one non-test `composeStacks` call (`examples/app-multi-package/objectstack.config.ts:68`) passes `{ manifest: 'preserve' }`. **Zero non-test callers**, so triage's escalation clause (p2) does not fire. ## Tests New `packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts`: - The ruling's three: `access` `'private'` then `'public'` refused (envelope `code` + `status` + `issues` + finding line); `access` identical passes and is carried once; `fields` still shallow-merges beside an identical `access`. - The card's `enable` case; each of the eight refused when different and passed when identical; three stacks name the first declarer; earlier-only kept; explicit `undefined` neither refuses nor erases; identity judged on the parsed object; `'override'` unchanged. - **Derivation pin:** the config-object list the refusal enumerates (read from the production composer) equals an independent walk of `ObjectSchema.shape`, and the walk equals the ruling's eight in shape order. - **Arrival pin:** in a fresh module graph with a probe config-object key added to the shape, the unedited composer refuses it and enumerates it; a probe union-with-object key stays later-wins. - **Boundary:** `systemFields` and `titleFormat` object forms, and a scalar, stay later-wins. Updated downstream readers: `compose-stacks-merge-collection-refusal.test.ts` (its docblock asserted config objects stay later-wins; the full-message pin and the both-directions shape pin now cover the second kind) and two comment references in `compose-stacks-collection-pipe-arm.test.ts` to the renamed helper (its regex still matches the new message unchanged). **Firing control** (commit `c61075ec96`, then `stack.zod.ts` restored to `44ce049a8`'s blob, hash `deaf6a024c` verified on disk; restored after, hash `347f597076` equals the HEAD blob, `git diff HEAD` empty): the new file plus the collection file ran **29 failed, 65 passed**. Red: every refusal pin, the derivation pin, the arrival pin, and the collection file's message and both-direction pins. Green on both trees: the acceptance, boundary and literal shape-walk pins. **Local verification at `8f98553d5c`, the final commit:** - `@objectstack/spec`, full suite: `vitest run --project local`: 527 files passed, 15535 tests passed, 1 todo. `--project repo`: 35 files passed, 602 tests passed. - `@objectstack/runtime`, full suite. Its `artifact-collections.test.ts` is the only test outside spec that composes with `'merge'`. `--project local`: 272 files passed, 3800 tests passed, 1 skipped. `--project repo`: 2 files passed, 69 tests passed. No other package's tests pass `objectConflict: 'merge'` (`git grep` over `packages/`). - `pnpm --filter @objectstack/spec run typecheck` (tsc, scripts program, test-layer program): exit 0. The test layer holds its ledger with no new signature. - Build: `turbo run build --filter='@objectstack/runtime^...'`, 29 of 29 tasks. `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up to date against that dist. `git status` is clean after the build, so no generated artifact moved. - Gates: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 82 commands, and I ran all 82. `--ran` reconciles them as **80 run, 2 NOT MEASURED, 0 unrun**; every run gate exited 0. The 2 NOT MEASURED are `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 PREREQUISITE NOT MET because they need a whole-workspace dist. CI owns those. - `check-adr-0087-registration`: `[BREAKING+bang] not-required (no-migration-prescription)`, exit 0. `check-changeset-no-major --event` with this body: "LEVEL AXIS: this PR declares clause-② `yes`, and no package whose `packages/**/src/**` it moves is graded `patch`", exit 0. ## Contract notes - **Changeset:** `@objectstack/spec` `minor` with a `**BREAKING**` banner, the launch-window convention for a breaking narrowing, as in the #14848 precedent (`64bd6a3`) and `.changeset/18239-merge-objects-refusal.md`. - **ADR-0087 disposition:** `adr-0087: not-required (no-migration-prescription)`, the category the #14848 precedent and the four later `composeStacks` / `mergeObjects` refusal changesets carry. Derived, not copied: nothing authorable is renamed, retired or re-typed, no stored metadata changes shape, the refusal carries its own fix, and zero non-test callers pass `objectConflict`. So no semantic entry is owed, and `registry.ts` is not regenerated. `check-adr-0087-registration` reads it as `[BREAKING+bang] not-required (no-migration-prescription)`, exit 0. - **Clause-② spelling:** the line above keeps the **ruled value `yes`**, copied from the claim. The repo's current reader (`scripts/pm/clause2-line.mjs`) spells a pure narrowing `no (narrowing)`: its value asks "does this widen an accept set or enlarge a public surface?", and this diff does neither. Breaking-ness does not depend on the arm here, because the changeset's `**BREAKING**` banner already carries it to the ADR-0087 gate. The level axis is satisfied either way: `yes` requires at least `minor`, and the changeset is `minor`. ## Acceptance notes - `packages/spec/src/api/error-code-ledger.zod.ts:1359`: the ledger comment for `STACK_COMPOSE_COLLECTION_CONFLICT` still describes only the collection trigger. It is incomplete, not false, and it sits outside this card's claimed file surface. Suggested text for whoever next touches the ledger: "under `objectConflict: 'merge'`, an object-level collection other than `fields`, or a fixed-shape config object, is declared with different values by two stacks". Carrier: none. - **Boundary, not a finding:** `systemFields` in its options-object form (`{ tenant: false }` beside `{ audit: false }`) still composes to the later object under `'merge'`, so an earlier package's tenant opt-out is replaced. It is a union, not a fixed shape, and the ruling names eight keys. Moving it is a decision for the seat, not something this derivation should do. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96451ec commit f7a3495

5 files changed

Lines changed: 637 additions & 81 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses a fixed-shape config object both objects declare with different values (#16075)
6+
7+
<!-- 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 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, one step past the #14848 narrowing that answered the same question the same way: two stacks whose same-name objects both declare a fixed-shape config object (`enable`, `access`, `protection`, ...) with different values are now refused under `'merge'` where they used to compose with the earlier declaration silently replaced. The refusal text names the object, the key and both stacks and carries its own fix, no stored metadata row or authored file changes shape, and the repository measures zero non-test call sites passing `objectConflict` at all, so there is no document for a migration to act on. -->
8+
9+
**BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })`
10+
— shipped as `minor` under the repo's launch-window convention for breaking
11+
changes. Maintainer ruling on #16075 (ruling record 5563452716, director
12+
decision batch #61, option 1, verbatim 「同意」): the #14848 refusal extends to
13+
fixed-shape config objects.
14+
15+
**What changed.** #14848 made `'merge'` refuse every object-level
16+
**collection** two stacks declare differently, and left everything else on
17+
later-wins. "Everything else" included eight **fixed-shape config objects** on
18+
`ObjectSchema` — `userActions`, `external`, `tenancy`, `access`, `lifecycle`,
19+
`enable`, `publicSharing`, `protection`. Measured on `main` @ `44ce049a8`
20+
before this change, each of the eight composed to the LATER object's
21+
declaration wholesale, with nothing said: `enable: { trackHistory: true }`
22+
beside `enable: { apiEnabled: true }` lost `trackHistory`, and an add-on
23+
package's `access: { default: 'public' }` switched a core package's
24+
`access: { default: 'private' }` off — the posture downgrade `composeStacks`
25+
already refuses at the top level for `api` / `server`.
26+
27+
Now, when both objects declare one of them with different values,
28+
`composeStacks` throws the refusal it throws for a collection — same code
29+
(`STACK_COMPOSE_COLLECTION_CONFLICT`), same `status: 422`, same three-line
30+
shape — naming the object, the key and both stacks by manifest id:
31+
32+
```
33+
composeStacks conflict: object 'shared' is defined in multiple stacks and its 'access' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1).
34+
objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged, and neither is a fixed-shape config object (userActions, external, tenancy, access, lifecycle, enable, publicSharing, protection): the later declaration would replace the earlier one wholesale, silently dropping every member 'com.example.a' (stack #0) set.
35+
Fix: declare 'access' 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.
36+
```
37+
38+
The config-object half of the refusal set is **derived from `ObjectSchema`'s
39+
shape**, like the collection half — every key whose declared type, through
40+
optional/default wrappers, a `lazy` or a `pipe`'s authored side, is a plain
41+
object and not a collection — so a config object added to the object schema
42+
joins the refusal without an edit to the composer. The collection refusal's
43+
message now lists both kinds; its first and last lines are unchanged.
44+
45+
**What did not change.**
46+
47+
- `fields` keeps its documented shallow merge (later fields win, earlier
48+
fields kept).
49+
- **Identical** declarations on both sides pass through and are carried once
50+
— the reading `'merge'` already gives an identical collection. Because the
51+
strict parse fills a config object's member defaults, "identical" is judged
52+
on the parsed objects: `enable: { apiEnabled: true }` and
53+
`enable: { apiEnabled: true, trackHistory: false }` are the same declaration.
54+
- A config object only the earlier object declares is kept; a later object
55+
that does not declare it (or declares it `undefined`) leaves it in place.
56+
- A **scalar** the later object declares (`label`, `sharingModel`, …) still
57+
replaces the earlier one. So does a key whose type is a **union** admitting
58+
an object beside a non-object form — `systemFields` (`false` or an options
59+
object) and `titleFormat` (a template string or an expression object): a
60+
union is not a fixed shape, and the ruling covers the fixed-shape keys only.
61+
- The default `'error'` and `'override'` are untouched, message for message.
62+
63+
**Who is affected.** Measured on `origin/main` @ `44ce049a8`: **zero**
64+
non-test call sites in `packages/**`, `examples/**`, `apps/**` pass
65+
`objectConflict` at all — the one non-test `composeStacks` call
66+
(`examples/app-multi-package`) passes `{ manifest: 'preserve' }` and takes the
67+
default `'error'`. An external author who opted into `'merge'` and relied on
68+
the later package's config object winning silently now gets the refusal above;
69+
the fix is the one it names.
70+
71+
Clause-②: yes

‎packages/spec/src/compose-stacks-collection-pipe-arm.test.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
/**
44
* [#19150] `declaresCollection` reads a `pipe` on the side the AUTHOR writes.
55
*
6-
* The walker behind `objectCollectionKeys()` — the key set
6+
* The walker behind `objectUnmergeableKeys()` (named `objectCollectionKeys()`
7+
* until #16075 added config objects to it) — the key set
78
* `objectConflict: 'merge'` refuses to combine (#14848) — read only `def.in`
89
* on its `pipe` arm. `z.preprocess(fn, schema)` puts a transform STAGE in `in`
910
* and the real, validated schema in `out`, the opposite of `a.transform(fn)`,
@@ -63,7 +64,7 @@ const NAMES = vi.hoisted(() => ({
6364
}));
6465

6566
// The probe keys ride on `ObjectSchema.shape` because that shape is the ONLY
66-
// input `objectCollectionKeys()` reads. Nothing else in the module graph is
67+
// input `objectUnmergeableKeys()` reads. Nothing else in the module graph is
6768
// replaced: the factory spreads the real module and the real shape.
6869
vi.mock('./data/object.zod', async (importOriginal) => {
6970
const actual = await importOriginal<typeof import('./data/object.zod')>();

‎packages/spec/src/compose-stacks-merge-collection-refusal.test.ts‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,19 @@
1111
* `[archive]`). Maintainer ruling (2026-09-04, option 4): refuse, in the shape
1212
* `'error'` uses, naming the object, the colliding collection and both
1313
* package ids; `fields` keeps its shallow merge; identical declarations pass
14-
* (as `composeSingleValue` passes identical top-level values); scalars and
15-
* fixed-shape config objects stay on later-wins.
14+
* (as `composeSingleValue` passes identical top-level values); scalars stay on
15+
* later-wins. Fixed-shape config objects stayed on later-wins too until
16+
* #16075 (ruling 5563452716, option 1) extended this same refusal to them —
17+
* pinned in `compose-stacks-merge-config-object-refusal.test.ts`; the message
18+
* below enumerates both kinds.
1619
*
1720
* The refusal set is DERIVED from `ObjectSchema`'s shape (every array- or
18-
* record-typed key but `fields`), so the last block pins it against the shape
19-
* in BOTH directions with an independent walk: every collection key refuses,
20-
* every other key composes. The literal list beside it is the reviewer's copy
21-
* — a new collection key on the object schema joins the refusal without an
22-
* edit to `stack.zod.ts`, and shows up here as a one-line diff.
21+
* record-typed key but `fields`, plus every fixed-shape config object), so the
22+
* last block pins it against the shape in BOTH directions with an independent
23+
* walk: every collection or config-object key refuses, every other key
24+
* composes. The literal list beside it is the reviewer's copy — a new
25+
* collection key on the object schema joins the refusal without an edit to
26+
* `stack.zod.ts`, and shows up here as a one-line diff.
2327
*/
2428
import { describe, it, expect } from 'vitest';
2529
import { composeStacks, defineStack, type ObjectStackDefinition } from './stack.zod';
@@ -67,12 +71,29 @@ const COLLECTION_KEYS_IN_SHAPE_ORDER = [
6771
'actions',
6872
] as const;
6973

74+
/**
75+
* The fixed-shape config objects the same refusal covers since #16075, in
76+
* shape order — the second list the refusal prints. Pinned against the shape
77+
* in `compose-stacks-merge-config-object-refusal.test.ts`.
78+
*/
79+
const CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER = [
80+
'userActions',
81+
'external',
82+
'tenancy',
83+
'access',
84+
'lifecycle',
85+
'enable',
86+
'publicSharing',
87+
'protection',
88+
] as const;
89+
7090
const REFUSED = (key: string, holder: string, later: string) =>
7191
`composeStacks conflict: object 'shared' is defined in multiple stacks and its '${key}' is declared with ` +
7292
`different values by ${holder} and ${later}.`;
7393
const WHY = (holder: string) =>
7494
"objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection " +
75-
`(${COLLECTION_KEYS_IN_SHAPE_ORDER.join(', ')}) is not merged: the later declaration would replace the ` +
95+
`(${COLLECTION_KEYS_IN_SHAPE_ORDER.join(', ')}) is not merged, and neither is a fixed-shape config ` +
96+
`object (${CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER.join(', ')}): the later declaration would replace the ` +
7697
`earlier one wholesale, silently dropping every entry ${holder} wrote.`;
7798
const FIX = (key: string) =>
7899
`Fix: declare '${key}' on 'shared' in exactly one of the two stacks, make the two declarations identical, ` +
@@ -236,13 +257,38 @@ describe('the refusal set is derived from ObjectSchema.shape — pinned in both
236257
return false;
237258
}
238259

260+
/**
261+
* [#16075] Independent walk for the second kind: a fixed-shape config
262+
* object — `object` once wrappers are stripped, through lazy/pipe, and
263+
* deliberately NOT into a union (a union admitting a non-object form is not
264+
* a fixed shape). Asked only of a key that is not a collection.
265+
*/
266+
function isConfigObject(schema: unknown, depth = 0): boolean {
267+
if (depth > 8) return false;
268+
const def = (schema as { _zod?: { def?: Record<string, unknown> } })._zod?.def;
269+
const type = def?.type as string | undefined;
270+
if (!type) return false;
271+
if (type === 'object') return true;
272+
if (['optional', 'nullable', 'default', 'prefault', 'readonly', 'nonoptional', 'catch'].includes(type)) return isConfigObject(def!.innerType, depth + 1);
273+
if (type === 'lazy') return isConfigObject((def!.getter as () => unknown)(), depth + 1);
274+
if (type === 'pipe') return isConfigObject(def!.in, depth + 1) || isConfigObject(def!.out, depth + 1);
275+
return false;
276+
}
277+
239278
const shapeKeys = Object.keys(ObjectSchema.shape);
240279
const derived = shapeKeys.filter((k) => k !== 'fields' && isCollection((ObjectSchema.shape as Record<string, unknown>)[k]));
280+
const derivedConfigObjects = shapeKeys.filter(
281+
(k) => k !== 'fields' && !derived.includes(k) && isConfigObject((ObjectSchema.shape as Record<string, unknown>)[k]),
282+
);
241283

242284
it('the literal list equals the shape walk, in shape order (a new collection key on the object schema lands here as a one-line diff)', () => {
243285
expect(derived).toEqual([...COLLECTION_KEYS_IN_SHAPE_ORDER]);
244286
});
245287

288+
it('the config-object literal list equals the shape walk, in shape order (#16075)', () => {
289+
expect(derivedConfigObjects).toEqual([...CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER]);
290+
});
291+
246292
it("'fields' is a record on the shape and is the one collection excluded by rule", () => {
247293
expect(isCollection((ObjectSchema.shape as Record<string, unknown>).fields)).toBe(true);
248294
expect(derived).not.toContain('fields');
@@ -257,13 +303,13 @@ describe('the refusal set is derived from ObjectSchema.shape — pinned in both
257303
};
258304

259305
it.each(shapeKeys.filter((k) => k !== 'name' && k !== 'fields'))(
260-
"'%s': refused under 'merge' iff the shape declares it as a collection",
306+
"'%s': refused under 'merge' iff the shape declares it as a collection or a fixed-shape config object",
261307
(key) => {
262308
const [left, right] = valuesFor(key);
263309
const a = defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', { [key]: left })] }, { strict: false });
264310
const b = defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', { [key]: right })] }, { strict: false });
265311
const msg = refusal(() => composeStacks([a, b], { objectConflict: 'merge' }));
266-
if (derived.includes(key)) {
312+
if (derived.includes(key) || derivedConfigObjects.includes(key)) {
267313
expect(msg).toContain(REFUSED(key, A0, B1));
268314
} else {
269315
expect(msg).toBeNull();

0 commit comments

Comments
 (0)