Skip to content

Commit 28f9277

Browse files
os-justinclaude
andauthored
fix(spec): every composeStacks conflict refusal carries an ADR-0112 envelope (#19685)
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: - `packages/spec/src/stack.zod.ts:2019-2147` — the seven classes PR #16342 landed, each `extends StackRefusalError` with exactly one `readonly 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. - The `StackRefusalError` docblock states the rule in words: "⭐ One code per site, never a shared `STACK_VALIDATION_FAILED` catch-all". - The triage reading `5556795849` on #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 `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 `minor` — `check-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 - The `composeStacks conflict:` message prose is untouched by design, so the suites that pin it whole (`compose-stacks-merge-collection-refusal.test.ts` and `compose-stacks-action-key-collision.test.ts` assert entire messages with `toBe`) are the byte-for-byte evidence, and they pass unchanged. - No widening into `composeStacks`' merge semantics, into class B, or into #16075 / #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 即可;账本行退出会让 `ErrorCode` union 收窄,那是一次破坏性动作,所以回滚要连 changeset 一起撤。 **席位意见** — (留给 PM 席位填) **你要做的** — ① 确认 `STACK_COMPOSE_*` 这个拼写族可接受(这是粒度栅栏之内我自己下的唯一判断:名字要说清「冲突在两个 stack 之间」);② 确认 class B 继续留白是你要的结果,还是要另立一张卡给它一个 500 类信封;③ 这是达档契约复核件(clause-② yes),落地前需要本车道的 tier 记录。 --- _Generated by [Claude Code](https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7e1b048 commit 28f9277

6 files changed

Lines changed: 568 additions & 20 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): every `composeStacks` conflict refusal carries an ADR-0112 envelope — six new `STACK_COMPOSE_*` codes beside the `defineStack` family
6+
7+
`composeStacks` refuses six authored-entity conflicts, and until now every one of them threw
8+
`new Error(message)` with `code` and `status` both `undefined`. The `defineStack` family in the
9+
same file has carried the envelope since #15963, so `packages/spec/src/stack.zod.ts` held two
10+
refusal families that are the same thing to an author — a stack refused at authoring time,
11+
through the same callers — and two different things to a consumer branching on `error.code`.
12+
13+
Every message in the family carries the literal `composeStacks conflict:` prefix, which is how it
14+
is located: FIVE of the six raise inside helper functions 300-800 lines above `composeStacks`'
15+
own body, so reading the function the defect is named after finds one of them.
16+
17+
| refusal | raised by | code |
18+
| :--- | :--- | :--- |
19+
| a single-valued top-level key declared with different values by two stacks | `composeSingleValue` | `STACK_COMPOSE_KEY_CONFLICT` |
20+
| `functions` authored in the map form by one stack, the array form by another | `composeFunctions` | `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT` |
21+
| two stacks defining one handler name | `composeFunctions` | `STACK_COMPOSE_FUNCTION_CONFLICT` |
22+
| under `objectConflict: 'merge'`, an object-level collection other than `fields` declared differently | `refuseUnmergeableCollections` | `STACK_COMPOSE_COLLECTION_CONFLICT` |
23+
| the same object name in two stacks under the default `objectConflict: 'error'` | `mergeObjects` | `STACK_COMPOSE_OBJECT_CONFLICT` |
24+
| a cross-stack action key collision | `collectComposedActionKeyCollisions` | `STACK_COMPOSE_ACTION_KEY_COLLISION` |
25+
26+
Each carries `status: 422` — an unprocessable authored entity, not a server fault — and the
27+
findings the site collected in `issues`, one entry per finding. **Message text is byte-for-byte
28+
unchanged at every site**: this adds the machine-readable half, it rewords no sentence, and the
29+
message pins across the repo read the prose they always did.
30+
31+
One code per refusal site rather than a shared `STACK_COMPOSE_CONFLICT` catch-all — the
32+
granularity the `defineStack` family landed with, and the granularity the ADR-0112 ledger's
33+
boot-refusal class already had before it. The `STACK_COMPOSE_*` spelling says what the
34+
per-stack family's spellings cannot: the defect is a disagreement BETWEEN stacks, each of which
35+
is legal on its own, so the fix is in the composition rather than in one malformed stack.
36+
`STACK_CROSS_REFERENCE_INVALID` stays the deliberate exception in the other direction — its
37+
per-stack and artifact passes share one code because they are one rule family over two scopes.
38+
39+
All six are registered in `ERROR_CODE_LEDGER` under `@objectstack/spec`, under the ruling that
40+
every code shipped in `dist` is the published face, door or no door. No wire door raises them:
41+
`composeStacks` runs at authoring and boot time, and the reading was re-measured here — zero
42+
`composeStacks` call sites under `packages/runtime/src` + `packages/rest/src` (7 non-test
43+
occurrences, all doc comments or message prose in one file), with `defineStack` lighting the
44+
same probe 31 times across 8 files as the positive control.
45+
46+
Not narrowed: `composeStacks` accepts and refuses exactly the inputs it did before, and no export
47+
changes — the error classes stay module-local, as every member of the `defineStack` family is,
48+
because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112
49+
contract is the `code` / `status` pair read structurally.
50+
51+
⛔ The seventh bare `Error` in that file is deliberately untouched:
52+
`composeStacks internal error: no source stack recorded for composed object …` is the code
53+
discovering its own bookkeeping is inconsistent, not an authored entity being refused. Filing it
54+
at 422 would tell an author their stack is invalid when the defect is ours. Whether it takes a
55+
500-class envelope of its own is a separate decision.
56+
57+
Clause-②: yes

‎content/docs/references/api/contract.mdx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +325 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +331 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3232
| **message** | `string` | ✅ | Readable error message |
3333
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -335,6 +335,12 @@ const result = ApiErrorSchema.parse(data);
335335
* `SSO_REGISTER_FAILED`
336336
* `SSO_REGISTER_FORBIDDEN`
337337
* `STACK_CAPABILITY_UNKNOWN`
338+
* `STACK_COMPOSE_ACTION_KEY_COLLISION`
339+
* `STACK_COMPOSE_COLLECTION_CONFLICT`
340+
* `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT`
341+
* `STACK_COMPOSE_FUNCTION_CONFLICT`
342+
* `STACK_COMPOSE_KEY_CONFLICT`
343+
* `STACK_COMPOSE_OBJECT_CONFLICT`
338344
* `STACK_CROSS_REFERENCE_INVALID`
339345
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
340346
* `STACK_NAMESPACE_PREFIX_INVALID`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,12 @@ const result = ErrorCode.parse(data);
503503
* `SSO_REGISTER_FAILED`
504504
* `SSO_REGISTER_FORBIDDEN`
505505
* `STACK_CAPABILITY_UNKNOWN`
506+
* `STACK_COMPOSE_ACTION_KEY_COLLISION`
507+
* `STACK_COMPOSE_COLLECTION_CONFLICT`
508+
* `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT`
509+
* `STACK_COMPOSE_FUNCTION_CONFLICT`
510+
* `STACK_COMPOSE_KEY_CONFLICT`
511+
* `STACK_COMPOSE_OBJECT_CONFLICT`
506512
* `STACK_CROSS_REFERENCE_INVALID`
507513
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
508514
* `STACK_NAMESPACE_PREFIX_INVALID`

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1279,7 +1279,8 @@ export const ERROR_CODE_LEDGER = {
12791279
'EXTERNAL_SCHEMA_MISMATCH',
12801280
'EXTERNAL_SCHEMA_MODE_VIOLATION',
12811281
'EXTERNAL_WRITE_FORBIDDEN',
1282-
// [#16449] The eight rows below are `door: 'none'` codes — raised at
1282+
// [#16449] The eight rows below — through `STACK_TRIGGER_CAPABILITY_REQUIRED`
1283+
// — are `door: 'none'` codes — raised at
12831284
// authoring / boot, before any HTTP boundary exists — registered under the
12841285
// #16404 ruling (door or no door; see the header). Each ships in this
12851286
// package's `dist`, so its spelling is the face a consumer's
@@ -1308,6 +1309,29 @@ export const ERROR_CODE_LEDGER = {
13081309
'STACK_SCHEMA_INVALID', // `ObjectStackDefinitionSchema.safeParse` failed; `issues` carries the zod issues structurally
13091310
'STACK_SINGLE_APP_VIOLATION', // an `app` package declares more than one app (ADR-0019 D3)
13101311
'STACK_TRIGGER_CAPABILITY_REQUIRED', // an auto-launched flow while `requires` omits `triggers`
1312+
// [#16348] The COMPOSITION half of the same family, and `door: 'none'` on
1313+
// the same reading: the six `composeStacks` refusals, one code per raise
1314+
// site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the
1315+
// findings the site collected on `issues`. Every message carries the
1316+
// literal `composeStacks conflict:` prefix, which is how the family is
1317+
// located — five of the six raise inside helper functions, not in
1318+
// `composeStacks`' own body. Raised where `defineStack` is: `os validate` /
1319+
// `os build` and a hand-written `objectstack.config.ts`, before any HTTP
1320+
// boundary exists (measured: zero `composeStacks` CALL sites under
1321+
// `packages/runtime/src` + `packages/rest/src` — 7 non-test occurrences,
1322+
// all doc comments or message prose in one file; positive control
1323+
// `defineStack`, same two trees, 31 occurrences across 8 files).
1324+
// ⛔ NOT registered, and the absence is load-bearing: the seventh bare
1325+
// `Error` in that file, `composeStacks internal error: no source stack
1326+
// recorded for composed object …`, is an internal-bookkeeping invariant
1327+
// rather than an authored-entity refusal — a 422 would blame the author
1328+
// for our defect. Its disposition is its own decision.
1329+
'STACK_COMPOSE_ACTION_KEY_COLLISION', // two stacks declare the same action key — the collision `defineStack` refuses within one stack, one composition step later
1330+
'STACK_COMPOSE_COLLECTION_CONFLICT', // under `objectConflict: 'merge'`, an object-level collection other than `fields` is declared with different values by two stacks
1331+
'STACK_COMPOSE_FUNCTION_CONFLICT', // two stacks define a handler under the same name; handlers resolve by name at boot
1332+
'STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT', // `functions` authored in the map form by one stack and the array form by another
1333+
'STACK_COMPOSE_KEY_CONFLICT', // a single-valued top-level key is declared with different values by two stacks
1334+
'STACK_COMPOSE_OBJECT_CONFLICT', // the same object name is defined by more than one stack under the default `objectConflict: 'error'`
13111335
],
13121336
} as const satisfies Record<string, readonly string[]>;
13131337

0 commit comments

Comments
 (0)