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 |
…rimitive does with enableOnInstall (objectstack-ai#19710) Fixes objectstack-ai#19339 Clause-②: no The second and final increment on this card. PR objectstack-ai#19691 corrected three of the four carriers the executable criterion names — the kernel `.describe()` and the two reference pages generated from it — and landed as `7e1b048a1d`. One carrier remained, in the file that objectstack-ai#19691's dispatch had fenced out by mistake (recorded on the card at comment 5773730316): the doc block on `PackageInstallRequestSchema.enableOnInstall` in `packages/spec/src/api/package-api.zod.ts`. That block is the map a reader follows from the authority to the other two declarations of this key, and its entry for the kernel copy said: > Same type, same default, same meaning; **its own implementation does not read it, and this door does not forward it down that seam.** It ships. `src/**/*.zod.ts` is in this package's `files[]`, and the comment survives bundling into `dist/api/index.js` and `dist/browser/api/index.mjs` — so this is published text, not an internal note. ## ⭐ Only ONE half of that sentence was false It is a compound claim about two different layers, and they were re-derived separately from the source rather than rewritten together. Correcting the true clause would have swapped one false sentence for another. | clause | layer it is about | verdict on `main` | what this PR does | |:--|:--|:--|:--| | "its own implementation does not read it" | the in-process primitive `MetadataProtocol.installPackage` | **FALSE** since `482d584121` | corrected, and scoped | | "this door does not forward it down that seam" | the HTTP door `POST /api/v1/packages` | **still TRUE** | kept, and its mechanism spelled out | **Half one, measured.** `packages/metadata-protocol/src/protocol.ts`, the `requestedEnabled` arms: `=== true` calls `enablePackage`, `=== false` calls `disablePackage`, an absent key makes no lifecycle call at all. `=== true` / `=== false`, never a truthiness test and never a `??` default, so the three states are three outcomes. **Half two, measured.** `packages/runtime/src/domains/packages.ts`, the install handler: it calls `protocolSvc.installPackage({ manifest, settings: body.settings })` — the key is not in that call — then performs the enable/disable flip itself against the registry, and writes the durable record from the row it returned (`setPackageDisabled(environmentId, pkgId, rowDisabled)`). So the clause is still accurate, and the replacement text now says WHY rather than only THAT. **The scope words are load-bearing, and they follow objectstack-ai#19691's pattern.** The primitive moves the **registry row** — what every in-process reader serves, for the life of the process. The durable disabled-package record is keyed by ENVIRONMENT, which an `InstallPackageRequest` does not carry, so `POST /api/v1/packages` still owns the half that survives a restart. Stating the primitive "honours it" without that qualifier would overstate it in the other direction. ## Premise, re-verified on this tree rather than relayed - `482d584121` is an ancestor of this branch: `git merge-base --is-ancestor 482d584 origin/main` exits **0**. Control leg on the same checkout with a commit known to be in that history (`596090efbe`, the commit that WROTE the denial) also exits **0**, so the positive reading is not a shallow-clone artefact. `git rev-parse --is-shallow-repository` reads `false` independently. - Carrier census before the edit, radius stated: `git grep` over the whole tracked tree. The exact phrase stood in `packages/spec/src/api/package-api.zod.ts` for this key. Dark control on a near-miss spelling of the same shape over the same file: **0** hits, so the probe discriminates. ## The criterion is fully satisfied — measured after merging `origin/main` `origin/main` (with objectstack-ai#19691 in it) is merged into this branch through `scripts/pm/os-regen-merge.sh`, so the four places the criterion names can be read on ONE tree: | place the criterion names | denial hits | corrected text present | who corrected it | |:--|:--:|:--:|:--| | `packages/spec/src/kernel/package-registry.zod.ts` | 0 | 1 | objectstack-ai#19691 | | `content/docs/references/api/protocol.mdx` | 0 | 1 | objectstack-ai#19691 | | `content/docs/references/kernel/package-registry.mdx` | 0 | 1 | objectstack-ai#19691 | | `packages/spec/src/api/package-api.zod.ts` | 0 | 1 | this PR | Dark control on a near-miss spelling over the same four files: 0 on each. `objectstack-ai#19691`'s implementation body was re-asserted present after the merge, along with objectstack-ai#19685's incoming entries, so nothing was swallowed in either direction. That is why this PR uses a closing keyword where objectstack-ai#19691 used `Part of`. ## No generated page changes — measured, not assumed `pnpm --filter @objectstack/spec check:generated` reports **all 15 generated artifacts up to date**, before and after the edit, with no regeneration and no `--fix` run. That is the expected reading and it is the measurement: the corrected text is a TSDoc block inside the schema factory, not a `.describe()`, and only `.describe()` text reaches `content/docs/references/**`. The generated row for this key on `content/docs/references/api/package-api.mdx` already read "honoured at POST /api/v1/packages", and still does, byte-identical. ⛔ No reference page was hand-edited; none needed regenerating. The same text does reach `packages/spec/json-schema/**` for the KERNEL copy only — that tree is gitignored, generated at build time from the kernel `.describe()` objectstack-ai#19691 corrected, and it is clean on this branch. ## Changeset — measured against `files[]`, not pattern-matched `patch` on `@objectstack/spec`; `skip-changeset` would be a false declaration. Answered against the package's own `files[]` after a real build, `npm pack --dry-run --json`, 2031 files: - **subject** — `src/api/package-api.zod.ts` is present in the listing (`files[]` carries `src/**/*.zod.ts`), and the corrected comment also reaches `dist/api/index.js`, `dist/api/index.mjs`, `dist/browser/api/index.js` and `dist/browser/api/index.mjs`, all under `dist`. - **positive control** — `dist/index.d.ts` is in the same listing, as it must be. - **negative controls** — **0** `*.test.ts` paths and **0** `content/` paths are in that listing, so the instrument is not simply answering yes. Level: nothing is added, removed, renamed or retyped and no default moves — `check:api-surface` and `check:authorable-surface` are green with no diff — so this is a correction to a published description, not a widening. ## Verification, at `3e94943aa7` **Gates** — ⛔ not a recalled list. Derived from the real change set with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, each exit code captured BEFORE any pipe, then reconciled with `--ran`: ```text ✓ dispatch-gates --ran: 76 derived famil(ies) accounted for — 76 run, 0 NOT-MEASURED (a DERIVED zero — all 76 recorded an exit code and none of them is 3). ``` All 76 exit 0. On the pre-merge head three of them first answered **exit 3 = PREREQUISITE NOT MET** (`check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`) because the workspace was not built; that was cleared with `pnpm build` and all three re-run to exit 0. ⛔ An exit 3 was never read as a pass. The whole union was then re-derived and re-run on the merged head — byte-identical family set, 76/76 at exit 0. **Tests** | run | result | |:--|:--| | `pnpm build` (turbo, excluding docs) | 73 tasks successful | | `pnpm --filter @objectstack/spec test` | 515 files, 15021 passed, 1 todo | | `pnpm --filter @objectstack/spec typecheck` | pass — `tsc --noEmit`, plus `check:scripts-typecheck` and `check:test-typecheck` (53 files / 257 errors / 142 pinned signatures held, shrink-only), so the test layer is MEASURED, not assumed | | `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 — full run, no narrowing claimed | | `pnpm --filter @objectstack/spec check:generated` | all 15 up to date | No new test, and this is deliberate. `check:docs` can only ever prove a page equals its `.describe()`; nothing can compare a sentence to an implementation, which is the card's own finding. Inventing an instrument here would be a new verification surface the card did not ask for. ## Acceptance notes **The pending changeset carrying the same root is still standing, and is still not a dev edit.** `.changeset/18605-enable-on-install-one-authority.md` states that the kernel copy's published description "now records that this layer does not read it". objectstack-ai#19691 made that sentence false and left the note alone on purpose — `pr-automation.yml` route 0 classes editing another card's pending note as a DELIBERATE CORRECTION requiring written confirmation on the PR. The card body ruled the same way. It is a release decision, handed to the review seat, and it is outside this card's executable criterion, which names four places and not this one. **`content/docs/releases/v17/17-0.mdx` also carries the phrase and is correctly untouched.** Release notes record what shipped in the version they document; that surface is RELEASE-OWNED and read-only to a code PR. **Sequencing with objectstack-ai#19273.** That card is the SHAPE half of this field (`.default(true)` versus the ruled 「缺省 = 保持」) and is fenced out here. It rewrites the same field's published text from the other side; whichever lands second regenerates the same rows. ⛔ Not this PR's to sequence. **Clause-② hint, recorded and answered.** `dispatch-gates` flags `packages/spec/src/**` as a clause-② SUSPECT surface, so construction is at `CONTRACT_REVIEW_TIER`. It is a hint, not a verdict: this diff adds no key to a published payload, changes no accept or reject behaviour, and leaves the accept set byte-for-byte — the declaration stays `no`, which is also the dispatching seat's reading. ⛔ No label was written by this branch. `needs:contract-review` is the seat's to place, and `skip-changeset` is refuted by the measurement above. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
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