Repository navigation
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
Description
Activity
Triage:
needs-user-decision·domain:spec·priority:p3. Lands inpackages/spec/src/stack.zod.ts.Triage seat, session
session_01SwJQDFKe8tVit3BXQ9EfR5, R+145, 2026-09-04T15:52Z. Escalated because every live option changes what a published composition function produces — 协议/公开契约变化, which is on the manual floor whatever the facets say.⭐ The card's own confidence gap, measured — and it changes the shape of the answer
The card asks whether cross-package object merging is a capability worth the surface, and leaves that open. Measured on
origin/mainat 15:50Z:reading result non-test call sites passing objectConflict(packages/**,examples/**)0 composeStacksnon-test callers6+ (CLI compile,metadata/src/plugin.ts,plugin-security,plugin-dev,examples/app-multi-package, …)objectConflictdefault (stack.zod.ts:2518)'error'⇒
'merge'has zero measured consumers in this tree. Every real caller takes the default, which refuses the collision outright. The silent loss is reachable only by an author who opts into'merge', and nobody in the repo does.⚠️ ⛔ This does not say the loss is harmless — an external author who reads the describe text and opts in gets it, and the describe text does not warn them. It says the cost of changing'merge'is near zero, which is what usually makes these forks expensive.The options
- Merge object-level collections by name (
actionsfirst), throw on a same-name pair — the shapecomposeFunctionsalready uses. - Keep shallow semantics, WARN once per dropped entry, as
warnMalformedCollectionKeydoes. - Keep as is, make the describe text say it plainly.
⚠️ Added by triage, ⛔ not in the card: make'merge'REFUSE when the later object carries a collection the shallow merge cannot combine.'merge'then means "merge what is mergeable" and says so loudly instead of dropping.
- ① 项目长远合理性(权重 ≥50%,领起推荐) —— 这里的不变量是声明即强制、不静默丢弃:今天两个包各自声明了同名对象上的 action,合成后有一个消失,compose / build / boot 三处都不出声。这正是
composeStackssilently drops every non-array top-level key —api:today,server:as of #4910 #5005 在顶层键上关掉的那一类,只是低了一层。⇒ ③ 单独出局:把静默损失写进文档,损失照旧静默;文档挡不住代码。①在 1 与 4 之间偏 4 —— 「两个包声明同一个对象」在创业阶段更像作者错误而非要支持的能力,而收窄特例比长出一套逐集合的合并规则更符合长远方向。选项 1 要为每种集合定义合并语义、把 owner 模型从 per-object 改成 per-action,那是一条永久增生的路。 - ② 实际业务拉动 —— 实测为零(见上表)。无人传
objectConflict,默认即拒绝。按分歧推荐序:零拉动 ⇒ 荐不扩散,即 ⛔ 不选 1。⚠️ 这也是唯一可能翻转推荐的读数 —— 若维护者知道有外部作者在用'merge'合并带 actions 的对象,②立刻变重,方向转向 1。 - ③ 防 AI 犯错 —— 最锐。
ConflictStrategySchema写的是「Shallow-merge items with the same name (later fields win)」,读起来像在讲fields,⛔ 没有任何一个字说「另一个包的 actions 会被丢掉」。一个 AI 依约推理,推不出这个损失;它写出的合成在运行时少一个动作,而没有任何信号可供自我纠正。响亮拒绝 > 警告 > 文档,这一条把 4 排在 2 前面、把 2 排在 3 前面。 - ④ 创业阶段不扩散 —— 指向 4。选项 1 是新增能力面(逐集合合并语义 + owner 模型改造 + 既有验收 pin 翻转);选项 4 是删掉一条没人用的宽容路径,面变小。已发布零消费的能力不因沉没成本获得豁免。
推荐:4(拒绝),回退 2(警告)。 ①以≥50% 权重领起并指向收窄;②零实测拉动、按分歧推荐序取不扩散;③把响亮排在前面;④同向。⭐ 关键在于零消费者让「拒绝」几乎无代价 —— 通常这类收窄要付的迁移账,这里不存在。
⛔ 不建议 3:它把一个代码问题降级成文字问题,而损失继续静默,是 ③④ 都反对、且恰好最省事因而最容易被选中的那条路。
⛔ 不建议 1,除非 ② 被推翻:它是唯一需要付出永久语义增生的选项,而买到的能力今天无人使用。
置信缺口: ⛔ 未测量仓外作者。@objectstack/spec是已发布包,'merge'是已发布的公开取值,所以「零消费者」只对本树成立,⛔ 对外部不成立 —— 选 4 是一次 minor 破坏性收窄,需要 changeset 明说。⚠️ 一条必须先读的依赖,已在本卡评论里复核席(#14662 契约复审)记下:PR #14854 的碰撞检查把每个合成对象的
actions归属到单一输入 stack(actionsOwner),其 P3 验收 pin 正是依赖'merge'把 actions 整个交给后者。⇒ 选项 1 会让 owner 模型变成 per-action,并把那条 pin 从 accepted 翻成 refused。选项 4 同样会动它(冲突改为拒绝)。任何方向的实施都要先读这条,⛔ 不可把 pin 的变红读作回归。priority:p3:今天零消费者、零用户可见影响;⛔ 但不是 p4,因为对外已发布的取值带着一句读不出真实后果的 describe。
Generated by Claude Code
- Merge object-level collections by name (
Maintainer ruling recorded — 4:
objectConflict: 'merge'refuses when the later object carries a collection the shallow merge cannot combine (actions,validation,hooks,indexes, …); it means "merge what is mergeable" and says so loudly instead of droppingDirector seat, summon #14, session
session_01LsEjuNMPitCHwEfYftZ1um(GitHubos-warren), 2026-09-04. Provenance: maintainer, live PM chat, decision batch #38 (item 5, presented with the recommendation 4, fallback 2), verbatim reply 「同意」. Premise: triage facets 5542258615 — zero non-test call sites passobjectConflictin this tree, the default is'error', so'merge'has no measured consumer; the describe text ("later fields win") does not say the earlier package's actions are discarded.Ruled: 4. In
packages/spec/src/stack.zod.tsmergeObjects, when both inputs declare the same object and the later one carries any object-level collection beyondfieldsthat the shallow merge would replace wholesale,composeStacksthrows (the same refusal shape'error'uses, naming the object, the colliding collection and both package ids).fieldskeeps its documented shallow merge. TheConflictStrategySchemadescribe text for'merge'is rewritten to state the rule. Not taken: 1 (per-collection merge by name — permanent semantic growth for a capability nobody uses; the fallback only if ② is overturned by a known external author), 2 (warn per dropped entry — the fallback), 3 (prose only).Why (① ≥50%): declared = enforced, and never silent loss — the same class #5005 closed one level up. Two packages declaring one object is, at this stage, more likely an authoring error than a capability to grow rules for; narrowing the special case beats growing a per-collection merge model. ② is measured zero, so no expansion; ③ loud refusal > warning > docs; ④ deletes an unused lenient path.
Execution:
domain:speclane, S.⚠️ Read comment 5521297578 first: PR #14854's collision check attributesactionsto a single owning stack, and its P3 pin ("both stacks embed the same name on one object … accepted") depends on'merge'handingactionswholesale to the later object — under 4 that case is now refused at compose, so the pin flips to a refusal pin, ⛔ not a regression. Pins: the refusal for a colliding collection, thefields-only merge still accepted, the default'error'unchanged.Clause-②: yes(a published value's accept-set narrows) ⇒needs:contract-reviewon the PR. Changeset:@objectstack/specminor with a BREAKING banner ('merge'now refuses object pairs whose collections cannot be merged; previously the earlier package's entries were silently dropped) and an adr-0087 disposition comment.State transition, same stroke:
needs-user-decision→pm:queue.domain:spec·priority:p3unchanged. Ledger: director seat post #12708, batch #38.
Generated by Claude Code
Claim: PM loop round R2 —
domain:specseat,session_01M59rPZZFzqhfMUPFqqZTkf, 2026-09-05T22:26Z. One dev, branchclaude/issue-14848-object-conflict-merge-refuses-collections,mode:subagent, tierclaude-fable-5-1. Size S.Clause-②: yes
Ruled card, executed as ruled: director ruling
5542636547(2026-09-04T15:20Z; maintainer verbatim 「同意」 to decision batch #38 item 5) — 4: inpackages/spec/src/stack.zod.tsmergeObjects, when both inputs declare the same object and the later one carries any object-level collection beyondfieldsthat the shallow merge would replace wholesale (actions,validation,hooks,indexes, …),composeStacksunderobjectConflict: 'merge'THROWS with the same refusal shape'error'uses, naming the object, the colliding collection and both package ids;fieldskeeps its documented shallow merge; theConflictStrategySchemadescribe for'merge'states the rule. Not taken: 1 (per-collection merge by name), 2 (warn per dropped entry — the fallback), 3 (prose only).⚠️ Read comment5521297578first: PR #14854's collision check attributesactionsto one owning stack and its P3 acceptance pin incompose-stacks-action-key-collision.test.tsdepends on'merge'handingactionswholesale to the later object — under 4 that case is refused at compose, so the pin flips to a refusal pin, ⛔ not a regression. Clause-② yes: a published value's accept set narrows — the seat runs the in-seat contract review, then hangs and clearsneeds:contract-reviewon both carriers (PR + this card).File face (readings on
origin/main53cbad9f7at 2026-09-05T22:23Z):packages/spec/src/stack.zod.ts—mergeObjectsat:2848(the'merge'arm at:2882–:2883{ ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } }, the'error'refusal message at:2870–:2873, theactionsOwnerdocblock:2842),ConflictStrategySchemaat:2565–:2568('merge' — Shallow-merge items with the same name (later fields win)),objectConflictdefault'error'at:2578; pins — the refusal for a colliding collection (message names object, collection, both package ids), thefields-only merge still accepted, the default'error'unchanged, the #14854 P3 pin flipped to refusal; regenerated reference page (content/docs/references/**for the stack / compose schema) if the describe feeds it; one@objectstack/specchangeset (minor, launch-window**BREAKING**banner —'merge'now refuses object pairs whose collections cannot be merged, previously the earlier package's entries were silently dropped — with the adr-0087 disposition the gate accepts). Hot file:stack.zod.tsis also touched by PR #16051 (#14877, in the landing window, flipping ~22:42Z) in a region-disjoint place (the disposition table:875vsmergeObjects:2848) — the dev mergesorigin/mainviascripts/pm/os-regen-merge.shbefore opening the PR if #16051 has landed, and expects one merge lap otherwise. Dedup: #14662 / PR #14854 (the action-key collision check, landed), #5005 (the top-level key-loss class).
Generated by Claude Code
os-dev-report
{ "issue": 14848, "status": "done", "branch": "claude/issue-14848-object-conflict-merge-refuses-collections", "pr": "https://github.com/objectstack-ai/objectstack/pull/16079", "premise_still_valid": true, "summary": "Executed ruling 4 as written: in packages/spec/src/stack.zod.ts mergeObjects, objectConflict: 'merge' now throws (the 'error' refusal shape, naming the object, the colliding collection key and both stacks via stackLabel/manifest id) when both objects declare an object-level collection other than fields with different values; fields keeps its shallow merge, scalars/config objects keep later-wins (stated in the docblock), identical declarations pass through (measured choice, mirrors composeSingleValue/deepEqualAuthored), and an explicit undefined on the later object is read as no declaration (neither refuses nor erases). The refusal set is DERIVED from ObjectSchema.shape (array/record after wrappers, union members counted, fields excluded, lazily resolved): indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions — the ruling's illustrative 'validation'/'hooks' are not object-level keys (validations is; no object-level hooks). ConflictStrategySchema/composeStacks/collision-walk docblocks rewritten; no export added (api-surface 0/0) so no generated page moved (check:generated 15/15 current). PR #14854 pins flipped per note 5521297578: P3 (made two DIFFERENT embedded declarations) is a merge refusal pin with override acceptance kept plus an identical-embedded acceptance pin; P5b both orders refused under merge, override pins unchanged; P4 (identical built copies) unchanged and still reaches the action-key check; action-echo three-stack merge arm flipped to refusal. New file compose-stacks-merge-collection-refusal.test.ts (62 tests) pins the full message, each collection key by name, first-declarer naming, fields-only merge, identical/absent/explicit-undefined cases, default 'error' verbatim, 'override' unchanged, and the two-direction shape pin. Changeset @objectstack/spec minor with BREAKING banner and adr-0087 not-required (no-migration-prescription). M1 re-measured: zero non-test objectConflict call sites (53cbad9f7 and c463d03e0); no stop condition. Merge lap done: origin/main c463d03e0 merged via os-regen-merge.sh (5 commits, no conflict, nothing to regenerate); #16051 has not landed, so one more merge lap may follow it. Card assignee was set by the dispatch; newest Claim: 5555195352 names this branch; no second claim posted, no assignee written.", "tests": "All heavy runs via scripts/pm/os-verify-lock.sh (OS_VERIFY_LOCK_SLOT=issue-14848), exits captured before any pipe. spec build: VERDICT command-exit 0 (check-dts-emitted 34/34). spec suite post-merge @29e4671f6: 'Test Files 477 passed (477) / Tests 12826 passed (12826)' (pre-merge 479/12902, green); spec typecheck exit 0 incl. check:test-typecheck OK (test layer compiled under tsconfig.test.json). Targeted compose-stacks suites: 7 files, 195 passed. check:generated: 'All 15 generated artifacts are up to date' (one earlier red was the #14985 mtime shape after a byte-identical restore; rebuild cleared it). Ablation with fix committed: removed the refuseUnmergeableCollections call (anchor 1 to 0, marker 0 to 1, blob differs from HEAD) -> 'Tests 25 failed | 74 passed (99)' across the three pin files, every refusal pin red; restore via git checkout HEAD -- path proven by blob hash 370b1218… equal to HEAD:path, git diff HEAD empty, porcelain empty; subject resolves from source (./stack.zod), no dist leg. Gates: dispatch-gates --repo objectstack-ai/objectstack --commands on 29e4671f6 derived 72 commands, all 72 ran, --ran reconciliation '72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN'; 70 green (check:doc-formula-expressions after building the @objectstack/lint closure: '58 cases passed', '22 record-scoped formula example(s) across 428 files'); NOT MEASURED locally by their own text, declared to CI Lint & Repo Gates: pnpm check:dual-build-cjs-loads and pnpm check:type-check-debt (exit 3 'nothing was measured', prerequisite whole-repo pnpm build). check:nul-bytes, check:adr-0087-registration, check:empty-changeset, check:changeset-no-major green; control-byte grep over changed files clean. Lint, proved narrowing @29e4671f6: eslint --no-inline-config --format json over the 4 changed TS files: 4 files, 0 errors, 0 warnings; population = repo eslint.config.mjs, which states no parserOptions.project / no typed rules (line 328) so untouched files' verdicts cannot move. Consumers, declared narrowing: turbo ls --affected (merge-base c463d03e0) = 75 packages (cli closure 58, runtime 31, beyond the local cap); check:api-surface green (0 added/0 removed) proves the consumer compile face unchanged; cli/runtime typecheck + tests declared to CI required TypeScript Type Check / Test Core. CI status at report time: in_progress.", "mcp_calls": "0 — every GitHub read and write went through repo-scoped REST (curl): card + comments + reviewer note read, dedup read (open domain:spec list, 126 cards, control hit #14848 present, no match), finding filed, draft PR opened, report comment posted.", "open_questions": [ { "question": "Attribution footer form: the harness instruction ends PR bodies with the '🤖 Generated with [Claude Code]' line + session URL, while AGENTS.md prescribes the rule-line + session-URL '_Generated by_' block; the PR body carries both and the platform re-placed its rule-line block at the tail on read-back. No action taken; flagging the two-standard conflict only.", "options": ["A keep both (as landed)", "B seat trims one form on the in-seat contract review"], "recommendation": "A, because both surfaces are satisfied and the PR body prose carries the durable attribution regardless." }, { "question": "Reading of an explicit `undefined` on the later object under 'merge': the fix drops explicitly-undefined keys from the later object's spread for ALL keys (scalars included), so the refusal, the spread and actionsOwner share one reading of 'declares'. The ruling narrows collections only; this is a one-line coherence choice, stated in the docblock and PR body, for the contract review to confirm or narrow to collection keys.", "options": ["A keep the uniform reading (as landed)", "B strip undefined only for collection keys, leave scalars on the bare spread"], "recommendation": "A, because zod v4 keeps an explicitly-undefined input key as an own property, so a built stack can carry one, and a scalar erased by undefined is not a declaration any author wrote." } ], "out_of_scope_findings": ["filed as #16075: under 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 — the config-object half of the boundary the collection-only ruling drew; unlabelled, unassigned, for PM triage"] }
Generated by Claude Code
Review verdict — PR #16079 at head 29e4671 (base c252041 after the dev's merge lap at c463d03): ACCEPT · Clause-② contract review PASS (in-seat,
domain:specseat,session_01M59rPZZFzqhfMUPFqqZTkf, this session's model is the CONTRACT_REVIEW_TIER; readings 2026-09-05T23:42Z unless stated).Checklist against ruling 4 (5542636547):
- The refusal:
mergeObjects''merge'arm callsrefuseUnmergeableCollectionsbefore the spread; a collection the later object declares that the composed object already carries with a different value throws in the'error'refusal shape, naming the object, the colliding collection and both stacks by manifest id and position (stackLabel), with the first declarer tracked per composed object so a third stack is named against the stack it disagrees with. The message carries the fix (declare once, make identical, or'override'). Verified in the diff. fieldskeeps its documented shallow merge;'error'and'override'are untouched and their messages pinned verbatim; theConflictStrategySchemadocblock (the enum carries no.describe()string — the JSDoc is the describe text here), thecomposeStacksdocblock and example, and the collision walk's docblock all state the rule. No export added or removed (check:api-surface0 / 0), so no generated page moved.- The refusal set is derived from
ObjectSchema.shape(array or record after the optional / default / nullable / readonly / catch wrappers, throughlazyandpipe, a union counting when any member is a collection),fieldsexcluded by rule, resolved lazily. Measured on this tree:indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions. The ruling's illustrativevalidation/hooksare not object-level keys (validationsis; there is no object-levelhooks, and an undeclared key is refused by the strict parse before composition) — the dev reported this against the ruling's wording rather than following it; accepted as measured. The walk reads zod v4's_zod.definternals; the new test pins the derived set against the shape in both directions with an independent walk and carries the literal list, so a zod-internal rename or a new collection key surfaces as a red pin, not as silent fallback to wholesale replacement. - The feat(spec): composeStacks refuses two stacks whose actions resolve to one scope-qualified runtime key #14854 pins flipped exactly as reviewer note 5521297578 foresaw: P3 is a refusal pin under
'merge'(two different embedded declarations),'override'keeps its acceptance, an identical-embedded pair stays accepted under both; P5b refused both orders under'merge','override'pins unchanged; P4 (identical built copies) unchanged and still reaches 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). The composeStacks re-merges bound standalone actions that defineStack already copied into their objects — every bound action appears twice in the composed object's actions #14847 echo test's three-stack'merge'arm relied on the dropped case and is a refusal pin now. - New pin file
compose-stacks-merge-collection-refusal.test.ts(62 tests): the card's own case with the full message, every collection key refused by name, first-declarer naming across three stacks, manifest-less inputs by position, thefields-only merge, identical / absent / explicit-undefinedcases, default'error'verbatim,'override'unchanged, the two-direction shape pin. - Changeset
@objectstack/specminor with the BREAKING banner and theadr-0087: not-required (no-migration-prescription)disposition, as the ruling prescribed; nothing authorable renamed, retired or re-typed. Call sites re-measured: zero non-testobjectConflictcallers at 53cbad9 and c463d03; no stop condition. - Governed surface:
node scripts/pm/check-governed-merges.mjs --teston the five-file list reads NOT governed — ordinary queue landing. - Dev report 5555561645 read: build green, spec suite 477 files / 12826 tests at 29e4671, typecheck including the test layer, generated artifacts 15/15 current, dispatch-gates 72 derived / 72 run / 0 unrun (two declared to CI by their own text), lint 0 / 0 on the four changed files, ablation (the refusal call removed) went 25 red / 74 green across the three pin files and restored to a proven-identical blob.
Contract review (Clause-② — a published option value's accept set narrows):
composeStacks({ objectConflict: 'merge' })now refuses same-name object pairs whose object-level collections differ, where it used to compose with the earlier stack's entries silently dropped. Every object key, every option and the enum parse as before; the narrowing is at composition time only, carries its own prescription in the refusal text, and has zero measured consumers in this tree. Identical declarations pass through (the minimal true reading of "would replace wholesale" — an identical replacement drops nothing — and the same readingcomposeSingleValuegives top-level keys). PASS.Open questions in the dev report:
- Attribution footer, both forms in the PR body — A, keep both; not a contract matter.
- Explicit
undefinedon the later object stripped before the spread for all keys, scalars included — A, as landed: the ruling's own rationale is never silent loss; an explicitly-undefined key on a built stack (zod v4 keeps it as an own property) is not a declaration any author wrote, so erasing an earlier scalar with it would be the same silent loss one level down. Stated in the docblock; zero measured callers; the maintainer can narrow it to collection keys by a one-line amendment if the uniform reading is unwanted.
Out of scope: finding #16075 (a fixed-shape config object still replaces wholesale under
'merge'— the config-object half of the boundary the collection-only ruling drew) is left to triage.Hot file: PR #16051 (#14877) also changes
packages/spec/src/stack.zod.tsand is still in the merge queue (enqueued 22:43:49Z). Its hunks sit at lines 811–1000 (top-level key dispositions); this PR's sit at 2562–3360 (object-level merge).git merge-tree --write-tree f8d52d540 29e4671f6reads clean at 23:40Z, and the two changes are semantically independent. The queue builds this PR's group on top of #16051's, so the queue run is the combined-tree test; if the queue kicks this PR after #16051 lands, a real merge lap and a delta review at the new head follow before re-enabling auto-merge.Next: carriers cycled at this head (label event stream on PR #16079 and this card), then
check-clause2-carriers.mjs --pair 16079must read ✓; flip to ready + auto-merge once CI on 29e4671 is green.
Generated by Claude Code
- The refusal:
Landing provenance (domain:spec seat,
session_01M59rPZZFzqhfMUPFqqZTkf, 2026-09-06T00:12Z): PR #16079 flipped to ready 00:11:4xZ and auto-merge (squash) enabled 00:11:49Z on head29e4671f6— every check on that head green (33 success / 8 skipped, none red),mergeable_state: clean; theadded_to_merge_queuereading follows on the landing watch (not yet on the timeline at the time of writing). ACCEPT + Clause-② PASS5555571219at29e4671f6;needs:contract-reviewhung and cleared on both carriers (PR #16079, this card) at that head (23:42Z), pair check ✓; governed predicate 0 of 5 paths. Hot file: PR #16051 (#14877) landed onmainas69602e578at 23:50Z with disjoint hunks instack.zod.ts(top-level dispositions vs the object-level merge); the in-memory merge of this head onto that one read clean at 23:40Z, and the merge queue builds this PR's group on the landed tree, so its run is the combined-tree test — a kick would trigger a real merge lap and a delta review before re-enabling. The landing note and theRelease:line follow on MERGED; no follow-up card is owed beyond finding #16075, already filed for triage.
Generated by Claude Code
Landed (domain:spec seat,
session_01M59rPZZFzqhfMUPFqqZTkf, 2026-09-06T01:19Z): PR #16079 merged 2026-09-06T01:18:01Z as64bd6a399through the merge queue (squash;added_to_merge_queue00:12:35Z, about 65 minutes in queue; its group was built on the tree that already carried PR #16051'sstack.zod.tshunks, and the combined-tree run was green — the queue served as the merge lap, no kick). Probe onorigin/mainat 2026-09-06T01:18Z (tip2648774b9):git grep -c refuseUnmergeableCollections origin/main -- packages/spec/src/stack.zod.ts= 3; control 0 atc2520416c;compose-stacks-merge-collection-refusal.test.tsis on main. ACCEPT + Clause-② PASS5555571219at29e4671f6, carriers cycled on both, pair ✓; provenance5555710617.composeStacks({ objectConflict: 'merge' })now refuses same-name object pairs whose object-level collections differ (ruling 4, 5542636547). No follow-up card is owed; finding #16075 (the config-object half of the boundary) stays with triage.Release:
session_01M59rPZZFzqhfMUPFqqZTkf— reason: landed, card closed byFixes #14848— destination: none (closed). Same stroke:pm:dispatchedstripped, assignee cleared.
Generated by Claude Code
- added a commit that references this issue
on Sep 9, 2026 - added 3 commits that reference this issue
on Sep 28, 2026 - added a commit that references this issue
on Oct 9, 2026
Found while implementing #14662 (the cross-stack action-key check in
composeStacks); out of scope there and recorded here instead. Measured onmain@f3ae441fa,packages/spec/src/stack.zod.tsmergeObjects.What was measured
objectConflict: 'merge'is implemented asfieldsis the only key merged. Every other key present on the later object —actions,validation,hooks,indexes, labels,sharingModel, and so on — REPLACES the earlier object's value wholesale, and a key the later object omits keeps the earlier value. For the collections this is silent loss:ConflictStrategySchemadocuments'merge'as "Shallow-merge items with the same name (later fields win)", which reads as a description offields, not as "and the earlier package's actions are discarded". Nothing at compose, build or boot time says the action vanished — the same silent-loss class #5005 closed for top-level keys, one level down.Why it is recorded now
#14662's collision check judges what the composition CARRIES: an embedded action that
'merge'did not carry into the composed object cannot collide, so this loss is invisible to it by design (documented incollectComposedActionKeyCollisions). That is the right boundary for the collision rule, but it means the loss has no reporter at all.Options (for triage, not decided here)
'merge'merges the object-level collections by name (actionsfirst, since it is the one with a runtime key) and throws on a same-name pair — the shapecomposeFunctionsalready uses for handlers; a same-key pair then surfaces throughcomposeStacksconcatenatesactionsacross packages with no same-scope duplicate-key check — two packages each declaring oneglobal:NAMEcompose into one collapsed handler key #14662's check instead of being dropped.warnMalformedCollectionKeyreports a dropped key.Option 1 is the contract-first direction; whether cross-package object merging is a capability worth that much surface is the four-facet call. Filing unassigned for triage.
Generated by Claude Code