Repository navigation
Commit f7a3495
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
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
Lines changed: 3 additions & 2 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
| 6 | + | |
| 7 | + | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
| |||
63 | 64 | | |
64 | 65 | | |
65 | 66 | | |
66 | | - | |
| 67 | + | |
67 | 68 | | |
68 | 69 | | |
69 | 70 | | |
| |||
Lines changed: 56 additions & 10 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
14 | | - | |
15 | | - | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
16 | 19 | | |
17 | 20 | | |
18 | | - | |
19 | | - | |
20 | | - | |
21 | | - | |
22 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
23 | 27 | | |
24 | 28 | | |
25 | 29 | | |
| |||
67 | 71 | | |
68 | 72 | | |
69 | 73 | | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
70 | 90 | | |
71 | 91 | | |
72 | 92 | | |
73 | 93 | | |
74 | 94 | | |
75 | | - | |
| 95 | + | |
| 96 | + | |
76 | 97 | | |
77 | 98 | | |
78 | 99 | | |
| |||
236 | 257 | | |
237 | 258 | | |
238 | 259 | | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
239 | 278 | | |
240 | 279 | | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
241 | 283 | | |
242 | 284 | | |
243 | 285 | | |
244 | 286 | | |
245 | 287 | | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
246 | 292 | | |
247 | 293 | | |
248 | 294 | | |
| |||
257 | 303 | | |
258 | 304 | | |
259 | 305 | | |
260 | | - | |
| 306 | + | |
261 | 307 | | |
262 | 308 | | |
263 | 309 | | |
264 | 310 | | |
265 | 311 | | |
266 | | - | |
| 312 | + | |
267 | 313 | | |
268 | 314 | | |
269 | 315 | | |
| |||
0 commit comments