Skip to content

feat(spec)!: composeStacks objectConflict 'merge' refuses a fixed-shape config object both objects declare differently (#16075) - #19915

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-16075-merge-config-object-refused
Sep 24, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-16075-merge-config-object-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:

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.

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

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

… both objects declare differently

The objectConflict 'merge' refusal set is now derived over two kinds:
the object-level collections it already refused, and every fixed-shape
config object on ObjectSchema (a wrapper-stripped object type that is
not a collection) - userActions, external, tenancy, access, lifecycle,
enable, publicSharing and protection today. Two same-name objects that
declare one with different values are refused with the collection
refusal's envelope and message shape; identical declarations pass;
fields keeps its shallow merge. A union admitting an object beside a
non-object form (systemFields, titleFormat) is not a fixed shape and
stays on later-wins.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
…acceptance pins

Pins that identical is judged on the parsed config object (a member
spelled out at its default is the same declaration) and that an
explicit undefined on the later object neither refuses nor erases.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 23, 2026
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

11 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8490127962834a125239ae85d9e6a88c0386896c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6ad52072d99951efa9fe2cd1926a1a53729140b0 — the merge of head 8f98553d5c02eee27012e065f5d746cb4f7b2f11 into base 8490127962834a125239ae85d9e6a88c0386896c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6ad52072d99951efa9fe2cd1926a1a53729140b0 && git checkout 6ad52072d99951efa9fe2cd1926a1a53729140b0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8490127962834a125239ae85d9e6a88c0386896c 8f98553d5c02eee27012e065f5d746cb4f7b2f11 && git checkout -B drift-repro 8490127962834a125239ae85d9e6a88c0386896c && git merge --no-ff 8f98553d5c02eee27012e065f5d746cb4f7b2f11

node scripts/docs-audit/affected-docs.mjs --json 8490127962834a125239ae85d9e6a88c0386896c

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 98/98 CONTRACT_REVIEW_TIER
Head-sha: 8f98553d5c02eee27012e065f5d746cb4f7b2f11

Isolated at-tier reviewer subagent, run by the domain:spec seat-4 session on the maintainer's instruction in that session (「帮我处理」, with the landing route chosen there); every one of its 98 transcript turns served at the tier the constant names. Adopted by the seat 2026-09-24T05:36Z. The record below is the reviewer's, unedited except the two header lines. The reviewer's own Served-tier: line named a model id; the seat replaced it with the transcript measurement.

① Derived judgments

Ruling read from the card (#16075 comment by os-zhuang 2026-09-07T00:41:39Z, option 1, maintainer 「同意」): 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, same message shape and same identical-passes reading as #14848; fields keeps its merge; derivation extended to wrapper-stripped object types; 'merge' describe text and later-wins docblock updated; tests for access-differs/access-identical/fields-merges.

Refs: PR ref refs/review/pr-19915 = 8f98553d5c02eee27012e065f5d746cb4f7b2f11; merge-base 44ce049a8c52b533f8a4ae195c0dca697309e400; origin/main = c1641868a3da71537c9c6c572d2bd2044103236b. Five files changed vs merge-base (git diff --stat): the changeset, stack.zod.ts (+149/-69), one new test, two touched tests.

  1. Accept-set change, right against the ruling. packages/spec/src/stack.zod.ts:4054 (PR ref) declaresConfigObject returns true for def.type === 'object' through the same wrapper set / lazy / pipeAuthorableSide as the collection walk, and deliberately not into a union. :4103-4109 objectUnmergeableKeys() maps every non-fields key to 'collection' (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 walk, unchanged) or, failing that, 'config object'. :4151 refuseUnmergeableCollections iterates both kinds. Measured against the shape at origin/main packages/spec/src/data/object.zod.ts: the eight keys resolve to strictObject directly (userActions :1765, publicSharing :2282, external via ObjectExternalBindingSchema :1250, enable via ObjectCapabilities :235, protection via shared/protection.zod.ts:64) or through lazySchema(() => strictObject(...)) (tenancy :650, access :707, lifecycle :852). systemFields :1891 and titleFormat :2149 are unions and stay later-wins; requiredPermissions :768 is a union with an array member and is already a collection. Derived set equals the ruling's eight, nothing added, nothing removed.
  2. "Differently" means structural inequality on the parsed declaration, not reference: stack.zod.ts:4169 (PR ref) if (deepEqualAuthored(held[key], value)) continue; — the helper is import { deepEqualAuthored } from './shared/deep-equal' (:15), untouched by the diff (git diff 44ce049a8c… refs/review/pr-19915 -- packages/spec/src/stack.zod.ts | grep -c deepEqualAuthored prints 0), whose docblock at origin/main:packages/spec/src/shared/deep-equal.ts:3-4 reads "Structural equality for authored declarations". This is 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's identical-passes reading, as ruled.
  3. Identical re-declaration accepted: pinned at compose-stacks-merge-config-object-refusal.test.ts:149 (strict-parsed access, carried once), :156 (it.each over all eight, strict: false), and the parsed-identity case (enable: { apiEnabled: true } vs the same with trackHistory: false spelled out). Earlier-only kept and explicit undefined neither refuses nor erases are pinned too.
  4. fields still merges: :163 pins the shallow merge beside an identical access; objectUnmergeableKeys skips fields by name (:4107).
  5. Envelope pinned: expectRefused at :99-102 asserts code === 'STACK_COMPOSE_COLLECTION_CONFLICT', status === 422, issues equal to the finding line; status comes from StackRefusalError base (stack.zod.ts:2095 readonly status = 422). Code kept, one raise site.
  6. Derivation and arrival are weight-bearing: DERIVATION (:270) reads the list the production refusal enumerates and equals it to an independent walk and to the literal eight; ARRIVAL (:291) uses vi.doMock('./data/object.zod'), which is the module stack.zod.ts:27 imports ObjectSchema from, so the mock reaches the composer; a probe config-object key is refused and a probe union-with-object key stays later-wins. The reporter's firing control (29 red on the base blob) is consistent with this; CI is the measurement of record.
  7. Describe text and docblocks: ConflictStrategySchema and objectConflict carry no .describe() (PR ref :3564, :3575), so the "describe text" is the docblock at :3552-3563, now saying config objects are refused; the mergeObjects docblock that stated config objects stay later-wins is corrected; composeStacks docblock and @example corrected; StackComposeCollectionConflictError docblock corrected. git grep at origin/main for the superseded phrases outside stack.zod.ts, CHANGELOG and changesets returns nothing, so no generated artifact or docs page carried the old text.
  8. Nothing in the repo that composes stacks breaks: git grep -l "objectConflict:\s*'merge'" refs/review/pr-19915 -- packages apps examples — non-test hits are only doc comments and the ledger comment; the one non-test composeStacks call is examples/app-multi-package/objectstack.config.ts:70 with { manifest: 'preserve' } (default 'error'). packages/runtime/src/artifact-collections.test.ts:325 composes with 'merge' and declares none of the eight keys (grep returns nothing). Check-runs at the head sha: 35 runs, 32 success (Test Core 1-6, Build Core, Lint & Repo Gates, Check Changeset, Governed Surface Queue Guard, Spec property liveness, Type Check jobs), 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke). Triage's p2 escalation clause does not fire.
  9. Required and present: all ruling execution notes covered. Beyond the ruling (small, disclosed): the middle line of the collection refusal also changed — it now enumerates the config-object list too (:4180-4184), and the noun/verb switch on kind. First and third lines are byte-identical to 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; the changeset states this at lines 48-49. Internal helper renames (objectCollectionKeys → objectUnmergeableKeys, declaredCollections → declaredUnmergeableKeys) are non-exported. collectComposedActionKeyCollisions (sibling fix(spec): composeStacks' action-key collision pass skips malformed actions, so step 7 refuses them with the envelope #19903 region) untouched.

② Semver level

  • .changeset/16075-merge-config-object-refused.md:2 '@objectstack/spec': minor — the launch-window level for a breaking narrowing (AGENTS.md origin/main :1081-1085; scripts/check-changeset-no-major.mjs :47-50, :68-73).
  • :9 **BREAKING** banner present; summary line feat(spec)!: present; exactly one ADR-0087 marker (grep -c 'adr-0087:' prints 1) at :7, spelled not-required (no-migration-prescription) ...why... — the form scripts/check-adr-0087-registration.mjs :62-63 requires, category in CATEGORIES :496-500. No FROM → TO prescription in the body (the only -> hit is the comment closer), so the category is not refused; matches 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 precedent marker (git show 64bd6a3:.changeset/compose-merge-refuses-object-collections.md). Gate runs in pr-automation.yml:939-940 and CI "Check Changeset" / "Lint & Repo Gates" are success at the head.
  • packages/spec/CHANGELOG.md and content/docs/releases/** not edited (release-owned) — correct.
  • Level, banner and marker are all right.

③ Boundary flags

  1. Non-blocking — Clause-② spelling vs the repo's one reader. PR body and changeset :71 declare Clause-②: yes with no arm. scripts/pm/clause2-line.mjs at origin/main :70 defines the value as answering "does this widen an accept set or enlarge a public surface", :91-96 read yes as "a widening" and no (narrowing) as "NOT a widening, but breaking. This is the whole point of the arm." This diff widens nothing and adds no export, so the true spelling is no (narrowing) — the spelling the closest precedent on the same function uses (.changeset/18239-merge-objects-refusal.md:25). Consequence: check-adr-0087-registration.mjs:639-640 signal (4) clause-②-narrowing does not fire; breaking-ness rides only on the prose banner and the ! — the carriers [finding] An accept-set narrowing owes a **BREAKING** banner in core but not in platform-objects — and the ADR-0087 classifier reads the banner #16421 called the weakest. Not blocking because (a) the ruling verbatim says "Clause-② conformance limb yes" and the claim copied it, (b) the PR body discloses the discrepancy under "Contract notes", (c) both gate verdicts are unchanged (BREAKING+bang → disposition required and present; level axis: yes needs at least minor, changeset is minor). Recommend the seat change both lines to Clause-②: no (narrowing) before landing, or record that the ruled spelling is kept knowingly.
  2. Non-blocking — published refusal string changed for collection conflicts too (see ①.9). Disclosed; no test outside the three touched files pins the old middle line (git grep for "silently dropping every entry" outside them returns nothing).
  3. Non-blocking — one pin is membership-only for the new kind. compose-stacks-merge-collection-refusal.test.ts:299-303 (PR ref) valuesFor returns ['x', 'y'] for any non-collection key, so the both-directions it.each exercises config-object keys with scalar strings; membership is what it proves. Object-valued comparison is pinned in the new file (:131, :156), so nothing is unproven, only the pin's name is wider than its evidence.
  4. Non-blocking — ledger comment incomplete, outside the claimed surface. packages/spec/src/api/error-code-ledger.zod.ts:1359 still describes only the collection trigger for STACK_COMPOSE_COLLECTION_CONFLICT. Disclosed in the PR's Acceptance notes; not false, incomplete.
  5. Non-blocking — branch is 18 commits behind origin/main. The only main-side change to stack.zod.ts since the merge-base is fdeeea0cc with hunks at :1241, :1570, :4411; the PR's hunks run :2394–:4816 excluding that range; mergeable_state is clean.
  6. Files: all five are inside the claim (stack.zod.ts regions named, its tests, .changeset/). No registry.ts regeneration owed (nothing authorable moves). Commit trailers are the model-free pair AGENTS.md :451-455 requires; no model identifier in title, body, changeset or code.

Implemented-by: claude/issue-16075-merge-config-object-refused
Reviewed-by: session_019c3Hi6ZMU1p6m6aA6Bz45d

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 24, 2026 05:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit f7a3495 Sep 24, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-16075-merge-config-object-refused branch September 24, 2026 06:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant