fix(spec): every composeStacks conflict refusal carries an ADR-0112 envelope - #19685
Conversation
The six authored-entity refusals `composeStacks` raises — every one under the literal `composeStacks conflict:` message prefix, five of them inside helper functions rather than `composeStacks`' own body — threw `new Error(message)` with `code` and `status` both `undefined`, while the `defineStack` family in the same file has carried the envelope since #15963. Each site now throws its own `StackRefusalError` member: one code per refusal site (the granularity the sibling family landed with), `status: 422`, and the finding the site collected on `issues`. Message text is byte-for-byte what the bare `Error` carried. The internal-bookkeeping invariant (`composeStacks internal error: no source stack recorded for composed object …`) stays a bare `Error` deliberately: it is the code finding its own records inconsistent, not an authored entity being refused, and a 422 would blame the author for our defect. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
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 678ea47e614015c328e7d9a8a553b5f614279cf7 && git checkout 678ea47e614015c328e7d9a8a553b5f614279cf7
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 408ca2e366ad7ca97ec7306f1c0ecda5efa2394e 13582b88da4ac8e5bb9649a64dd9a59fb6be027f && git checkout -B drift-repro 408ca2e366ad7ca97ec7306f1c0ecda5efa2394e && git merge --no-ff 13582b88da4ac8e5bb9649a64dd9a59fb6be027f
node scripts/docs-audit/affected-docs.mjs --json 408ca2e366ad7ca97ec7306f1c0ecda5efa2394e
|
Contract review
Reviewed-by: ⛔ Reviewed against the diff and Which limb of clause ② this is⛔ The accept set does not move. Every input refused before is refused after, and the five single-finding sites build the finding once and interpolate it into the SAME message — spot-checked at ✅ 公开导出面增删: six new What this seat verified by reading the diff, ⛔ not the report
The two citations the dispatch reserved to me, opened and read here rather than taken on report:
⛔ A premise in THIS SEAT's dispatch order had drifted, and the dev was right to report it rather than absorb itMy dispatch said class A needs a The two judgements the dev correctly left to this seat
Declared, ⛔ not discovered later
VerdictACCEPT. Generated by Claude Code |
Carrier stripped on PASS, and the record it cites. 2026-09-22T09:10Z
The record: the in-seat contract review at ⛔ Why this PR was ejected — this seat's fault, and the same one twice
⭐ Both were a step-ordering error in this seat's own protocol notes: strip-on-PASS was recorded as a POST-landing step when it is a PRE-enqueue one. Both are corrected in this same act. Generated by Claude Code |
⛔⛔ WITHDRAWN AS THE REVIEW OF RECORD — this seat is not at
|
Contract review
Served-tier: Reading time: 2026-09-22T09:33Z — checks, mergeability and carriers read at this instant. Rendered by the isolated at-tier review subagent spawned by the ① Derived judgmentsLimb — 公开导出面增删 (a widening), stated more exactly than the seat's record. The Is a refusal's SHAPE change itself an accept/reject change? No. The refused set is identical; what changes is the thrown value's observable face — What ships, and the artifact set. Granularity and naming, judged independently. One code per raise site mirrors the seven-class Class B stays out, and no card. Concur.
Joint with ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #16348
Clause-②: yes — the refusal envelope of a published door is its contract face: the same input that is refused today with a bare
Erroris refused after this with a typed 422 carrying a code, and once a code ships indista consumer'scatch (e) { switch (e.code) }pins the spelling.composeStacksrefused six authored-entity conflicts withnew Error(message)—codeandstatusbothundefined— while thedefineStackfamily in the same file has carried the ADR-0112 envelope since #15963 (PR #16342). Each of the six now throws its ownStackRefusalErrormember:status: 422, one code per refusal site, and the findings the site collected onissues. Message text is byte-for-byte unchanged at every site.The class-A population, re-derived on this branch
Located by MESSAGE-PREFIX FAMILY, never by the line numbers on the card (they are against
273247e56fand have drifted ~+370). Instrument and readings, taken at93cdc43d5before the first edit:93cdc43d5):3285composeSingleValue:3334composeFunctions(map-vs-array shape):3349composeFunctions(claim, duplicate handler name):3636refuseUnmergeableCollections:3715mergeObjects(objectConflict: 'error'):4265composeStacksbody, viaformatComposedActionKeyCollisions:3824collectComposedActionKeyCollisionsFive of the six raise inside helper functions 300-800 lines above
composeStacks' own body, which is the census defect the card warned about: counting by one syntactic locus under-reads a family that travels through helpers.The granularity reading I mirrored, and where I read it
Read off
main, not assumed:packages/spec/src/stack.zod.ts:2019-2147— the seven classes PR fix(spec): every defineStack refusal carries an ADR-0112 envelope — six STACK_* codes beside STACK_CROSS_REFERENCE_INVALID #16342 landed, eachextends StackRefusalErrorwith exactly onereadonly code, one class per refusal site:STACK_CROSS_REFERENCE_INVALID,STACK_SCHEMA_INVALID,STACK_CAPABILITY_UNKNOWN,STACK_NAMESPACE_PREFIX_INVALID,STACK_SINGLE_APP_VIOLATION,STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED,STACK_TRIGGER_CAPABILITY_REQUIRED. Seven raise sites at:3068-3129, one per class.StackRefusalErrordocblock states the rule in words: "⭐ One code per site, never a sharedSTACK_VALIDATION_FAILEDcatch-all".5556795849on finding(spec): six of defineStack's seven refusals are still bare Errors — no ADR-0112 code/status — leaving one envelope among neighbours that have none #15963, which set that granularity, in its own words: 「boot-refusal 这一类在账本里已经是 14 行、粒度是「一处拒绝一行」,不是共用一个总 code。」 and 「所以剩下六处应当各自一个 code」.Nothing I read contradicts "one code per refusal site", so the fence holds and I mirrored it: six sites, six codes. The at-tier contract review of #16342 (comment
5560140069on #15963, §6) also opines on this split and AGREES with it — "The card should take the six as one family (same callers, same 422 class); filing only the action-key collision would recreate the 1-of-N inconsistency this PR fixes. The invariant stays out (or 500-class)" — so no reading outranks the dispatch's claim.The
STACK_COMPOSE_*spelling (rather than continuing the per-stack family'sSTACK_SUBJECT_CONDITIONshape) is the one judgement inside that fence: what these six refuse is a disagreement BETWEEN stacks, a conditiondefineStackcannot raise — each input is legal alone. A bareSTACK_OBJECT_CONFLICTwould read as "this stack's object is malformed" and point a consumer at the wrong half of the artifact.STACK_CROSS_REFERENCE_INVALIDstays the deliberate exception in the other direction, because its per-stack and artifact passes are one rule family over two scopes.composeSingleValueSTACK_COMPOSE_KEY_CONFLICTfunctionsauthored in the map form by one stack, the array form by anothercomposeFunctionsSTACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICTcomposeFunctionsSTACK_COMPOSE_FUNCTION_CONFLICTfieldsdeclared differently underobjectConflict: 'merge'refuseUnmergeableCollectionsSTACK_COMPOSE_COLLECTION_CONFLICTobjectConflict: 'error'mergeObjectsSTACK_COMPOSE_OBJECT_CONFLICTcollectComposedActionKeyCollisionsSTACK_COMPOSE_ACTION_KEY_COLLISIONClass B is out, by the dispatch's fence and by my own reading
composeStacks internal error: no source stack recorded for composed object '...'stays a bareError. It is the code discovering its own bookkeeping is inconsistent —mergeObjectsrecords every composed object the moment it is first seen, so a miss is an edit to that function, not an authored input. A 422 would tell an author their stack is unprocessable when the defect is ours. Its own exit (bare, or a 500-class envelope) is a separate decision and is not taken here.That fence is now a test rather than a promise:
compose-stacks-refusal-envelopes.test.tsasserts that exactly one barethrow new Error(is left instack.zod.tsand that it is the internal-invariant message, so a seventh authored-entity refusal arriving bare fails.The ledger rows, and a premise that had drifted
The card (and the dispatch) say class A needs a
doorclassification row and thatcheck:dispatcher-error-vocabularydemands a row per code in both directions. That mechanism has moved since the card was filed, and the dispatch's own instruction is what re-measuring corrected: under the #16404 ruling, #16449 and then #16649, adoor: 'none'code has no resting place short of a row inERROR_CODE_LEDGER, and theboot-refusalverdicts the dispatcher vocabulary used to carry for these codes were RATCHETED OUT with that batch — the sevendefineStackcodes carry zero rows inpackages/runtime/src/dispatcher-error-vocabulary.tstoday (boot-refusalthere is down from 20 rows to 4). So the reachability reading lives in the ledger comment, one line per row, which is where I put it.check:dispatcher-error-vocabularyis green: a registered code vanishes from its scan by construction, which is the same ratchet.The door verdict itself, re-measured on this branch with its own positive control:
The control is lit, so the zero is a reading rather than a dark grep. (The card's own figures — 6 and 32 — have drifted with the tree; the shape of the answer has not.) The only non-test caller of
composeStacksanywhere isexamples/app-multi-package/objectstack.config.ts, which is authoring time by construction.door: 'none'/ boot refusal holds.git grep -- 'packages/runtime/src/**/*.ts'silently misses files sitting directly insrc/and answered 0 for the probe. The readings above use directory pathspecs (-- packages/runtime/src packages/rest/src). A zero from the glob form is not a measurement.Changeset:
minor, from afiles[]reading of my ownnpm pack --dry-run --jsoninpackages/spec(the authoritative resolution offiles[]) lists both changed sources as shipped —src/stack.zod.tsandsrc/api/error-code-ledger.zod.ts(matched bysrc/**/*.zod.ts) — plusdist/index.js, which carries all six spellings (1 hit each indist/index.jsanddist/index.mjs; positive controlSTACK_TRIGGER_CAPABILITY_REQUIRED1, negative control an invented spelling 0). The test file does not ship. So the diff publishes,skip-changesetis false, and withClause-②: yesthe level isminor—check-changeset-no-major.mjs's enforce leg refuses a clause-② diff whose only moved package is gradedpatch, and it passes here.Verification
Every exit code captured before any pipe; heavy runs through
scripts/pm/os-verify-lock.shwithOS_VERIFY_LOCK_SLOT=issue-16348, verdicts read off itsVERDICT command-exitline.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack— re-derived after theorigin/mainmerge and after the changeset existed (the first derivation warned STALE TREE and named 6 family files that had moved; the second is clean). Reconciliation line is in the report comment on the card.packages/specfull suite,typecheck,build,check:generated(the six new ledger rows madecontent/docs/references/**stale; regenerated with--fix, which touched exactly the two pages and exactly the six lines).check:dispatcher-error-vocabulary,check:error-code-provenance,check:error-code-casing,check:doc-authoring,check:nul-bytesgreen.turbo run typecheck, and repo-widepnpm lintrun WHOLE.Acceptance notes
composeStacks conflict:message prose is untouched by design, so the suites that pin it whole (compose-stacks-merge-collection-refusal.test.tsandcompose-stacks-action-key-collision.test.tsassert entire messages withtoBe) are the byte-for-byte evidence, and they pass unchanged.composeStacks' merge semantics, into class B, or into 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 / [finding] composeStacks step 2 (mergeObjects) iteratesstack.objectsunguarded — an unparsed input with a non-arrayobjectsraises a bare TypeError outside the ADR-0112 envelope; same defect family #18212 just closed forpermissions/data, one step earlier, PRE-EXISTING on main #18239.维护者速读(草稿)
改了什么 —
packages/spec/src/stack.zod.ts里composeStacks的六处「作者写错了」的拒绝,从裸Error换成和defineStack同一个信封:status: 422+ 一处一个STACK_COMPOSE_*code +issues逐条findings。报错文字一个字节都没改。六个 code 登记进 ADR-0112 账本,生成的 docs references 随之重生成。第七处(内部记账不一致)按派发令保持裸Error不动。为什么改 — 同一个文件里两个拒绝家族,对作者是同一回事,对消费者是两回事:
defineStack那七处自 #15963 起能用error.code分支,composeStacks这六处读到undefined,而undefined读起来像「这不是一次校验拒绝」,不像「这是一次还没有 code 的拒绝」。五处藏在助手函数里,谁打开composeStacks都只看得到一处。风险与代价(含回滚) — 风险面是「新增可观测面」而非行为变化:accept-set 不动,报错文字不动,没有新导出(类保持 module-local)。真正的代价是词表:code 一旦随
dist发出去,改名就是破坏性变更,所以是 clause-② /minor。回滚成本低——全部改动集中在一个包的两个源文件加一个测试文件加生成的两页文档,revert 即可;账本行退出会让ErrorCodeunion 收窄,那是一次破坏性动作,所以回滚要连 changeset 一起撤。席位意见 — (留给 PM 席位填)
你要做的 — ① 确认
STACK_COMPOSE_*这个拼写族可接受(这是粒度栅栏之内我自己下的唯一判断:名字要说清「冲突在两个 stack 之间」);② 确认 class B 继续留白是你要的结果,还是要另立一张卡给它一个 500 类信封;③ 这是达档契约复核件(clause-② yes),落地前需要本车道的 tier 记录。Generated by Claude Code