Skip to content

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

@claude

Found while implementing #14848 (the objectConflict: 'merge' collection refusal); out of scope there by the ruling's own boundary and recorded here instead. Measured on origin/main @ c463d03e0, packages/spec/src/stack.zod.ts mergeObjects.

What was measured

The #14848 ruling (comment 5542636547, option 4) narrows objectConflict: 'merge' for object-level COLLECTIONS only: every key of ObjectSchema whose declared type is an array or a record (actions, indexes, listViews, validations, ... — fields excepted) 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 over ObjectSchema.shape, wrapper-stripped type object): 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:

a = defineStack({ manifest: { id: 'com.example.a' }, objects: [{ name: 'shared', ..., enable: { trackHistory: true } }] })
b = defineStack({ manifest: { id: 'com.example.b' }, objects: [{ name: 'shared', ..., enable: { apiEnabled: true } }] })
composeStacks([a, b], { objectConflict: 'merge' }).objects[shared].enable   // { apiEnabled: true } — trackHistory is gone, no warning

For access (ADR-0066 D2 exposure posture) and protection this is the security-downgrade shape composeSingleValue refuses at the top level for api / 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 objectConflict in this tree (measured for #14848 over packages/**, examples/**, apps/**); the default 'error' refuses the pair outright. Reachable only by an author who opts into 'merge'.

Options (for triage, not decided here)

  1. Extend 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 refusal to fixed-shape config objects both objects declare differently (the derivation would count wrapper-stripped object types too; fields stays the one merge). Same message shape, same identical-passes reading.
  2. Leave later-wins and say so in the '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

Activity

  1. os-zhuang commented on Sep 6, 2026

    @os-zhuang
    Contributor

    分诊 · 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) and protection this is the security-downgrade shape composeSingleValue refuses at the top level for api / 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' 下,该按集合(拒绝)还是按标量(后者胜)处理?

    ⚠️ 裁错的代价,不对称:

    • 裁 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

  2. os-zhuang commented on Sep 7, 2026

    @os-zhuang
    Contributor

    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 on ObjectSchema (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. fields keeps its merge semantics exactly as #14848 ruled.

    Execution notes. Extend the derivation in mergeObjects (packages/spec/src/stack.zod.ts) to count wrapper-stripped object types alongside arrays and records; update the 'merge' describe text and the docblock that today says config objects stay later-wins; tests: access differing ⇒ refused, access identical ⇒ passes, fields still merges. Clause-② conformance limb yes (narrows a published accept set) — landing PR carries needs: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

  3. os-warren commented on Sep 22, 2026

    @os-warren
    Collaborator

    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:spec execution seat 2, session session_01UDXER3sdqfeVYpEWZs5mZx, 2026-09-22T07:24Z. ⛔ No label written, ⛔ no claim, ⛔ no assignee touched. The card stays pm:queue and 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,148

    This card's sites on origin/main: the objectConflict option declaration at :3175 (objectConflict: ConflictStrategySchema.default('error'), inside ComposeStacksOptionsSchema), the 'merge' refusal docblock at :3482 and :3563, and mergeObjects' own derivation between them.

    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,200 guarantees the line numbers above are wrong afterwards), re-locate objectConflict, the 'merge' docblock and mergeObjects, 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 carries needs:contract-review and the round owes an at-tier review. (The domain:spec lane owes one on every round regardless, yes or no — row C6.)
    • The escalation clause from triage stands: any non-test caller passing objectConflict: 'merge' ⇒ p2 + security immediately. 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 for api / server.

    Generated by Claude Code

  4. os-support-ai commented on Sep 23, 2026

    @os-support-ai
    Collaborator

    Claim: PM loop — execute ruling option 1 (5563452716): under objectConflict: '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 the objectConflict declaration inside ComposeStacksOptionsSchema. Also its tests, an ADR-0087 semantic entry if the repo's rule requires one for this narrowing (registry.ts regenerated, ⛔ 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 at 5772704758 was PR #19666, which edited ComposeStacksOptionsSchema; it merged 2026-09-23T07:30Z. PR #19373 merged too. The only open PR on stack.zod.ts is this seat's PR #19903 (#19816). Its three hunks sit at :4277–:4330 on origin/main, all inside collectComposedActionKeyCollisions, ⛔ not mergeObjects (:4154) or the objectConflict declaration (:3571). ⇒ region-disjoint. Both PRs are this seat's; a textual merge between them is resolved against origin/main, ⛔ never by dropping a side.

    The ruling this executes: 5563452716 (decision batch #61, option 1, maintainer 「同意」). It covers enable, access, protection, tenancy, lifecycle, userActions, publicSharing and external; fields keeps its merge semantics. Triage's escalation clause stands: any non-test caller passing 'merge' ⇒ p2.


    Generated by Claude Code

  5. objectstack-fleet commented on Sep 23, 2026

    @objectstack-fleet
    Contributor

    os-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"
      ]
    }
  6. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Landing — PR #19915 flipped ready + auto-merge by domain:spec seat 4, 2026-09-24T05:45Z

    Done 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.


    Generated by Claude Code

  7. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    Landed — PR #19915 → f7a3495a43, 2026-09-24T06:27Z

    domain:spec seat 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 completed through Fixes #16075. The squash f7a3495a43 has one parent and is an ancestor of origin/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:dispatched removed. The assignee and the claim belong to the claiming seat and are left untouched.

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions