feat(spec)!: composeStacks objectConflict: 'merge' refuses object pairs whose object-level collections cannot be merged (#14848) - #16079
Conversation
…ections (#14848 WIP) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…WIP) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…ject-conflict-merge-refuses-collections
📓 Docs Drift Check6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 974f9aa842e4f5a457d5b9e50528617d3a2c5fdf && git checkout 974f9aa842e4f5a457d5b9e50528617d3a2c5fdf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c2520416cff04575dd3def789dd04a5745e3912b 29e4671f6b1d95ac7fa79deec325c15117dfd3e5 && git checkout -B drift-repro c2520416cff04575dd3def789dd04a5745e3912b && git merge --no-ff 29e4671f6b1d95ac7fa79deec325c15117dfd3e5
node scripts/docs-audit/affected-docs.mjs --json c2520416cff04575dd3def789dd04a5745e3912b |
Fixes #14848
composeStacks({ objectConflict: 'merge' })now refuses two same-name objects that both declare an object-level collection other thanfieldswith different values — the refusal shape'error'uses, naming the object, the colliding collection and both stacks by manifest id — instead of the later object's array replacing the earlier package's wholesale in silence.fieldskeeps its documented shallow merge. Clause-②: yes — a published option value's accept set narrows; theneeds:contract-reviewcarrier is the seat's to hang and clear.Ruling (director seat, comment 5542636547 on #14848, executed as written)
Maintainer provenance as recorded there: decision batch #38 item 5, verbatim reply 「同意」.
What changed (
packages/spec/src/stack.zod.ts)mergeObjects'merge'arm: before the spread,refuseUnmergeableCollectionswalks the derived collection-key set; a key the later object declares that the composed object already carries with a different value throws. Per composed object it tracks the first stack that declared each collection (collectionOwner), so the refusal names that stack against the disagreeing later one — the readingcomposeSingleValuealready gives top-level keys.deepEqualAuthored, the same predicate the top-level composer uses. This is what keeps two built stacks that each bind one standalone action to the same object — identical copies on both objects — flowing to the cross-stack action-key check (composeStacksconcatenatesactionsacross packages with no same-scope duplicate-key check — two packages each declaring oneglobal:NAMEcompose into one collapsed handler key #14662), which refuses them by name as before.undefinedon the later object is read as no declaration everywhere: it neither counts as a differing value nor erases the earlier array (the bare spread used to let it; zod v4 keeps an explicitly-undefined input key as an own property, so a built stack can carry one).actionsOwnerreads the same defined-value check.label,sharingModel,enable,access, ...) keep later-wins — the ruling narrows collections only; the docblock says so. The config-object half of that boundary is filed as 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 (recorded, not addressed here).ConflictStrategySchemadocblock for'merge'states the rule;composeStacks' docblock, example and the collision walk's docblock are updated in the same terms. No.describe()string moved and no export was added or removed (check:api-surfacegreen, 0 added / 0 removed).The collection-key set — derived, not listed
objectCollectionKeys()walksObjectSchema.shapeat first use and keeps every key whose declared type is an array or a record once the optional/default/nullable/readonly/catch wrappers are stripped, reading through alazyor apipe, and counting a union when any member is a collection (requiredPermissionsadmitsstring[]beside its object form).fieldsis the one exclusion, by rule. Measured on this tree, in shape order:indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actionsTwo notes for the reviewer: the ruling's illustrative
validation/hooksare not object-level keys onObjectSchema— the real names arevalidations, and there is no object-levelhooks(an undeclared key is refused by the strict parse before composition sees one, so it is no member either); and the string-array keys (highlightFields,searchableFields) are members by the same measurement — a latersearchableFieldsreplacing an earlier one is the same silent loss. A fixed-shape config object (wrapper-strippedobject) is not a collection and stays on the scalar rule (see #16075).The test pins the set against the shape in both directions with an independent walk (every collection key refuses; every other key composes with later-wins) and carries the literal list as the reviewer's copy — a collection key added to the object schema joins the refusal without an edit to the composer and shows up as a one-line test diff.
Refusal message shape
The existing
'error'message is unchanged character for character (pinned), and'override'is untouched.The #14854 pins, flipped (reviewer's note 5521297578)
compose-stacks-action-key-collision.test.ts— each flipped arm is a refusal pin asserting the object, the collection and both ids, not a baretoThrow:'override'keeps the acceptance pin (B's array replaces A's);'merge'is the refusal pin. A third pin keeps the identical-embedded pair accepted under both strategies, carried once.[boundToSharedB(), embeddedA()]) and its reverse: both built objects carry a differentactionsarray, so under'merge'both orders are refused at the object merge (the action-key envelope never appears);'override'keeps its two original pins (collision one way, acceptance the other).boundA() + boundB(), identical built copies): unchanged under both strategies — the object merge passes the identical copies and the action-key check refuses, as before.compose-stacks-action-echo.test.ts(#14847): the three-stack'merge'arm relied onemb1/emb2being silently dropped — exactly the refused case — so it is a refusal pin now; the echo-once reading stays on'override'.actionsOwnerkeeps its per-object contract (#14854): a differingactionspair is refused before ownership could matter, and an identical pair is one declaration whichever stack it is attributed to (docblock says so).New pins —
compose-stacks-merge-collection-refusal.test.ts(62 tests)The card's own case with the full message asserted; each of
indexes(strict-parsed),validations,listViews,fieldGroups,searchableFields,highlightFields,activityMilestones,requiredPermissionsrefused by name; first-declarer naming across three stacks; manifest-less inputs named by position; thefields-only merge accepted (later fields win, earlier kept, later label wins); identical collections carried once; an absent later key keeps the earlier collection; an explicitundefinedneither refuses nor erases; the built-copies pair reaching the action-key check; the default'error'message pinned verbatim;'override'unchanged; the two-direction shape pin over every key ofObjectSchema.shape.Call sites (M1, re-measured)
Zero non-test call sites pass
objectConflictinpackages/**,examples/**,apps/**onorigin/main@53cbad9f7and again @c463d03e0— every real caller takes the default'error'. No stop condition reached.content/docs/getting-started/examples.mdxmentions the option generically and stays; no prose inpackages/**,content/docs/**orskills/**describes the old behaviour ("later fields win" hits all describe thefieldsmerge, which still holds).Verification (all under
scripts/pm/os-verify-lock.sh, slotissue-14848; exit codes captured before any pipe)pnpm --filter @objectstack/spec build— VERDICT command-exit 0 (check-dts-emitted34/34).pnpm --filter @objectstack/spec test—Test Files 477 passed (477),Tests 12826 passed (12826)(post-merge run at29e4671f6; pre-merge run 479 / 12902, both green).pnpm --filter @objectstack/spec typecheck— exit 0; itscheck:test-typecheckleg compiles the test layer undertsconfig.test.json, so the new pins are covered.pnpm --filter @objectstack/spec check:generated—All 15 generated artifacts are up to date(one earlier red was the [finding]check:api-surfacerefuses on file mtimes while the spec build short-circuits on a content hash — a merge that re-checks-out an unchanged spec source yields a false "packages/spec/dist is OLDER than packages/spec/src" refusal #14985 mtime shape after a byte-identical restore; a rebuild cleared it).refuseUnmergeableCollections(...)call removed from the'merge'arm (anchor count 1 → 0, marker 0 → 1, blob differs from HEAD); the three pin files wentTests 25 failed | 74 passed— every refusal pin red, every acceptance pin green; restoregit checkout HEAD -- pathproven by blob hash equal toHEAD:path(370b1218…),git diff HEADempty, porcelain empty. The subject resolves from source (./stack.zod), so no dist leg applies.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(post-merge tree29e4671f6) derived 72 commands; all 72 ran;--ranreconciliation: 72 derived, 72 run, 0 unrun. 70 green after building the@objectstack/lintclosure forcheck:doc-formula-expressions; two are NOT MEASURED locally by their own text (pnpm check:dual-build-cjs-loads,pnpm check:type-check-debt— both exit 3 "nothing was measured", prerequisite is a whole-repopnpm build) and are declared to CI'sLint & Repo Gates.29e4671f6:eslint --no-inline-config --format jsonover the 4 changed TS files — 4 files, 0 errors, 0 warnings; the population is the repo's owneslint.config.mjs, which states noparserOptions.projectand no typed rules (line 328), so a change to these files cannot move any untouched file's verdict.turbo ls --affected(merge-basec463d03e0) lists 75 packages — spec plus every consumer (cli closure 58 packages, runtime 31; beyond the local cap).check:api-surfacegreen proves no export was added, removed or narrowed, so the consumers' compile face did not move; their typecheck and tests go to CI's requiredTypeScript Type Check/Test Core.origin/mainmerged atc463d03e0viascripts/pm/os-regen-merge.sh(5 commits, no conflict, no regeneration owed; feat(spec): export COMPOSE_KEY_DISPOSITIONS and STACK_DEFINITION_KEYS — the artifact envelope's top-level key set and each key's composition rule, derivable instead of hand-copied #16051 has not landed —COMPOSE_KEY_DISPOSITIONSis not exported onorigin/main).Changeset
.changeset/compose-merge-refuses-object-collections.md—@objectstack/specminor with the launch-window BREAKING banner andadr-0087: not-required (no-migration-prescription)(nothing authorable renamed, retired or re-typed; the refusal text carries the whole prescription; zero call sites).check:adr-0087-registration,check:empty-changeset,check:changeset-no-majorall green.Not addressed here
#14662 / #14854 remain the landed action-key check and are only re-pinned; #5005 remains the top-level class; #16075 remains open (config-object half of the boundary).
🤖 Generated with Claude Code
https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
Generated by Claude Code