Repository navigation
feat(spec)!: composeStacks objectConflict 'merge' refuses a fixed-shape config object both objects declare differently (#16075) - #19915
Conversation
… 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>
📓 Docs Drift Check11 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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 |
Contract reviewServed-tier: 98/98 Isolated at-tier reviewer subagent, run by the ① Derived judgmentsRuling read from the card (#16075 comment by os-zhuang 2026-09-07T00:41:39Z, option 1, maintainer 「同意」): under Refs: PR ref
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #16075
Clause-②: yes
Executes ruling
5563452716on #16075 (director decision batch #61, option 1, maintainer reply verbatim 「同意」): undercomposeStacks({ objectConflict: 'merge' }), a fixed-shape config object onObjectSchemathat both objects declare with different values is refused, with the same message shape and the same identical-passes reading #14848 uses for collections.fieldskeeps its merge semantics exactly as #14848 ruled.What changed
packages/spec/src/stack.zod.ts,mergeObjects' derivation only:declaresConfigObject(schema): a wrapper-strippedobjecttype, read through alazyand apipe's authored side (the samepipeAuthorableSiderule the collection walk uses), and deliberately not into a union.objectCollectionKeys()becameobjectUnmergeableKeys(): one pass overObjectSchema.shapethat maps each 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'. Still derived, never hand-listed;fieldsstill excluded by name.refuseUnmergeableCollections(name kept: one raise site, one code) iterates both kinds. The envelope is unchanged:STACK_COMPOSE_COLLECTION_CONFLICT,status: 422,issues: [finding]. The first line (finding) and the third line (fix) 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's. The middle line now lists both kinds and names what is dropped per kind:'merge'describe text onConflictStrategySchema,StackComposeCollectionConflictError,declaresCollection, the wrapper set,pipeAuthorableSide,mergeObjects(which said config objects stay later-wins) andcomposeStacks(prose and@example).collectComposedActionKeyCollisions(sibling PR fix(spec): composeStacks' action-key collision pass skips malformed actions, so step 7 refuses them with the envelope #19903's region) is not touched.Measured first, on
origin/main@44ce049a8Today's behaviour, each of the eight, two stacks,
'merge': every one ACCEPTED, composed to the later declaration wholesale.enable(strict parse)trackHistory: true+ defaultsapiEnabled: true(sotrackHistory: falseby default)trackHistory: falseaccess(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 eightDerived fixed-shape set vs the ruling's eight: a runtime walk of
ObjectSchema.shape(43 keys), wrapper-stripped, finds exactly eight keys whose type isobject: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 anobjectonly as a union member:requiredPermissions(array or object, already a collection),systemFields(falseor an options object) andtitleFormat(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 objectConflictoverpackages/,examples/,apps/at44ce049a8: every non-test hit is a doc comment, a message string or the ledger comment. The one non-testcomposeStackscall (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:access'private'then'public'refused (envelopecode+status+issues+ finding line);accessidentical passes and is carried once;fieldsstill shallow-merges beside an identicalaccess.enablecase; each of the eight refused when different and passed when identical; three stacks name the first declarer; earlier-only kept; explicitundefinedneither refuses nor erases; identity judged on the parsed object;'override'unchanged.ObjectSchema.shape, and the walk equals the ruling's eight in shape order.systemFieldsandtitleFormatobject 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 incompose-stacks-collection-pipe-arm.test.tsto the renamed helper (its regex still matches the new message unchanged).Firing control (commit
c61075ec96, thenstack.zod.tsrestored to44ce049a8's blob, hashdeaf6a024cverified on disk; restored after, hash347f597076equals the HEAD blob,git diff HEADempty): 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. Itsartifact-collections.test.tsis 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 passobjectConflict: 'merge'(git grepoverpackages/).pnpm --filter @objectstack/spec run typecheck(tsc, scripts program, test-layer program): exit 0. The test layer holds its ledger with no new signature.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 statusis clean after the build, so no generated artifact moved.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 82 commands, and I ran all 82.--ranreconciles them as 80 run, 2 NOT MEASURED, 0 unrun; every run gate exited 0. The 2 NOT MEASURED arecheck:dual-build-cjs-loadsandcheck: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 --eventwith this body: "LEVEL AXIS: this PR declares clause-②yes, and no package whosepackages/**/src/**it moves is gradedpatch", exit 0.Contract notes
@objectstack/specminorwith a**BREAKING**banner, the launch-window convention for a breaking narrowing, as in 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 (64bd6a3) and.changeset/18239-merge-objects-refusal.md.adr-0087: not-required (no-migration-prescription), the category 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 and the four latercomposeStacks/mergeObjectsrefusal changesets carry. Derived, not copied: nothing authorable is renamed, retired or re-typed, no stored metadata changes shape, the refusal carries its own fix, and zero non-test callers passobjectConflict. So no semantic entry is owed, andregistry.tsis not regenerated.check-adr-0087-registrationreads it as[BREAKING+bang] not-required (no-migration-prescription), exit 0.yes, copied from the claim. The repo's current reader (scripts/pm/clause2-line.mjs) spells a pure narrowingno (narrowing): its value asks "does this widen an accept set or enlarge a public surface?", and this diff does neither. Breaking-ness does not depend on the arm here, because the changeset's**BREAKING**banner already carries it to the ADR-0087 gate. The level axis is satisfied either way:yesrequires at leastminor, and the changeset isminor.Acceptance notes
packages/spec/src/api/error-code-ledger.zod.ts:1359: the ledger comment forSTACK_COMPOSE_COLLECTION_CONFLICTstill 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: "underobjectConflict: 'merge', an object-level collection other thanfields, or a fixed-shape config object, is declared with different values by two stacks". Carrier: none.systemFieldsin 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