Skip to content

fix(spec): every composeStacks conflict refusal carries an ADR-0112 envelope - #19685

Merged
os-justin merged 5 commits into
mainfrom
claude/issue-16348-composestacks-envelopes
Sep 22, 2026
Merged

os-justin merged 5 commits into
mainfrom
claude/issue-16348-composestacks-envelopes

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

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 Error is refused after this with a typed 422 carrying a code, and once a code ships in dist a consumer's catch (e) { switch (e.code) } pins the spelling.

composeStacks refused six authored-entity conflicts with new Error(message)code and status both undefined — while the defineStack family in the same file has carried the ADR-0112 envelope since #15963 (PR #16342). Each of the six now throws its own StackRefusalError member: status: 422, one code per refusal site, and the findings the site collected on issues. 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 273247e56f and have drifted ~+370). Instrument and readings, taken at 93cdc43d5 before the first edit:

git grep -n 'throw new Error('     packages/spec/src/stack.zod.ts   ->  7
git grep -n 'composeStacks conflict:' packages/spec/src/stack.zod.ts ->  7 lines:
    6 message sites + 1 docblock mention (:3848)
git grep -n 'composeStacks internal error:' packages/spec/src/stack.zod.ts -> 1
control (the prefix probe is alive): the same prefix outside this file
    -> 10 hits across 4 test files, plus 4 in packages/spec/CHANGELOG.md
control (the throw probe is alive): 'throw new Error(' across
    packages/spec/src non-test -> 9 files, 24 hits, stack.zod.ts the largest at 7
line (at 93cdc43d5) enclosing function class
:3285 composeSingleValue A
:3334 composeFunctions (map-vs-array shape) A
:3349 composeFunctions (claim, duplicate handler name) A
:3636 refuseUnmergeableCollections A
:3715 mergeObjects (objectConflict: 'error') A
:4265 composeStacks body, via formatComposedActionKeyCollisions A
:3824 collectComposedActionKeyCollisions B — untouched

Five 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:

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 5560140069 on #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's STACK_SUBJECT_CONDITION shape) is the one judgement inside that fence: what these six refuse is a disagreement BETWEEN stacks, a condition defineStack cannot raise — each input is legal alone. A bare STACK_OBJECT_CONFLICT would read as "this stack's object is malformed" and point a consumer at the wrong half of the artifact. STACK_CROSS_REFERENCE_INVALID stays the deliberate exception in the other direction, because its per-stack and artifact passes are one rule family over two scopes.

refusal raised by code
a single-valued top-level key declared with different values by two stacks composeSingleValue STACK_COMPOSE_KEY_CONFLICT
functions authored in the map form by one stack, the array form by another composeFunctions STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT
two stacks defining one handler name composeFunctions STACK_COMPOSE_FUNCTION_CONFLICT
an object-level collection other than fields declared differently under objectConflict: 'merge' refuseUnmergeableCollections STACK_COMPOSE_COLLECTION_CONFLICT
the same object name in two stacks under the default objectConflict: 'error' mergeObjects STACK_COMPOSE_OBJECT_CONFLICT
a cross-stack action key collision collectComposedActionKeyCollisions STACK_COMPOSE_ACTION_KEY_COLLISION

Class 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 bare Error. It is the code discovering its own bookkeeping is inconsistent — mergeObjects records 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.ts asserts that exactly one bare throw new Error( is left in stack.zod.ts and 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 door classification row and that check:dispatcher-error-vocabulary demands 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, a door: 'none' code has no resting place short of a row in ERROR_CODE_LEDGER, and the boot-refusal verdicts the dispatcher vocabulary used to carry for these codes were RATCHETED OUT with that batch — the seven defineStack codes carry zero rows in packages/runtime/src/dispatcher-error-vocabulary.ts today (boot-refusal there 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-vocabulary is 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:

PROBE   composeStacks, non-test, packages/runtime/src + packages/rest/src
        -> 7 occurrences, ALL in packages/runtime/src/artifact-collections.ts
           :11 :93 :117 :143 doc comments, :314 a line comment,
           :327 :330 inside a message template  ==>  ZERO call sites
CONTROL defineStack, same two trees, same filter
        -> 31 occurrences across 8 files (app-plugin.ts 16, job-handler-context.ts 4,
           standalone-stack.ts 3, domains/packages.ts 2, default-host.ts 2,
           artifact-collections.ts 2, dispatcher-error-vocabulary.ts 1,
           sandbox/body-runner.ts 1)

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 composeStacks anywhere is examples/app-multi-package/objectstack.config.ts, which is authoring time by construction. door: 'none' / boot refusal holds.

⚠️ Instrument note, because it nearly produced a false zero: git grep -- 'packages/runtime/src/**/*.ts' silently misses files sitting directly in src/ 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 a files[] reading of my own

npm pack --dry-run --json in packages/spec (the authoritative resolution of files[]) lists both changed sources as shipped — src/stack.zod.ts and src/api/error-code-ledger.zod.ts (matched by src/**/*.zod.ts) — plus dist/index.js, which carries all six spellings (1 hit each in dist/index.js and dist/index.mjs; positive control STACK_TRIGGER_CAPABILITY_REQUIRED 1, negative control an invented spelling 0). The test file does not ship. So the diff publishes, skip-changeset is false, and with Clause-②: yes the level is minorcheck-changeset-no-major.mjs's enforce leg refuses a clause-② diff whose only moved package is graded patch, and it passes here.

Verification

Every exit code captured before any pipe; heavy runs through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=issue-16348, verdicts read off its VERDICT command-exit line.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack — re-derived after the origin/main merge 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/spec full suite, typecheck, build, check:generated (the six new ledger rows made content/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-bytes green.
  • Consumer suites that compose stacks, turbo run typecheck, and repo-wide pnpm lint run WHOLE.

Acceptance notes

维护者速读(草稿)

改了什么packages/spec/src/stack.zod.tscomposeStacks 的六处「作者写错了」的拒绝,从裸 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 即可;账本行退出会让 ErrorCode union 收窄,那是一次破坏性动作,所以回滚要连 changeset 一起撤。

席位意见 — (留给 PM 席位填)

你要做的 — ① 确认 STACK_COMPOSE_* 这个拼写族可接受(这是粒度栅栏之内我自己下的唯一判断:名字要说清「冲突在两个 stack 之间」);② 确认 class B 继续留白是你要的结果,还是要另立一张卡给它一个 500 类信封;③ 这是达档契约复核件(clause-② yes),落地前需要本车道的 tier 记录。


Generated by Claude Code

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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 22, 2026
@github-actions

github-actions Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 18 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/getting-started/examples.mdx (via composeStacks (symbol, a top-level function))
  • content/docs/getting-started/glossary.mdx (via composeStacks (symbol, a top-level function))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))

5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-1.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-3.mdx (via composeStacks (symbol, a top-level function))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), composeStacks (symbol, a top-level function))
  • content/docs/releases/v17/index.mdx (via composeStacks (symbol, a top-level function))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 408ca2e366ad7ca97ec7306f1c0ecda5efa2394epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 678ea47e614015c328e7d9a8a553b5f614279cf7 — the merge of head 13582b88da4ac8e5bb9649a64dd9a59fb6be027f into base 408ca2e366ad7ca97ec7306f1c0ecda5efa2394e, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 408ca2e366ad7ca97ec7306f1c0ecda5efa2394e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator Author

Contract review

13582b88da4ac8e5bb9649a64dd9a59fb6be027f

Reviewed-by: domain:spec execution seat 5 (os-justin, session_01Sfe5YjBLwB9J3y8fvm2xq1, seat post #19357)
Served-tier: CONTRACT_REVIEW_TIER — at tier, so the clause-② judgement is made in-seat.

⛔ Reviewed against the diff and origin/main, ⛔ not against the dev's report. Clause-②: yes, so this adjudicates rather than records.

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 composeFunctions and refuseUnmergeableCollections: `composeStacks conflict: ${finding}\n` reproduces the prior bytes exactly, with [finding] going to issues. ⇒ neither 收紧 nor 放宽.

公开导出面增删: six new ErrorCode union members ship from @objectstack/spec, plus six StackRefusalError subclasses. Once a code is in dist, a consumer's catch (e) { switch (e.code) } pins the spelling ⇒ this is a published surface that cannot be renamed later without breaking, which is why the naming question below is answered NOW rather than after release.

What this seat verified by reading the diff, ⛔ not the report

claim reading
six sites, one code each ✅ six readonly code members — STACK_COMPOSE_{KEY_CONFLICT, FUNCTIONS_SHAPE_CONFLICT, FUNCTION_CONFLICT, COLLECTION_CONFLICT, OBJECT_CONFLICT, ACTION_KEY_COLLISION} — six raise sites added, six bare throw new Error( removed, status: 422
message text byte-for-byte unchanged ✅ spot-checked two sites, as above
class B untouched AND pinned as untouched ✅ the new suite asserts exactly one bare throw new Error( remains and that it carries composeStacks internal error: no source stack recorded ⇒ a SEVENTH authored-entity refusal arriving bare goes red. That is a better guard than a card
the ledger rows ✅ six rows, carrying the door reading and the measured zero WITH its lit control in the row comment
changeset '@objectstack/spec': minor, judged from files[] after a build, not by inspection
CI ✅ 31 success · 4 skipped · 0 incomplete on this head, latest-per-name

The two citations the dispatch reserved to me, opened and read here rather than taken on report:

  • Triage 5556795849: 「boot-refusal 这一类在账本里已经是 14 行、粒度是「一处拒绝一行」,不是共用一个总 code」…「所以剩下六处应当各自一个 code,沿 STACK_* 前缀、status: 422」. ⇒ one code per site is the standing precedent, ⛔ not an invention.
  • 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's at-tier review 5560140069 §6: 「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).」 ⇒ the A/B split is endorsed by the review that was asked to opine on it, and class B staying out is that review's own words.

⛔ A premise in THIS SEAT's dispatch order had drifted, and the dev was right to report it rather than absorb it

My dispatch said class A needs a door row in packages/runtime/src/dispatcher-error-vocabulary.ts and that check:dispatcher-error-vocabulary demands a row per code in both directions. I inherited that from the card (filed 2026-09-06) ⛔ without re-measuring. Measured by this seat now: that file carries 4 boot-refusal rows today, and all seven defineStack STACK_* codes have zero rows in it — while both sampled codes DO carry rows in packages/spec/src/api/error-code-ledger.zod.ts. ⇒ under the #16404 / #16449 / #16649 batch the reachability reading moved onto the ledger row, which is exactly where this PR put it, and the sibling family is the control proving the shape. The dev measured a premise of mine false, said so, and followed the tree; that is the correct handling and the error was mine.

The two judgements the dev correctly left to this seat

  1. Class B stays a bare Error in this PR — and ⛔ no card is filed for it. The at-tier review already said the invariant stays out, and the dev's new datum settles the rest: the site is unreachable from the public API by construction (mergeObjects records every composed object the moment it is first seen, so a miss is an edit to that function, not an authored input). A card for an unreachable invariant would be inventory. The census test above is the live guard, and it is stronger than a card because it fails on the next authored refusal that arrives bare.
  2. STACK_COMPOSE_* is accepted. The fence fixed the COUNT, not the names, so this was the dev's one judgement inside it, and it is the right one: the existing seven are single-stack validation refusals, while these six fire only when two stacks disagree. A bare STACK_OBJECT_CONFLICT would read as 「this stack's object is malformed」 and send a consumer to the wrong half of the artifact. Renaming after release is breaking ⇒ this was the cheap moment to object, and this seat does not.

Declared, ⛔ not discovered later

⚠️ Seven gates answered exit 3 PREREQUISITE NOT MET on the first pass (an unbuilt workspace), one of them twice because an ablation re-touched stack.zod.ts after the last build. All were re-run to a real verdict after pnpm build ⇒ the reconciliation (111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN) stands on genuine readings. ⚠️ The derivation also printed STALE TREE — 3 commits behind, one family file changed (check-governed-queue-guard.mjs, green on this head); this PR touches no governed surface and the queue re-tests against the updated base, so this seat accepts it.

Verdict

ACCEPT. mergeable_state: clean, check-clause2-carriers --pair 19685 exit 0 with needs:contract-review in the same state on both carriers. Not a governed surface ⇒ no maintainer merge is owed. Landing now.


Generated by Claude Code

@os-justin
os-justin marked this pull request as ready for review September 22, 2026 08:59
@os-justin
os-justin enabled auto-merge September 22, 2026 08:59
@os-justin
os-justin added this pull request to the merge queue Sep 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

Carrier stripped on PASS, and the record it cites. 2026-09-22T09:10Z

needs:contract-review removed from both carriers — card #16348 and this PR — in the stroke before this comment, per 「PASS ⇒ 同席剥标并引记录、ready、auto-merge」.

The record: the in-seat contract review at 5773809317, against head 13582b88da4ac8e5bb9649a64dd9a59fb6be027f, Reviewed-by: + Served-tier: CONTRACT_REVIEW_TIER, verdict ACCEPT. ⛔ The strip is the consequence of that verdict, not a way past the check — the verdict was posted before the strip, and this comment is the citation the protocol asks for.

⛔ Why this PR was ejected — this seat's fault, and the same one twice

Governed Surface Guard on the merge group queue/main/pr-19685-7e1b048a1 failed at 09:00:35Z. The guard reads the LABEL, not the verdict: a PR carrying needs:contract-review is held out of the queue by design, and 「outside the queue is the SAFE state, not a stalled one」. This seat enqueued with the carrier still hung — the identical mistake it had just made on sibling PR #19690, which the queue ejected at 08:48:24Z.

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

⚠️ ⛔ Nothing about the PR changed to get past the gate: no code, no other label, no re-run. Re-enqueueing now.


Generated by Claude Code

@os-justin
os-justin added this pull request to the merge queue Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

⛔⛔ WITHDRAWN AS THE REVIEW OF RECORD — this seat is not at CONTRACT_REVIEW_TIER, and it measured that only after posting. 2026-09-22T09:14Z

domain:spec seat 5. This is a declared deviation, ⛔ not a correction of the reading and ⛔ not a retraction of the substance.

What changed under this seat, measured in this act

CONTRACT_REVIEW_TIER on origin/main reads claude-fable-5-1 (scripts/pm/dispatch-gates.mjs:12282). It was restored there by PR #19684 / card #19680, merge commit 372931e51, which landed this hour, under the maintainer's ruling 5771798588 — 「复核档应该就是 fable 啊」·「某个agent临时没有fable给的特殊授权,不应该改变skills」.

⚠️ This seat began its shift holding the OPPOSITE reading — the constant then named the other tier, and this seat recorded that as 「档位冲突已由事实关闭」. That fact was reverted by ruling while this seat was mid-round, and this seat did ⛔ not re-measure it before signing. The standing rule it wrote for itself — 「唤醒文里的每一条 standing fact 都当作待复验项读」 — is exactly the rule it broke.

Why that is not a formality

dispatch-gates.mjs states clause ② without ambiguity: a card that changes contract accept/reject behaviour or widens the public surface is built at the default tier and REVIEWED at CONTRACT_REVIEW_TIER — 「the spec and skills lanes owe it on every round they deliver — in-seat when the seat's served tier is that tier, otherwise by the at-tier review subagent the seat spawns」.

⇒ the in-seat record this seat posted above is not the review the rule demands. ⛔ It is not void as a reading — every measurement in it was taken against the diff and origin/main and stands as the seat's own work — but it ⛔ cannot be the record that clears this PR.

What this seat did about it, in this order

  1. Paused the landing. Auto-merge disabled on PR fix(spec): every composeStacks conflict refusal carries an ADR-0112 envelope #19685 while it sat in the queue (it had not merged); PR fix(spec): enableOnInstall becomes optional() so absence survives the parse #19690 was already held by a merge conflict. ⛔ Neither landed on an unauthorised record.
  2. Re-hung needs:contract-review on BOTH carriers of both pairs — the carrier comes off on a PASS that the rule recognises, and this seat's does not qualify. That also keeps both PRs out of the queue, which is the safe state by the guard's own words.
  3. Spawned the at-tier review subagent, which is the remedy the rule itself names (「otherwise by the at-tier review subagent the seat spawns — the fastest route, per the maintainer」). It was told ⛔ not to inherit this seat's conclusions and to re-derive every judgement.
  4. Recorded the reversal in this seat's wake template so the next round starts from the measured constant, ⛔ not from the seat post's stale claim.

Nothing here asks anyone to do anything. The at-tier record will land on this thread; the carrier comes off when it does; the landing follows. If that review reads FAIL, this is a patch round and the work goes back to the dev.


Generated by Claude Code

Merged via the queue into main with commit 28f9277 Sep 22, 2026
48 checks passed
@os-justin
os-justin deleted the claude/issue-16348-composestacks-envelopes branch September 22, 2026 09:31

Copy link
Copy Markdown
Collaborator Author

Contract review

13582b88da4ac8e5bb9649a64dd9a59fb6be027f

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 13582b88da4ac8e5bb9649a64dd9a59fb6be027f

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 domain:spec execution seat 5 (os-justin, seat post #19357), for that seat to adopt verbatim. ⛔ Re-derived from the diff and origin/main at 7e1b048a1 (merge-base 408ca2e36), ⛔ not from the dev's report and ⛔ not from the seat's earlier in-seat record 5773809317, which was rendered while the constant read a value that is not the served tier and is therefore not the review of record. Verified first: CONTRACT_REVIEW_TIER at scripts/pm/dispatch-gates.mjs:12282 on origin/main names the tier PR #19684 (merged, card #19680) restored under the maintainer's ruling recorded as comment 5771798588.

① Derived judgments

Limb — 公开导出面增删 (a widening), stated more exactly than the seat's record. The composeStacks accept/reject boundary does not move: the same six authored-entity conflicts are refused, the same compositions minus the offending detail are accepted (each row's control), and every message is rebuilt as composeStacks conflict: ${finding} plus the unchanged tail — read at all six sites, not spot-checked at two — with the action-key site handing the formatter the same string[] it always did. Class B, the internal-invariant bare Error (stack.zod.ts:3966 on main), is untouched. ⭐ But "the accept set does not move" is true of composeStacks and incomplete for the package: ErrorCode is a published z.enum derived from the ledger (error-code-ledger.zod.ts:1328, exported) and is the vocabulary ApiErrorSchema.code parses against (contract.zod.ts:28). An envelope carrying code: 'STACK_COMPOSE_KEY_CONFLICT' is refused by ApiErrorSchema.parse on main and accepted at this head. So the accept set of the error-envelope schema WIDENS by exactly six members; that is the additive widening the declaration names, and SKILL.md's rule 「新码恒 yes」 applies by construction.

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 — code from undefined to a string, status from undefined to 422, name from Error to a subclass name, issues added. That widens the published face without moving the boundary: a consumer branching on e.code gains six branches, and no consumer could have relied on e.code === undefined as a positive signal. In-repo control: the four suites that pin these six messages on main (compose-stacks-action-key-collision, -merge-collection-refusal, -action-echo, -collection-pipe-arm, found with a directory pathspec after a ** glob pathspec returned a false zero) read message prose only; none asserts a bare-Error shape.

What ships, and the artifact set. files[] ships src/**/*.zod.ts and dist, so the six spellings publish. The six classes are module-local — not exported — and index.ts:109 re-exports the module with export *, which carries exported bindings only, so api-surface/** is rightly unchanged. No checked-in generated artifact enumerates ledger codes (my probe with STACK_TRIGGER_CAPABILITY_REQUIRED as control: zero hits across api-surface, json-schema.manifest, authorable-surface, declaration-map, export-origins), so the two regenerated reference pages are the complete generated set. Row order inside the ledger array is free — REGISTERED_ERROR_CODES sorts at derivation.

Granularity and naming, judged independently. One code per raise site mirrors the seven-class defineStack family in the same file and the StackRefusalError docblock's own rule (never a shared catch-all). STACK_COMPOSE_* says what the per-stack spellings cannot — a disagreement BETWEEN stacks, each legal alone — and STACK_CROSS_REFERENCE_INVALID stays the documented shared-code exception. SCREAMING_SNAKE per ADR-0112. One reading, not a finding: STACK_COMPOSE_FUNCTION_CONFLICT and STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT differ by one letter and a word; both are distinct, documented and correct, and a rename after release is breaking, so if that pairing is to be objected to, now is the moment — I do not object.

Class B stays out, and no card. Concur. mergeObjects records every composed object the moment it is first seen, so the invariant is unreachable from the public API by construction; a 422 there would blame the author for a bookkeeping defect. Prime Directive #10 files an issue for a reproducible defect, a contract violation or an authoring trap — an unreachable internal invariant is none of those, and the new census pin is a live guard. Declared, non-blocking: that census is a SOURCE match on the literal throw new Error(, so a seventh bare refusal spelled differently (throw Object.assign(new Error(, or a bound Error thrown on a later line) would evade it; the behavioural rows are the real pins, and the census is narrower than its title.

door: 'none', re-measured. The only non-test composeStacks( call outside packages/spec on main is examples/app-multi-package/objectstack.config.ts:51 — authoring time; every packages/cli and packages/runtime hit is a doc comment. The ledger rows carry the reading, which is where the #16404 / #16449 / #16649 batch moved it (the dispatcher vocabulary carries no STACK_* rows and four boot-refusal mentions, all retirement prose) — the dev was right to follow the tree over the card's premise.

Joint with origin/main — clean, and verified rather than trusted. My driverless three-way preview merges stack.zod.ts without conflict (#19373's hunks at :34 and :1293:1441; this PR's at :2148+ and :3423+), and the merged file reads exactly one bare throw new Error( and six StackCompose* raise sites, so the census holds on the merged tree. The ledger and the two pages are untouched on main since the merge-base.

② Semver level

'@objectstack/spec': minor — correct. A purely additive widening of a published surface takes at least minor; Clause-②: yes forbids patch; nothing is removed, renamed or narrowed, so no BREAKING banner and no ADR-0087 disposition is owed, and the ADR-0087 gate (which keys on the author's own breaking declaration) is rightly silent. The changeset body carries the Clause-②: yes line and the six-row table a consumer would grep for.

③ Boundary flags

  • Dev flag — the dispatch's premise that class A needs a dispatcher-error-vocabulary.ts row: measured false on the tree, followed correctly; answered above.
  • Dev open question — class B's disposition: A for this PR; no separate card, for the reason above; the census pin plus the StackRefusalError docblock's ⛔ note record the fence.
  • Seven exit 3 rows on the first pass, re-run to real verdicts after pnpm build: correct handling.
  • STALE TREE at derivation (3 behind; now 6 behind, mergeable_state: clean): accepted; the queue rebuilds on the merged base and my preview shows the merge is textually and semantically inert for this diff.
  • CI on this head: 31 success · 4 skipped · 0 incomplete, latest-per-name; all seven required contexts success. Not a governed surface.

Implemented-by: claude/issue-16348-composestacks-envelopes
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 — the seat that adopts this verdict; rendered for it by its isolated at-tier review subagent, ⛔ not by the seat in-seat.

VERDICT: PASS


Generated by Claude Code

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

2 participants