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
21 changes: 21 additions & 0 deletions .changeset/register-shipped-error-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"@objectstack/spec": minor
---

feat(spec): register every error code that ships in `dist` — `OBJECT_OWNERSHIP_CONFLICT`, the seven `STACK_*` `defineStack` refusals and `PLUGIN_UI_REQUIRED_KEY_MISSING` enter `ERROR_CODE_LEDGER` (#16449)

Under the #16404 ruling (director seat, decision batch #62, 2026-09-07, option D; maintainer 「同意」) **the published contract face for error codes is `ERROR_CODE_LEDGER` / `StandardErrorCode`**: every `code` that ships in a package's `dist` is registered there, door or no door, because a consumer's `catch (e) { switch (e.code) }` pins the spelling the moment it ships and nothing could flag a later rename. Nine codes were shipping unregistered on this tree and now have rows, each under the package that stamps it:

| code | stamped by | `status` | reaches an HTTP door on this tree? |
|---|---|---|---|
| `OBJECT_OWNERSHIP_CONFLICT` | `@objectstack/objectql` (`SchemaRegistry.registerObject`, ADR-0029 D3) | 422 | no — every path aborts boot or is caught below any door |
| `STACK_SCHEMA_INVALID` · `STACK_CAPABILITY_UNKNOWN` · `STACK_CROSS_REFERENCE_INVALID` · `STACK_NAMESPACE_PREFIX_INVALID` · `STACK_SINGLE_APP_VIOLATION` · `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` · `STACK_TRIGGER_CAPABILITY_REQUIRED` | `@objectstack/spec` (`defineStack`, #14552 / #15963) | 422 | no — raised by `os validate` / `os build` and the host configs at boot |
| `PLUGIN_UI_REQUIRED_KEY_MISSING` | `@objectstack/spec` (`PluginSchema`'s `superRefine`, on the zod issue's `params.code`, #16334) | rides `PLUGIN_CONTRACT_VIOLATION`'s | no — raised at `kernel.use()` |

The card's ninth, `NAMESPACE_CONFLICT`, was already registered by #14748 and already answers `error.code: NAMESPACE_CONFLICT` at `POST /api/v1/packages`; this release changes nothing there.

**Wire consequence, stated plainly.** For a code that reaches an HTTP door, registration changes what a client reads: `error.code` becomes the specific code instead of the standard member the status derives (`VALIDATION_ERROR` for 422) with the producer's spelling demoted beside it in `declaredCode`. That is the ruling's intended effect — a consumer can branch on the real code — and it is what the Clause-② review judges. Measured on this tree, **none of the nine has such a door** (the table's last column; `OBJECT_OWNERSHIP_CONFLICT`'s reading was re-taken: the only two non-test `registerObject` callers outside `objectql`, both in `metadata-protocol`, catch it and log), so **no HTTP body changes with this release**. What changes is the face: `ErrorCode` — the union `ApiErrorSchema.code` parses against — gains nine members, `REGISTERED_ERROR_CODES` lists them, the generated docs references carry them (`check:generated` found nothing else stale — no authorable schema and no JSON-schema artifact reads this union), and each refusal's `e.code` is now a member of the union a consumer's exhaustive `switch` is written over. Should a door ever answer with one of these codes, the wire carries the specific code from then on. The `declaredCode` demotion (#9106) remains for genuinely unknown / third-party spellings only.

**Why `minor`, and no `BREAKING` banner.** Nothing is removed or renamed; every existing body parses exactly as before. The change is a purely additive widening of a published surface (nine new `ErrorCode` members), which the 2026-09-04 ruling on #15294 requires to be at least `minor`. The one consumer-visible cost is type-level: an exhaustive `switch` over the `ErrorCode` TYPE gains nine cases to cover — additive, and the shape the ruling asks for.

Also in this release, as the mechanism that keeps the class closed: `check:dispatcher-error-vocabulary` now refuses to classify a `packages/spec/src/**` stamp site as anything but `foreign-vocabulary` or `runtime-pinned` (`spec-face-unregistered`) — a code raised under the spec tree is a ledger member or it fails CI — and the ledger's header records the ruling as the "door or no door" rule in its own words. The nine `boot-refusal` classification rows in `dispatcher-error-vocabulary.ts` ratcheted out with the registrations, their reachability reading now carried on the ledger rows.
11 changes: 10 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' \| … +299 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' \| … +308 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 @@ -245,6 +245,7 @@ const result = ApiErrorSchema.parse(data);
* `OBJECT_API_DISABLED`
* `OBJECT_API_METHOD_NOT_ALLOWED`
* `OBJECT_OVERLAY_PACKAGE_MISMATCH`
* `OBJECT_OWNERSHIP_CONFLICT`
* `OBJECT_PACKAGE_DISABLED`
* `OPENAPI_UNAVAILABLE`
* `OS_PROTOCOL_INCOMPATIBLE`
Expand All @@ -262,6 +263,7 @@ const result = ApiErrorSchema.parse(data);
* `PLUGIN_INSTALL_FAILED`
* `PLUGIN_MANIFEST_INVALID`
* `PLUGIN_REGISTER_FAILED`
* `PLUGIN_UI_REQUIRED_KEY_MISSING`
* `PROJECT_MEMBERSHIP_REQUIRED`
* `PROJECT_NOT_FOUND`
* `PROJECT_PROVISIONING`
Expand Down Expand Up @@ -316,6 +318,13 @@ const result = ApiErrorSchema.parse(data);
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
* `SSO_REGISTER_FAILED`
* `SSO_REGISTER_FORBIDDEN`
* `STACK_CAPABILITY_UNKNOWN`
* `STACK_CROSS_REFERENCE_INVALID`
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
* `STACK_NAMESPACE_PREFIX_INVALID`
* `STACK_SCHEMA_INVALID`
* `STACK_SINGLE_APP_VIOLATION`
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
* `STORED_TYPE_NOT_CANONICAL`
* `STORE_UNAVAILABLE`
* `SUGGESTION_CONFIRM_FAILED`
Expand Down
48 changes: 48 additions & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,39 @@ is registered here — and if it merely re-spells a standard member, that
registration is a recorded waiver, never drift. A code registered NOWHERE
(a tenant app's own spelling) still reaches the wire, in `declaredCode`.

## Door or no door — every code that ships in `dist` is registered (#16404)

Ruled by the director seat (decision batch #62, 2026-09-07, option D;
maintainer 「同意」): **the published contract face for error codes is THIS
ledger together with `StandardErrorCode`.** Any `code` that ships in a
package's `dist` is registered here whether or not an HTTP door can ever
answer with it — a thrown value's `code` is what a consumer's
`catch (e) { switch (e.code) }` pins, and once shipped it cannot be renamed
without breaking that consumer. Registering a code widens this face and is
therefore a Clause-② change (`needs:contract-review`), door or no door; a
code present in `dist` and absent here is a protocol gap, not a tier
question.

ONE shape, no second list: a `door: 'none'` code is a row like any other —
the string under the package that stamps it, and a comment that states its
`status` and the reachability reading ("no HTTP door on this tree; the
thrown value is the boundary"). The dispatcher vocabulary's `boot-refusal`
verdict (`packages/runtime/src/dispatcher-error-vocabulary.ts`) records
that same reachability for the codes NOT yet registered, and a row here
ratchets its vocabulary row out exactly as a `pending-registration`
registration does. What registration changes for such a code is the face,
not the wire: nothing demotes today, and if a door ever does answer with
it, `error.code` carries the specific code instead of the status-derived
member plus `declaredCode`. The `declaredCode` demotion (#9106) stays for
genuinely unknown / third-party spellings only.

`packages/spec/src/**` is held to this mechanically:
`check:dispatcher-error-vocabulary` refuses to classify a stamp site under
that tree as anything but `foreign-vocabulary` (a different vocabulary that
merely spells itself `code`) or `runtime-pinned` — a `boot-refusal` or
`pending-registration` row for a spec site is a finding
(`spec-face-unregistered`), and the only way out is the row here.

A code emitted by several packages is listed once per emitting package —
the union dedupes; the per-package rows are provenance, not identity.

Expand Down Expand Up @@ -126,6 +159,12 @@ aborts, and the one request-reachable trigger sits inside a documented
best-effort catch that logs and continues. Its throw site and constant
(`MULTI_TENANT_UNSUPPORTED_CODE`, `@objectstack/driver-mongodb`) live on:
host boot matching is not wire vocabulary.
⚠️ That SECOND ground is superseded by #16404 ("Door or no door" above): a
boot refusal that ships in `dist` is owed a row, so the codes left out or
retired on the "not wire vocabulary" reasoning — the remaining
`boot-refusal` rows of `dispatcher-error-vocabulary.ts` — are registrations
owed under the ruling, not re-argued per card. What still retires a row is
the FIRST ground only: no producer left anywhere in `packages/**` source.
Before deleting a row, check that no producer remains repo-wide AND
that no consumer — including `objectui` and `cloud` — reads the literal;
tests that merely CONSTRUCT the code are not producers, and a test pinned to
Expand Down Expand Up @@ -361,6 +400,7 @@ const result = ErrorCode.parse(data);
* `OBJECT_API_DISABLED`
* `OBJECT_API_METHOD_NOT_ALLOWED`
* `OBJECT_OVERLAY_PACKAGE_MISMATCH`
* `OBJECT_OWNERSHIP_CONFLICT`
* `OBJECT_PACKAGE_DISABLED`
* `OPENAPI_UNAVAILABLE`
* `OS_PROTOCOL_INCOMPATIBLE`
Expand All @@ -378,6 +418,7 @@ const result = ErrorCode.parse(data);
* `PLUGIN_INSTALL_FAILED`
* `PLUGIN_MANIFEST_INVALID`
* `PLUGIN_REGISTER_FAILED`
* `PLUGIN_UI_REQUIRED_KEY_MISSING`
* `PROJECT_MEMBERSHIP_REQUIRED`
* `PROJECT_NOT_FOUND`
* `PROJECT_PROVISIONING`
Expand Down Expand Up @@ -432,6 +473,13 @@ const result = ErrorCode.parse(data);
* `SQL_DIALECT_EMISSION_UNSUPPORTED`
* `SSO_REGISTER_FAILED`
* `SSO_REGISTER_FORBIDDEN`
* `STACK_CAPABILITY_UNKNOWN`
* `STACK_CROSS_REFERENCE_INVALID`
* `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`
* `STACK_NAMESPACE_PREFIX_INVALID`
* `STACK_SCHEMA_INVALID`
* `STACK_SINGLE_APP_VIOLATION`
* `STACK_TRIGGER_CAPABILITY_REQUIRED`
* `STORED_TYPE_NOT_CANONICAL`
* `STORE_UNAVAILABLE`
* `SUGGESTION_CONFIRM_FAILED`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
*/

import { describe, it, expect } from 'vitest';
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';
import { ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
import { OBJECT_OWNERSHIP_CONFLICT_CODE, ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';

const APP_PKG = 'app.myapp';
const OTHER_PKG = 'app.otherapp';
Expand Down Expand Up @@ -156,3 +157,20 @@ describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
});
});

describe('#16449 — the ownership refusal code is a member of the closed ADR-0112 vocabulary', () => {
it('OBJECT_OWNERSHIP_CONFLICT parses against ErrorCode, and 422 cannot have derived it', () => {
// The #16404 ruling: a code that ships in `dist` is the published face,
// door or no door. No door answers with this refusal on this tree (every
// path aborts boot or is caught below any HTTP boundary), so the ledger
// row changes no body — what it fixes is the face: `e.code` on the thrown
// value is a member of the union a consumer's `switch` is exhaustive over,
// and should a door ever answer with it, `error.code` carries THIS code
// rather than the status-derived member plus `declaredCode`.
expect(ErrorCode.safeParse(OBJECT_OWNERSHIP_CONFLICT_CODE).success).toBe(true);
// The control that makes the first line discriminating: 422 does NOT
// derive this member, so a body carrying it would prove the producer's
// code was carried through, never re-derived from the status.
expect(standardErrorCodeForHttpStatus(422)).not.toBe(OBJECT_OWNERSHIP_CONFLICT_CODE);
});
});
Loading
Loading