Repository navigation
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
Description
Activity
- addedenhancementNew feature or requestNew feature or request
on Sep 6, 2026 分诊 ·
domain:spec/enhancement/priority:p3/needs-user-decision分诊席位。⛔ 不认领、不派发、不写代码、不合并、不裁决 decision-box 卡。⛔ 本 session 是
claude-opus-5,CONTRACT_REVIEW_TIER硬闸要求 fable。origin/main@932acc3d,2026-09-06T03:52Z。两条读数复现
#14848 已落地:
packages/spec/src/stack.zod.ts:2940的 docblock 逐字写着The object-level keys \objectConflict: 'merge'` refuses to combine (#14848).`默认仍是
'error'::2640—objectConflict: ConflictStrategySchema.default('error'),可达性 —— 卡的关键读数,我用独立口径复现:全
packages packages apps examples中提到objectConflict的非测试文件只有 1 个,就是stack.zod.ts自己的声明。⇒ 零个调用方传
objectConflict。 默认'error'直接拒绝这一对。⇒ 本卡描述的丢失今天无人可达,只有主动 opt-in'merge'的作者才碰得到。定级 p3 —— 可达性是决定性的那条
⛔ 不给 p2,尽管其中两个键(
access/protection)的形状是安全姿态降级:For
access(ADR-0066 D2 exposure posture) andprotectionthis is the security-downgrade shapecomposeSingleValuerefuses at the top level forapi/server: an add-on package switches off a core package's posture by declaring its own.⭐ 这个形状确实值得重视——顶层已经为
api/server拒绝了同一件事,object 层却没有,这本身就是不一致。但今天零可达:没有人传'merge',默认拒绝整对。⇒ p3。⛔ 我没有打
security标签,理由写明以便被推翻:security在本仓标的是实际存在的安全缺陷;一个零可达的潜在降级路径若也打上,会稀释安全队列的分辨力。
⚠️ 升级条款:一旦测得任何非测试调用方开始传objectConflict: 'merge'(或它成为文档推荐的组合方式)⇒ 立即 p2 +security。这条应写进接卡人的验收里。四面框(给裁决者)
要裁的一句话:
ObjectSchema上的八个固定形状配置对象(enable、access、protection、tenancy、lifecycle、userActions、publicSharing、external)在objectConflict: 'merge'下,该按集合(拒绝)还是按标量(后者胜)处理?- ① 长远合理性(≥50%):卡把两难说得很准——「A config object is neither a collection of authored entries nor a scalar」。按集合处理是扩大 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 的裁决;按标量处理则保留静默丢失。⇒ 这正是需要人来划线的地方。
- ② 拉动:零。今天无人可达(见上)。
- ③ 防 AI 犯错:一个 AI 组合两个包,后一个声明
enable: { apiEnabled: true },前一个的trackHistory: true无声消失。若它写的是access,消失的是暴露姿态。⇒ 指向选项 1(拒绝)。 - ④ 创业阶段不扩散:选项 1 是在既有推导里多算一类(wrapper-stripped
object类型);选项 2 是改一段 describe 文案。两者都小。
⚠️ 裁错的代价,不对称:- 裁 1 而应为 2:多拒绝了一些本可合并的组合 ⇒ 作者会看到一条拒绝并手动合并,可见、可回退;
- 裁 2 而应为 1:安全姿态静默降级,且没有任何东西说话 ⇒ 不可见。
⭐ ⇒ 这一侧的错更贵,而且贵在「不会被发现」上。 我把这条摆出来,⛔ 不代替裁决。
车道
domain:spec/ 定型enhancement两条选项的落点都在
packages/spec/src/stack.zod.ts(选项 1 改mergeObjects的推导集,选项 2 改'merge'的 describe 文案)⇒domain:spec。
enhancement:选项 1 收窄一个已发布组合策略的接受集(Clause-② conformance 肢 yes,届时挂needs:contract-review);选项 2 是文案。⛔ 我不预挂needs:contract-review——只有一条臂带它。⛔ 一条边界
⛔ 无论裁哪条,
fields保持它现有的合并语义——#14848 的裁决把它明确排除在集合拒绝之外,本卡不动它。
Generated by Claude Code
Ruling recorded — option 1, extend the #14848 refusal to fixed-shape config objects (director seat, decision batch #61, 2026-09-07)
Maintainer reply, verbatim: 「同意」 (all five batch #61 recommendations adopted).
Ruling. Under
objectConflict: 'merge', a fixed-shape config object onObjectSchema(enable,access,protection,tenancy,lifecycle,userActions,publicSharing,external) that both objects declare with different values is refused, with the same message shape and the same identical-passes reading #14848 uses for collections. An add-on package cannot switch off a core package's exposure or protection posture by declaring its own.fieldskeeps its merge semantics exactly as #14848 ruled.Execution notes. Extend the derivation in
mergeObjects(packages/spec/src/stack.zod.ts) to count wrapper-strippedobjecttypes alongside arrays and records; update the'merge'describe text and the docblock that today says config objects stay later-wins; tests:accessdiffering ⇒ refused,accessidentical ⇒ passes,fieldsstill merges. Clause-② conformance limb yes (narrows a published accept set) — landing PR carriesneeds:contract-review. Escalation clause from triage stands: any non-test caller passing'merge'⇒ p2.Labels:
needs-user-decision→pm:queue. Ledger on #12708 (batch #61).
Generated by Claude Code
Serial reading — this card is the lane's oldest takeable and it is ⛔ NOT taken this round. The collision is on the exact line the ruling's execution notes name.
domain:specexecution seat 2, sessionsession_01UDXER3sdqfeVYpEWZs5mZx, 2026-09-22T07:24Z. ⛔ No label written, ⛔ no claim, ⛔ no assignee touched. The card stayspm:queueand stays first in line.The ruling at
5563452716(option 1, maintainer 「同意」, batch #61) is live and its execution notes are complete, so ⛔ nothing about the card itself blocks it. What blocks it is another PR.The census, re-taken in this act over all 21 open PRs
⛔ Not inherited from an earlier round. Two open PRs touch
packages/spec/src/stack.zod.ts:PR hunk headers on that file #19666 @@ -1408,7,@@ -3213,14 +3214,40,@@ -3937,6 +3964,200,@@ -3961,12,@@ -4077,7,@@ -4125,6,@@ -4140,11,@@ -4270,5#19373 @@ -34,7,@@ -1293,6 +1293,148This card's sites on
origin/main: theobjectConflictoption declaration at:3175(objectConflict: ConflictStrategySchema.default('error'), insideComposeStacksOptionsSchema), the'merge'refusal docblock at:3482and:3563, andmergeObjects' own derivation between them.- fix(spec,objectql): declare the inert-JSON artifact and registry-record package body stages, and stop the record under-reporting functions #19373 — clear. Its two hunks (
:34,:1293) are ~2000 lines away from every site here. ⛔ No hold. ⚠️ feat(spec)!: a multi-package artifact carries its metadata once, in packages[] (#14512) #19666 — NOT clear, and ⛔ the seat is not narrowing this one to a miss. Its@@ -3213,14 +3214,40hunk opens onexport const ComposeStacksOptionsSchema = lazySchema(() => z.object({— the same schema object that declaresobjectConflictat:3175— and it grows that region from 14 lines to 40. The ruling's execution notes require updating the'merge'describe text, which lives in exactly that declaration. ⇒ the two changes are competing for the same lines, not merely the same file.
It also inserts 200 lines at
@@ -3937, which moves every line number after it, so any site this card measures today is stale the moment #19666 lands.⛔ Why this is not narrowed to 「different region, go ahead」
Because this seat did exactly that narrowing on #19580 earlier today and got it wrong — it went from a region-level hold to a single named file and picked the wrong file, which is worse than having stayed at region level. The lesson applies in the other direction too: a region reading that has to be right about a describe string's exact line is not a safer reading than the file-level one, it is a more confident one.
⇒ Held, ⛔ not dispatched. 「相交 ⇒ 排该 PR 之后串行或让行」 — this card serializes behind #19666.
What unblocks it
#19666 merges or closes. Then: re-take this census (⛔ never inherit this one —
@@ -3937,6 +3964,200guarantees the line numbers above are wrong afterwards), re-locateobjectConflict, the'merge'docblock andmergeObjects, and dispatch.⚠️ Two things the next taker should carry, both from the thread rather than from this comment's authority:Clause-②: yes— it narrows a published accept set, so the landing PR carriesneeds:contract-reviewand the round owes an at-tier review. (Thedomain:speclane owes one on every round regardless,yesorno— row C6.)- The escalation clause from triage stands: any non-test caller passing
objectConflict: 'merge'⇒ p2 +securityimmediately. Today the measured reach is zero non-test callers and the default'error'refuses the pair outright, which is the whole reason this sits at p3 despite two of the eight keys (access,protection) carrying a security-downgrade shape the top level already refuses forapi/server.
Generated by Claude Code
- fix(spec,objectql): declare the inert-JSON artifact and registry-record package body stages, and stop the record under-reporting functions #19373 — clear. Its two hunks (
os-support-ai commented
on Sep 23, 2026 CollaboratorMore actionsClaim: PM loop — execute ruling option 1 (
5563452716): underobjectConflict: 'merge', a fixed-shape config object both objects declare with different values is refused, extending the #14848 collection refusal, dispatched at 2026-09-23T17:13Z
Session:session_013RDBh5DqXd2xnLwvHLgLFr
Branch:claude/issue-16075-merge-config-object-refused
Worktree:objectstack-issue-16075
Domain:domain:spec
Seat:domain:spec#1
File surface:packages/spec/src/stack.zod.ts, regions only:mergeObjects(its collection-key derivation) and the docblocks and the'merge'describe text that state config objects stay later-wins, including theobjectConflictdeclaration insideComposeStacksOptionsSchema. Also its tests, an ADR-0087 semantic entry if the repo's rule requires one for this narrowing (registry.tsregenerated, ⛔ never by hand), whatever the generators rewrite, and.changeset/.
Container & model:M,mode:subagent,model: opus (default judgment tier)
Clause-②: yes
Thread-read: 5772704758
Serial constraints cleared: census over all 30 open PRs at 2026-09-23T17:13Z. The fence seat 2 recorded at5772704758was PR #19666, which editedComposeStacksOptionsSchema; it merged 2026-09-23T07:30Z. PR #19373 merged too. The only open PR onstack.zod.tsis this seat's PR #19903 (#19816). Its three hunks sit at:4277–:4330onorigin/main, all insidecollectComposedActionKeyCollisions, ⛔ notmergeObjects(:4154) or theobjectConflictdeclaration (:3571). ⇒ region-disjoint. Both PRs are this seat's; a textual merge between them is resolved againstorigin/main, ⛔ never by dropping a side.The ruling this executes:
5563452716(decision batch #61, option 1, maintainer 「同意」). It coversenable,access,protection,tenancy,lifecycle,userActions,publicSharingandexternal;fieldskeeps its merge semantics. Triage's escalation clause stands: any non-test caller passing'merge'⇒ p2.
Generated by Claude Code
objectstack-fleet commented
on Sep 23, 2026 ContributorMore actionsos-dev-report
{ "issue": 16075, "status": "done", "branch": "claude/issue-16075-merge-config-object-refused", "pr": "https://github.com/objectstack-ai/objectstack/pull/19915", "session": "session_013RDBh5DqXd2xnLwvHLgLFr", "premise_still_valid": true, "summary": "Executed ruling 5563452716 (option 1). In packages/spec/src/stack.zod.ts, mergeObjects' refusal set is now derived over two kinds: the #14848 collections (walk unchanged) and fixed-shape config objects (new declaresConfigObject: a wrapper-stripped object type, read through lazy and a pipe's authored side, deliberately not into a union). objectCollectionKeys() became objectUnmergeableKeys(), which maps each key to its kind. refuseUnmergeableCollections (name, code STACK_COMPOSE_COLLECTION_CONFLICT and status 422 all kept) refuses a differing config object; its first and third message lines are byte-identical to #14848's, and the middle line lists both kinds. Identical declarations pass, fields still merges, and collectComposedActionKeyCollisions (sibling PR #19903) is untouched. The 'merge' describe text on ConflictStrategySchema and the mergeObjects, composeStacks and error-class docblocks now say config objects are refused. MEASURED FIRST on origin/main @ 44ce049a8: all eight keys composed to the later declaration wholesale (strict access 'private' + 'public' composed to 'public'; strict enable {trackHistory:true} + {apiEnabled:true} lost trackHistory). The runtime walk of ObjectSchema.shape (43 keys) finds exactly the ruling's eight wrapper-stripped object keys, so nothing was added or removed. Boundary: systemFields and titleFormat carry an object only as a union member, so they stay later-wins. There are zero non-test callers passing objectConflict at all (only non-test composeStacks call: examples/app-multi-package, { manifest: 'preserve' }), so the p2 escalation clause does not fire. ADR-0087: derived not-required (no-migration-prescription), the #14848 precedent's category. Nothing authorable moves, so no semantic entry is owed and registry.ts is untouched. Changeset: minor + **BREAKING** banner. PR body carries 'Clause-②: yes' as ruled and copied from the claim; it states that clause2-line.mjs spells a pure narrowing 'no (narrowing)', and that the BREAKING banner carries breaking-ness to the ADR-0087 gate either way. Zero label writes, per the dispatch. The ruling's instruction to put needs:contract-review on the PR was not acted on: the dispatch forbids labels, and clause2-line.mjs records that ruling 5770886272 on #19061 retired that label. Commit trailers are model-free per AGENTS.md; the harness's model-named attribution suggestion was not followed.", "tests": "All at final HEAD 8f98553d5c unless noted. (1) New packages/spec/src/compose-stacks-merge-config-object-refusal.test.ts: ruling's three (access differing refused with envelope code STACK_COMPOSE_COLLECTION_CONFLICT + status 422 + issues + finding line; access identical passes; fields merges beside identical access), enable card case, it.each over the eight refused/passed, three-stack owner naming, earlier-only kept, explicit undefined, parsed-identity, override unchanged, DERIVATION pin (the config list the production refusal enumerates == independent shape walk == the literal eight), ARRIVAL pin (vi.doMock fresh module graph with a probe config-object key: refused and enumerated; probe union-with-object key stays later-wins), BOUNDARY (systemFields, titleFormat, scalar later-wins). Updated compose-stacks-merge-collection-refusal.test.ts (docblock asserted config objects later-wins; WHY message pin; both-direction shape pin now covers config objects) and two comment refs in compose-stacks-collection-pipe-arm.test.ts. (2) FIRING CONTROL, after committing c61075ec96: stack.zod.ts restored to BASE 44ce049a8 blob via git restore --source (on-disk hash deaf6a024c == BASE blob; marker grep declaresConfigObject=0), under trap; vitest on the new file + collection file gives 'Tests 29 failed | 65 passed (94)' (every refusal, derivation and arrival pin red; acceptance, boundary and literal shape-walk pins green on both trees); restored with git checkout HEAD --, hash 347f597076 == HEAD blob, git diff HEAD empty, git status clean. No dist involved: the tests import ./stack.zod from src. (3) Build: turbo run build --filter='@objectstack/runtime^...' --concurrency=2 gives 'Tasks: 29 successful, 29 total'; git status clean after; pnpm --filter @objectstack/spec check:generated gives 'All 15 generated artifacts are up to date'. (4) Full suites via os-verify-lock: spec --project local 'Test Files 527 passed (527) / Tests 15535 passed | 1 todo'; spec --project repo '35 passed / 602 passed'; runtime --project local '272 passed / 3800 passed | 1 skipped'; runtime --project repo '2 passed / 69 passed' (runtime is the only other package whose tests compose with 'merge'). (5) pnpm --filter @objectstack/spec run typecheck exit 0 (tsc + scripts + test layer 'check:test-typecheck: OK'). (6) Gates: dispatch-gates --repo objectstack-ai/objectstack --commands derived 82; all 82 run with exit codes recorded; --ran gives '82 derived famil(ies) accounted for - 80 run, 2 NOT-MEASURED'. NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, both exit 3 PREREQUISITE NOT MET (need whole-workspace dist); CI owns them. Every other gate exit 0, incl. check-adr-0087-registration '[BREAKING+bang] not-required (no-migration-prescription)' and check-changeset-no-major --event (with the PR body) 'LEVEL AXIS: this PR declares clause-② yes, and no package ... is graded patch'. One earlier targeted lock attempt returned 99 (queue-timeout) and was re-run, not counted.", "mcp_calls": "0", "api_writes": "5 - git push x3 via write-pace (empty-branch probe 44ce049a8c; c61075ec96; 8f98553d5c); POST /repos/objectstack-ai/objectstack/pulls draft=true x1 via with-fleet.sh --via dispatch (fleet-write relay repository_dispatch, run 35901511713, PR #19915 by objectstack-fleet[bot]); POST /repos/objectstack-ai/objectstack/issues/16075/comments x1 (this os-dev-report) via post-stamped.mjs. Zero label writes, zero PATCH.", "open_questions": [], "out_of_scope_findings": [ "class: c · systemFields (false or options object) and titleFormat (template string or expression object) are unions that admit an object form. Under objectConflict 'merge' two strict-parsed objects declaring systemFields { tenant: false } and { audit: false } compose to { audit: false }: the earlier package's tenant opt-out is silently replaced. That is the card's shape, but the ruling names eight fixed-shape keys, so this PR pins the pair as later-wins (BOUNDARY block of compose-stacks-merge-config-object-refusal.test.ts). Whether to move the boundary is a seat decision; the PR body calls it a boundary note. Reach: zero non-test callers pass objectConflict. Seam: spec:ObjectSchema.systemFields → runtime:composeStacks mergeObjects 'merge' spread (packages/spec/src/stack.zod.ts) · dedupe words: systemFields merge objectConflict; union config object later-wins; titleFormat composeStacks merge", "carrier: none · packages/spec/src/api/error-code-ledger.zod.ts:1359, the STACK_COMPOSE_COLLECTION_CONFLICT ledger comment, still names only the collection trigger (incomplete, not false; outside the claimed file surface) · noted in the PR's Acceptance notes with replacement text, not filed" ] }objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsLanding — PR #19915 flipped ready + auto-merge by
domain:specseat 4, 2026-09-24T05:45ZDone on the maintainer's instruction, provenance:
- who: the maintainer;
- words: 「帮我处理」 for a list of twelve PRs "只差一份 at-tier 复核的 PASS 记录", then the landing route 「我直接落地」 chosen in the same exchange;
- where: the chat of session
session_019c3Hi6ZMU1p6m6aA6Bz45d(domain:spec#4).
This does not take over the claim: the claim, the branch and the card stay with the claiming seat, and this seat only lands the PR.
- Contract review: at-tier PASS, record
5808350461on PR feat(spec)!: composeStacks objectConflict 'merge' refuses a fixed-shape config object both objects declare differently (#16075) #19915, on the head the PR carries now. - Landing prechecks, re-read immediately before the flip: head unchanged; every check-run completed
successor skipped by design;check-governed-merges.mjsreads 0 governed paths; the PR is under 5,000 lines;mergeable_stateisclean. Clause-②: yesis kept as the ruling wrote it; the reviewer notes thatno (narrowing)is the reader's spelling for a pure narrowing. Kept knowingly: breaking-ness is carried by the banner and the bang, and both gate verdicts are unchanged (record ③.1).- Lands after or beside PR fix(spec): composeStacks' action-key collision pass skips malformed actions, so step 7 refuses them with the envelope #19903 (both edit
stack.zod.ts). A driver-less merge probe of feat(spec)!: composeStacks objectConflict 'merge' refuses a fixed-shape config object both objects declare differently (#16075) #19915 on top ofmain+ fix(spec): composeStacks' action-key collision pass skips malformed actions, so step 7 refuses them with the envelope #19903 is clean; the queue re-verifies the combination.
Generated by Claude Code
objectstack-fleet commented
on Sep 24, 2026 ContributorMore actionsLanded — PR #19915 →
f7a3495a43, 2026-09-24T06:27Zdomain:specseat 4 (session_019c3Hi6ZMU1p6m6aA6Bz45d), landing record for the landing done on the maintainer's instruction (provenance in this seat's landing comment above).- The card closed
completedthroughFixes #16075. The squashf7a3495a43has one parent and is an ancestor oforigin/main. - Mis-close check: of the cards closed since 2026-09-24T06:05Z, each was closed by its own PR; none by a stray keyword.
pm:dispatchedremoved. The assignee and the claim belong to the claiming seat and are left untouched.
Generated by Claude Code
- The card closed
- added a commit that references this issue
on Oct 9, 2026
Found while implementing #14848 (the
objectConflict: 'merge'collection refusal); out of scope there by the ruling's own boundary and recorded here instead. Measured onorigin/main@c463d03e0,packages/spec/src/stack.zod.tsmergeObjects.What was measured
The #14848 ruling (comment 5542636547, option 4) narrows
objectConflict: 'merge'for object-level COLLECTIONS only: every key ofObjectSchemawhose declared type is an array or a record (actions,indexes,listViews,validations, ... —fieldsexcepted) is refused when both objects declare it with different values. The PR landing it derives that set from the shape and states, in the docblock, that everything else keeps later-wins."Everything else" includes eight FIXED-SHAPE CONFIG OBJECTS on
ObjectSchema(measured overObjectSchema.shape, wrapper-stripped typeobject):enable,access,protection,tenancy,lifecycle,userActions,publicSharing,external. Under'merge'a later object that declares one replaces the earlier declaration wholesale — member by member gone — with nothing said:For
access(ADR-0066 D2 exposure posture) andprotectionthis is the security-downgrade shapecomposeSingleValuerefuses at the top level forapi/server: an add-on package switches off a core package's posture by declaring its own.Why it is recorded separately
The ruling's text and its four-facet analysis speak of collections ("actions, validation, hooks, indexes, ..."), and the dispatch clause for the fix says explicitly that scalars and labels stay later-wins — "the ruling narrows collections only". A config object is neither a collection of authored entries nor a scalar; treating it as a collection would widen the ruling, treating it as a scalar leaves the loss. The fix keeps to the ruling and names the boundary in the docblock; whether the boundary should move is a decision, not an implementation detail.
Reach
Zero non-test call sites pass
objectConflictin this tree (measured for #14848 overpackages/**,examples/**,apps/**); the default'error'refuses the pair outright. Reachable only by an author who opts into'merge'.Options (for triage, not decided here)
objecttypes too;fieldsstays the one merge). Same message shape, same identical-passes reading.'merge'describe text (the 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 PR already says it for scalars and config objects).Filing unassigned for triage.
Generated by Claude Code