Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .changeset/composestacks-refusal-envelopes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
'@objectstack/spec': minor
---

feat(spec): every `composeStacks` conflict refusal carries an ADR-0112 envelope — six new `STACK_COMPOSE_*` codes beside the `defineStack` family

`composeStacks` refuses six authored-entity conflicts, and until now every one of them threw
`new Error(message)` with `code` and `status` both `undefined`. The `defineStack` family in the
same file has carried the envelope since #15963, so `packages/spec/src/stack.zod.ts` held two
refusal families that are the same thing to an author — a stack refused at authoring time,
through the same callers — and two different things to a consumer branching on `error.code`.

Every message in the family carries the literal `composeStacks conflict:` prefix, which is how it
is located: FIVE of the six raise inside helper functions 300-800 lines above `composeStacks`'
own body, so reading the function the defect is named after finds one of them.

| 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` |
| under `objectConflict: 'merge'`, an object-level collection other than `fields` declared differently | `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` |

Each carries `status: 422` — an unprocessable authored entity, not a server fault — and the
findings the site collected in `issues`, one entry per finding. **Message text is byte-for-byte
unchanged at every site**: this adds the machine-readable half, it rewords no sentence, and the
message pins across the repo read the prose they always did.

One code per refusal site rather than a shared `STACK_COMPOSE_CONFLICT` catch-all — the
granularity the `defineStack` family landed with, and the granularity the ADR-0112 ledger's
boot-refusal class already had before it. The `STACK_COMPOSE_*` spelling says what the
per-stack family's spellings cannot: the defect is a disagreement BETWEEN stacks, each of which
is legal on its own, so the fix is in the composition rather than in one malformed stack.
`STACK_CROSS_REFERENCE_INVALID` stays the deliberate exception in the other direction — its
per-stack and artifact passes share one code because they are one rule family over two scopes.

All six are registered in `ERROR_CODE_LEDGER` under `@objectstack/spec`, under the ruling that
every code shipped in `dist` is the published face, door or no door. No wire door raises them:
`composeStacks` runs at authoring and boot time, and the reading was re-measured here — zero
`composeStacks` call sites under `packages/runtime/src` + `packages/rest/src` (7 non-test
occurrences, all doc comments or message prose in one file), with `defineStack` lighting the
same probe 31 times across 8 files as the positive control.

Not narrowed: `composeStacks` accepts and refuses exactly the inputs it did before, and no export
changes — the error classes stay module-local, as every member of the `defineStack` family is,
because `packages/spec/src/index.ts` re-exports the module with `export *` and the ADR-0112
contract is the `code` / `status` pair read structurally.

⛔ The seventh bare `Error` in that file is deliberately untouched:
`composeStacks internal error: no source stack recorded for composed object …` is the code
discovering its own bookkeeping is inconsistent, not an authored entity being refused. Filing it
at 422 would tell an author their stack is invalid when the defect is ours. Whether it takes a
500-class envelope of its own is a separate decision.

Clause-②: yes
8 changes: 7 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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) |
| **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) |
| **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) |
| **message** | `string` | ✅ | Readable error message |
| **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`. |
Expand Down Expand Up @@ -335,6 +335,12 @@ const result = ApiErrorSchema.parse(data);
* `SSO_REGISTER_FAILED`
* `SSO_REGISTER_FORBIDDEN`
* `STACK_CAPABILITY_UNKNOWN`
* `STACK_COMPOSE_ACTION_KEY_COLLISION`
* `STACK_COMPOSE_COLLECTION_CONFLICT`
* `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT`
* `STACK_COMPOSE_FUNCTION_CONFLICT`
* `STACK_COMPOSE_KEY_CONFLICT`
* `STACK_COMPOSE_OBJECT_CONFLICT`
* `STACK_CROSS_REFERENCE_INVALID`
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
* `STACK_NAMESPACE_PREFIX_INVALID`
Expand Down
6 changes: 6 additions & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,12 @@ const result = ErrorCode.parse(data);
* `SSO_REGISTER_FAILED`
* `SSO_REGISTER_FORBIDDEN`
* `STACK_CAPABILITY_UNKNOWN`
* `STACK_COMPOSE_ACTION_KEY_COLLISION`
* `STACK_COMPOSE_COLLECTION_CONFLICT`
* `STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT`
* `STACK_COMPOSE_FUNCTION_CONFLICT`
* `STACK_COMPOSE_KEY_CONFLICT`
* `STACK_COMPOSE_OBJECT_CONFLICT`
* `STACK_CROSS_REFERENCE_INVALID`
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
* `STACK_NAMESPACE_PREFIX_INVALID`
Expand Down
26 changes: 25 additions & 1 deletion packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1279,7 +1279,8 @@ export const ERROR_CODE_LEDGER = {
'EXTERNAL_SCHEMA_MISMATCH',
'EXTERNAL_SCHEMA_MODE_VIOLATION',
'EXTERNAL_WRITE_FORBIDDEN',
// [#16449] The eight rows below are `door: 'none'` codes — raised at
// [#16449] The eight rows below — through `STACK_TRIGGER_CAPABILITY_REQUIRED`
// — are `door: 'none'` codes — raised at
// authoring / boot, before any HTTP boundary exists — registered under the
// #16404 ruling (door or no door; see the header). Each ships in this
// package's `dist`, so its spelling is the face a consumer's
Expand Down Expand Up @@ -1308,6 +1309,29 @@ export const ERROR_CODE_LEDGER = {
'STACK_SCHEMA_INVALID', // `ObjectStackDefinitionSchema.safeParse` failed; `issues` carries the zod issues structurally
'STACK_SINGLE_APP_VIOLATION', // an `app` package declares more than one app (ADR-0019 D3)
'STACK_TRIGGER_CAPABILITY_REQUIRED', // an auto-launched flow while `requires` omits `triggers`
// [#16348] The COMPOSITION half of the same family, and `door: 'none'` on
// the same reading: the six `composeStacks` refusals, one code per raise
// site, every one `status: 422` (`StackRefusalError`, `stack.zod.ts`), the
// findings the site collected on `issues`. Every message carries the
// literal `composeStacks conflict:` prefix, which is how the family is
// located — five of the six raise inside helper functions, not in
// `composeStacks`' own body. Raised where `defineStack` is: `os validate` /
// `os build` and a hand-written `objectstack.config.ts`, before any HTTP
// boundary exists (measured: zero `composeStacks` CALL sites under
// `packages/runtime/src` + `packages/rest/src` — 7 non-test occurrences,
// all doc comments or message prose in one file; positive control
// `defineStack`, same two trees, 31 occurrences across 8 files).
// ⛔ NOT registered, and the absence is load-bearing: the seventh bare
// `Error` in that file, `composeStacks internal error: no source stack
// recorded for composed object …`, is an internal-bookkeeping invariant
// rather than an authored-entity refusal — a 422 would blame the author
// for our defect. Its disposition is its own decision.
'STACK_COMPOSE_ACTION_KEY_COLLISION', // two stacks declare the same action key — the collision `defineStack` refuses within one stack, one composition step later
'STACK_COMPOSE_COLLECTION_CONFLICT', // under `objectConflict: 'merge'`, an object-level collection other than `fields` is declared with different values by two stacks
'STACK_COMPOSE_FUNCTION_CONFLICT', // two stacks define a handler under the same name; handlers resolve by name at boot
'STACK_COMPOSE_FUNCTIONS_SHAPE_CONFLICT', // `functions` authored in the map form by one stack and the array form by another
'STACK_COMPOSE_KEY_CONFLICT', // a single-valued top-level key is declared with different values by two stacks
'STACK_COMPOSE_OBJECT_CONFLICT', // the same object name is defined by more than one stack under the default `objectConflict: 'error'`
],
} as const satisfies Record<string, readonly string[]>;

Expand Down
Loading
Loading