diff --git a/.changeset/dispatcher-error-code-is-semantic.md b/.changeset/dispatcher-error-code-is-semantic.md
new file mode 100644
index 0000000000..f4d857af32
--- /dev/null
+++ b/.changeset/dispatcher-error-code-is-semantic.md
@@ -0,0 +1,88 @@
+---
+"@objectstack/spec": minor
+"@objectstack/runtime": minor
+"@objectstack/client": patch
+---
+
+fix(runtime,spec)!: the dispatcher's `error.code` is the semantic string it always declared; the HTTP status moves to `httpStatus` (#3842)
+
+`HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it
+straight into the field `ApiErrorSchema` reserves for a semantic string, so
+`error.code` came back as `400`/`403`/`503` — a number, duplicating the response
+status and occupying the one slot a caller is meant to branch on. The real code
+then had to go somewhere else, and did, three somewhere-elses: `details.code`
+(auth gate, permission denial, anonymous deny), `details.type`
+(project-membership gate), and `error.type` (`routeNotFound`). Four sites, three
+parking spots, because the declared one was full.
+
+**FROM → TO on the wire.** A dispatcher error body
+
+```json
+{ "success": false,
+ "error": { "message": "…", "code": 403, "details": { "code": "PERMISSION_DENIED" } } }
+```
+
+is now
+
+```json
+{ "success": false,
+ "error": { "code": "PERMISSION_DENIED", "message": "…", "httpStatus": 403 } }
+```
+
+| Reading | Was | Now |
+|---|---|---|
+| semantic code | `error.details.code` / `error.details.type` / `error.type` | `error.code` |
+| HTTP status | `error.code` | `error.httpStatus` (or the response status) |
+| context | `error.details` (with the code mixed in) | `error.details` (context only, absent when empty) |
+
+**One-line fix for a direct reader:** replace `body.error.details?.code ??
+body.error.type` with `body.error.code`, and `body.error.code` with
+`body.error.httpStatus`. **SDK callers need no change** — `ObjectStackClient`
+already normalised this (`err.code` semantic, `err.httpStatus` numeric) and still
+reads the old shape, so a client newer than its server is unaffected.
+
+Every code already on the wire moves **verbatim** — `PERMISSION_DENIED`,
+`ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`,
+`VALIDATION_FAILED`, `unauthenticated`. This change moves a field; it does not
+rename anything. Reconciling the repo's two code vocabularies is #3841, and this
+leaves it exactly one map and one enum to sweep instead of four parking spots.
+
+A branch with no code of its own is served one derived from the status, via the
+single declared map `HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus`
+in `@objectstack/spec/api` (`403` → `permission_denied`, `503` →
+`service_unavailable`, …). Derivation is necessary because `ApiErrorSchema.code`
+is required; drawing it from `StandardErrorCode` keeps a derived code a
+catalogued one rather than an invented string.
+
+**Spec changes:**
+
+- `ApiErrorSchema` gains optional `httpStatus: number` — the precedent is
+ `EnhancedApiErrorSchema.httpStatus`. Additive.
+- `StandardErrorCode` gains `method_not_allowed` and `precondition_required`,
+ the two statuses the runtime returns that the enum could not name. Additive.
+- **Breaking — `DispatcherErrorCode`** was `'404' | '405' | '501' | '503'` (string
+ spellings of HTTP statuses, for matching against the numeric `error.code`). It
+ is now `'ROUTE_NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'NOT_IMPLEMENTED' |
+ 'SERVICE_UNAVAILABLE'` — the same four members the removed `error.type` enum
+ declared, moved verbatim. FROM `DispatcherErrorCode.parse('404')` TO
+ `DispatcherErrorCode.parse('ROUTE_NOT_FOUND')`; to match a status, read
+ `error.httpStatus`. TypeScript flags every call site.
+- **Breaking — `DispatcherErrorResponseSchema`**: `error.code` is `z.string()`
+ (was `z.number().int()`), `error.type` is **removed** (folded into `code`), and
+ `error.httpStatus` / `error.details` are declared. This schema is what
+ legitimised the deviation — it declared the opposite of `ApiErrorSchema` for
+ the same field. FROM `{ code: 404, type: 'ROUTE_NOT_FOUND' }` TO
+ `{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }`.
+
+**Also aligned, because they are the same wire surface:** `dispatcher-plugin`'s
+`errorResponseBase` (the THROWN-error exit) and its inline 404, and the MCP 405.
+`errorResponseBase` previously discarded a thrown error's `.code` outright — it
+had nowhere to put it — so the two exits of one surface disagreed about what a
+caller would see; they now agree. Every body on this surface is built by one
+helper (`packages/runtime/src/error-envelope.ts`), guarded in both directions by
+`error-envelope.conformance.test.ts`: each branch driven and parsed against the
+schema imported from `packages/spec`, plus a source scan so a new branch cannot
+quietly reintroduce a numeric `code` or a `type`-as-code sibling.
+
+This deletes the #3687 pin in `http-dispatcher.test.ts`, which asked to be
+deleted rather than updated once the dispatcher was fixed.
diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx
index dad5eabba5..4ae49b4453 100644
--- a/content/docs/api/client-sdk.mdx
+++ b/content/docs/api/client-sdk.mdx
@@ -522,9 +522,12 @@ try {
`error.code` is always the **semantic** code as a string — the numeric HTTP
status lives on `error.httpStatus` and nowhere else. This holds regardless of
which server surface answered: the REST server replies with a flat
-`{ error, code, fields }` body while the runtime dispatcher replies with a
-wrapped `{ success, error: { message, code, details } }` body whose `error.code`
-is the HTTP status, and the client normalizes both before throwing.
+`{ error, code, fields }` body and the runtime dispatcher replies with a wrapped
+`{ success, error: { code, message, httpStatus, details } }` body, and the
+client normalizes both before throwing. (Dispatcher bodies from servers older
+than #3842 put the status in `error.code` and the semantic code in
+`error.details.code`; the client still reads those, so an SDK newer than the
+server it talks to behaves the same.)
### Per-field validation errors
diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx
index 78116dd9e9..33617464d0 100644
--- a/content/docs/api/error-catalog.mdx
+++ b/content/docs/api/error-catalog.mdx
@@ -5,7 +5,7 @@ description: Complete reference for all ObjectStack error codes with causes, fix
# Error Code Catalog
-ObjectStack uses a structured error system with **9 error categories** and **51 standardized error codes**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
+ObjectStack uses a structured error system with **9 error categories** and **53 standardized error codes**. Every error includes a machine-readable code, HTTP status mapping, and retry guidance.
**Source:** `packages/spec/src/api/errors.zod.ts`
@@ -14,6 +14,8 @@ ObjectStack uses a structured error system with **9 error categories** and **51
**Spec vs. wire format:** The codes below are `StandardErrorCode` — the standardized, lowercase snake_case contract that plugin/hook authors should throw with (see [Server-Side Error Handling](/docs/api/error-handling-server)). The kernel REST server (`@objectstack/rest`) that serves `/api/v1/data/*` today emits a flatter envelope with SCREAMING_SNAKE_CASE codes instead — e.g. `VALIDATION_FAILED`, `PERMISSION_DENIED`, `RECORD_NOT_FOUND`, `CONCURRENT_UPDATE`, `ERR_DATASOURCE_UNAVAILABLE` — not the codes in this catalog. See the [API Overview](/docs/api#error-handling) for both wire formats in use and [Wire Format](/docs/api/wire-format#7-error-response-format) for JSON examples.
+
+Reconciling the two vocabularies is [#3841](https://github.com/objectstack-ai/objectstack/issues/3841), still open. In the meantime the runtime dispatcher emits whichever code its producer already used (`PERMISSION_DENIED`, `VALIDATION_FAILED`, `unauthenticated`, …) and falls back to a `StandardErrorCode` from this catalog when a branch has no code of its own — `HttpStatusErrorCodeMap` in `@objectstack/spec/api` is the single place that mapping is spelled.
---
@@ -263,6 +265,25 @@ ObjectStack uses a structured error system with **9 error categories** and **51
---
+## Request Errors (405/428)
+
+Added in #3842 so `standardErrorCodeForHttpStatus` can name every status the
+runtime actually returns. Without them a `405` would fall into the generic 4xx
+bucket and be reported as a validation failure.
+
+### `method_not_allowed`
+**Cause:** The route exists but does not serve this HTTP method.
+**Fix:** Use the method named in the response's `Allow` header.
+**Retry:** `no_retry`
+
+### `precondition_required`
+**Cause:** The request is missing a precondition the route requires — most often
+an environment scope (no `X-Environment-Id` header and no hostname mapping).
+**Fix:** Send the missing header, or address the environment-scoped URL form.
+**Retry:** `no_retry`
+
+---
+
## Rate Limit Errors (429)
### `rate_limit_exceeded`
@@ -447,10 +468,9 @@ Two naming details matter here:
`if (apiError.fields)` tests "this failure is field-anchored".
- `apiError.code` is always the semantic code as a **string**. The numeric HTTP
status is on `apiError.httpStatus` and nowhere else. This holds for both wire
- formats: the flat REST envelope carries the code at the top level, the runtime
- dispatcher's wrapped envelope carries it in `error.details.code` (its
- `error.code` is the HTTP status), and the client normalizes both before
- throwing.
+ formats: the flat REST envelope carries the code at the top level and the
+ runtime dispatcher's wrapped envelope carries it in `error.code`, with the
+ status alongside it in `error.httpStatus`.
### TypeScript Example
diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx
index 35c4254ce1..0a0861773b 100644
--- a/content/docs/api/index.mdx
+++ b/content/docs/api/index.mdx
@@ -165,19 +165,24 @@ Validation failures additionally include a `fields` array (one entry per invalid
| `RECORD_NOT_FOUND` | 404 | Resource does not exist |
| `CONCURRENT_UPDATE` | 409 | Record was modified by another user |
-**Runtime dispatcher** (`@objectstack/runtime`) wraps errors in the `{ success: false, ... }` envelope, where `code` is the numeric HTTP status:
+**Runtime dispatcher** (`@objectstack/runtime`) wraps errors in the `{ success: false, ... }` envelope declared by `ApiErrorSchema`. `code` is the semantic string; the numeric HTTP status is on `httpStatus`, and `details` carries structured context only:
```json
{
"success": false,
"error": {
+ "code": "RECORD_NOT_FOUND",
"message": "Record not found: account/123",
- "code": 404,
- "details": {}
+ "httpStatus": 404
}
}
```
+Branch on `error.code`, never on `error.httpStatus` — the status answers "what
+class of failure" and the code answers "which failure". A branch that has no
+code of its own is served a `StandardErrorCode` derived from the status
+(`403` → `permission_denied`, `503` → `service_unavailable`, …).
+
The richer `ErrorResponseSchema` in `@objectstack/spec/api` (with `category`, `retryable`, etc.) is the aspirational spec envelope, not the current wire format. Its `category` values are drawn from the `ErrorCategory` enum: `validation`, `authentication`, `authorization`, `not_found`, `conflict`, `rate_limit`, `server`, `external`, `maintenance`.
diff --git a/content/docs/api/wire-format.mdx b/content/docs/api/wire-format.mdx
index e004be0b44..9aa95e8fae 100644
--- a/content/docs/api/wire-format.mdx
+++ b/content/docs/api/wire-format.mdx
@@ -427,6 +427,44 @@ Read `body.error.code`, not `body.code`, on these routes. `ObjectStackClient`
normalizes both — `error.code` and `error.message` are populated whichever
envelope the server used — so SDK callers do not need to branch.
+### Dispatcher routes use it too
+
+Everything the runtime dispatcher serves — `/api/v1/meta/*`, `/actions/*`,
+`/packages/*`, `/automation/*`, `/analytics/*`, `/ready`, … — answers in the same
+declared envelope, plus `httpStatus`:
+
+```json
+{
+ "success": false,
+ "error": {
+ "code": "PROJECT_MEMBERSHIP_REQUIRED",
+ "message": "Forbidden: user usr_01H… is not a member of project env_prod",
+ "httpStatus": 403,
+ "details": { "environmentId": "env_prod", "userId": "usr_01H…" }
+ }
+}
+```
+
+| Field | Meaning |
+|:---|:---|
+| `code` | The semantic code — the field to branch on. Never a number. |
+| `message` | Human-readable text, safe to show. 5xx messages are sanitised. |
+| `httpStatus` | The response status, mirrored. Redundant with the response line by design. |
+| `details` | Structured context only (`fields[]`, `issues[]`, ids). Never a parked code. |
+
+A route-resolution failure spells `code` from `DispatcherErrorCode`
+(`ROUTE_NOT_FOUND`, …) and adds `route` / `hint` / `service` as siblings. A
+branch with no code of its own gets a `StandardErrorCode` derived from the
+status.
+
+
+Before [#3842](https://github.com/objectstack-ai/objectstack/issues/3842) this
+envelope put the HTTP status in `error.code` and the real code in
+`error.details.code`, `error.details.type` or `error.type`. `ObjectStackClient`
+reads all of them, so an SDK newer than the server it talks to is unaffected;
+code reading these bodies directly should move to `error.code`.
+
+
---
## 8. Batch Operations
diff --git a/content/docs/references/api/analytics.mdx b/content/docs/references/api/analytics.mdx
index 953b420255..1341925e57 100644
--- a/content/docs/references/api/analytics.mdx
+++ b/content/docs/references/api/analytics.mdx
@@ -45,7 +45,7 @@ const result = AnalyticsEndpoint.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ cubes: { name: string; title?: string; description?: string; sql: string; … }[] }` | ✅ | |
@@ -72,7 +72,7 @@ const result = AnalyticsEndpoint.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ rows: Record[]; fields: { name: string; type: string }[]; sql?: string }` | ✅ | |
@@ -86,7 +86,7 @@ const result = AnalyticsEndpoint.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ sql: string; params: any[] }` | ✅ | |
diff --git a/content/docs/references/api/auth.mdx b/content/docs/references/api/auth.mdx
index c696cfde93..06560ec3d9 100644
--- a/content/docs/references/api/auth.mdx
+++ b/content/docs/references/api/auth.mdx
@@ -102,7 +102,7 @@ const result = AuthProvider.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ session: object; user: object; token?: string }` | ✅ | |
@@ -138,7 +138,7 @@ const result = AuthProvider.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ id: string; email: string; emailVerified: boolean; name: string; … }` | ✅ | |
diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx
index 9c3d278b38..838c7479cc 100644
--- a/content/docs/references/api/automation-api.mdx
+++ b/content/docs/references/api/automation-api.mdx
@@ -129,7 +129,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The created flow definition |
@@ -154,7 +154,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ name: string; deleted: boolean }` | ✅ | |
@@ -197,7 +197,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | Full flow definition |
@@ -223,7 +223,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>; … }` | ✅ | Full execution log with step details |
@@ -251,7 +251,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ flows: { name: string; label: string; type: string; status: string; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
@@ -279,7 +279,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ runs: { id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying'>; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
@@ -305,7 +305,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ name: string; enabled: boolean }` | ✅ | |
@@ -335,7 +335,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ success: boolean; output?: any; error?: string; durationMs?: number }` | ✅ | |
@@ -361,7 +361,7 @@ const result = AutomationApiErrorCode.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ name: string; label: string; description?: string; successMessage?: string; … }` | ✅ | The updated flow definition |
diff --git a/content/docs/references/api/batch.mdx b/content/docs/references/api/batch.mdx
index 4c25510102..72d48a596a 100644
--- a/content/docs/references/api/batch.mdx
+++ b/content/docs/references/api/batch.mdx
@@ -60,7 +60,7 @@ const result = BatchConfig.parse(data);
| :--- | :--- | :--- | :--- |
| **id** | `string` | optional | Record ID if operation succeeded |
| **success** | `boolean` | ✅ | Whether this record was processed successfully |
-| **errors** | `{ code: string; message: string; category?: string; details?: any; … }[]` | optional | Array of errors if operation failed |
+| **errors** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }[]` | optional | Array of errors if operation failed |
| **data** | `Record` | optional | Full record data (if returnRecords=true) |
| **index** | `number` | optional | Index of the record in the request array |
| **droppedFields** | `{ object: string; fields: string[]; reason: Enum<'readonly' \| 'readonly_when'> }[]` | optional | Write-observability (#3407/#3431/#3455): caller-supplied fields LEGALLY stripped from THIS row before it was written — static `readonly` (#2948) / TRUE `readonlyWhen` (#3042) on update, or the #3043 create-ingress strip. Per-row because a batch can drop different fields on different rows (`readonlyWhen` is record-state-dependent). Present ONLY when ≥1 field was dropped for this row; the row still succeeded (success unchanged). A single response header cannot express per-row drops, so this body field is the canonical bulk channel — REST does not emit `X-ObjectStack-Dropped-Fields` for batches. Optional — omit-when-empty keeps the shape backward-compatible. |
@@ -127,13 +127,13 @@ const result = BatchConfig.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed |
| **total** | `number` | ✅ | Total number of records in the batch |
| **succeeded** | `number` | ✅ | Number of records that succeeded |
| **failed** | `number` | ✅ | Number of records that failed |
-| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; details?: any; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record |
+| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record |
---
diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx
index a42cce44c9..a8ef3a938f 100644
--- a/content/docs/references/api/contract.mdx
+++ b/content/docs/references/api/contract.mdx
@@ -5,7 +5,23 @@ description: Contract protocol schemas
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-Standard Create Request
+The numeric HTTP status, when a producer chooses to mirror it into the body.
+
+Declared (#3842) because the runtime dispatcher had nowhere to put it and
+
+used `code` — the semantic slot above — instead, handing callers `403` where
+
+the contract promises a string and forcing the real code into `details`.
+
+`EnhancedApiErrorSchema.httpStatus` set the precedent; this is the same
+
+field on the base envelope.
+
+Optional and redundant on purpose: the response status is authoritative, so
+
+a producer that emits only the semantic `code` is fully conformant. Callers
+
+should branch on `code`, not on this.
**Source:** `packages/spec/src/api/contract.zod.ts`
@@ -32,6 +48,7 @@ const result = ApiError.parse(data);
| **code** | `string` | ✅ | Error code (e.g. validation_error) |
| **message** | `string` | ✅ | Readable error message |
| **category** | `string` | optional | Error category (e.g. validation, authorization) |
+| **httpStatus** | `integer` | optional | HTTP status of the response carrying this error |
| **details** | `any` | optional | Additional error context (e.g. field validation errors) |
| **requestId** | `string` | optional | Request ID for tracking |
@@ -45,7 +62,7 @@ const result = ApiError.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
@@ -84,9 +101,9 @@ const result = ApiError.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
-| **data** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; details?: any; … }[]; index?: number; … }[]` | ✅ | Results for each item in the batch |
+| **data** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; httpStatus?: integer; … }[]; index?: number; … }[]` | ✅ | Results for each item in the batch |
---
@@ -126,7 +143,7 @@ const result = ApiError.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **id** | `string` | ✅ | ID of the deleted record |
@@ -177,7 +194,7 @@ const result = ApiError.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `Record[]` | ✅ | Array of matching records |
| **pagination** | `{ total?: number; limit?: number; offset?: number; cursor?: string; … }` | ✅ | Pagination info |
@@ -193,7 +210,7 @@ const result = ApiError.parse(data);
| :--- | :--- | :--- | :--- |
| **id** | `string` | optional | Record ID if processed |
| **success** | `boolean` | ✅ | |
-| **errors** | `{ code: string; message: string; category?: string; details?: any; … }[]` | optional | |
+| **errors** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }[]` | optional | |
| **index** | `number` | optional | Index in original request |
| **data** | `any` | optional | Result data (e.g. created record) |
@@ -226,7 +243,7 @@ const result = ApiError.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `Record` | ✅ | The requested or modified record |
diff --git a/content/docs/references/api/dispatcher.mdx b/content/docs/references/api/dispatcher.mdx
index 4aae69ddf5..18d82d1b95 100644
--- a/content/docs/references/api/dispatcher.mdx
+++ b/content/docs/references/api/dispatcher.mdx
@@ -60,14 +60,14 @@ const result = DispatcherConfig.parse(data);
## DispatcherErrorCode
-404 = route not found, 405 = method not allowed, 501 = not implemented (stub), 503 = service unavailable
+Route-resolution failure mode emitted in `error.code`
### Allowed Values
-* `404`
-* `405`
-* `501`
-* `503`
+* `ROUTE_NOT_FOUND`
+* `METHOD_NOT_ALLOWED`
+* `NOT_IMPLEMENTED`
+* `SERVICE_UNAVAILABLE`
---
@@ -79,7 +79,7 @@ const result = DispatcherConfig.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `'false'` | ✅ | |
-| **error** | `{ code: integer; message: string; type?: Enum<'ROUTE_NOT_FOUND' \| 'METHOD_NOT_ALLOWED' \| 'NOT_IMPLEMENTED' \| 'SERVICE_UNAVAILABLE'>; route?: string; … }` | ✅ | |
+| **error** | `{ code: string; message: string; httpStatus?: integer; route?: string; … }` | ✅ | |
---
diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx
index a17adfc945..5de6d7615e 100644
--- a/content/docs/references/api/errors.mdx
+++ b/content/docs/references/api/errors.mdx
@@ -45,7 +45,7 @@ const result = EnhancedApiError.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' \| 'invalid_reference' \| 'duplicate_value' \| 'invalid_query' \| 'invalid_filter' \| 'invalid_sort' \| 'max_records_exceeded' \| 'unauthenticated' \| 'invalid_credentials' \| 'expired_token' \| 'invalid_token' \| 'session_expired' \| 'mfa_required' \| 'email_not_verified' \| 'permission_denied' \| 'insufficient_privileges' \| 'field_not_accessible' \| 'record_not_accessible' \| 'license_required' \| 'ip_restricted' \| 'time_restricted' \| 'resource_not_found' \| 'object_not_found' \| 'record_not_found' \| 'field_not_found' \| 'endpoint_not_found' \| 'resource_conflict' \| 'concurrent_modification' \| 'delete_restricted' \| 'duplicate_record' \| 'lock_conflict' \| 'rate_limit_exceeded' \| 'quota_exceeded' \| 'concurrent_limit_exceeded' \| 'internal_error' \| 'database_error' \| 'timeout' \| 'service_unavailable' \| 'not_implemented' \| 'external_service_error' \| 'integration_error' \| 'webhook_delivery_failed' \| 'batch_partial_failure' \| 'batch_complete_failure' \| 'transaction_failed'>` | ✅ | Machine-readable error code |
+| **code** | `Enum<'validation_error' \| 'invalid_field' \| 'missing_required_field' \| 'invalid_format' \| 'value_too_long' \| 'value_too_short' \| 'value_out_of_range' \| 'invalid_reference' \| 'duplicate_value' \| 'invalid_query' \| 'invalid_filter' \| 'invalid_sort' \| 'max_records_exceeded' \| 'unauthenticated' \| 'invalid_credentials' \| 'expired_token' \| 'invalid_token' \| 'session_expired' \| 'mfa_required' \| 'email_not_verified' \| 'permission_denied' \| 'insufficient_privileges' \| 'field_not_accessible' \| 'record_not_accessible' \| 'license_required' \| 'ip_restricted' \| 'time_restricted' \| 'resource_not_found' \| 'object_not_found' \| 'record_not_found' \| 'field_not_found' \| 'endpoint_not_found' \| 'resource_conflict' \| 'concurrent_modification' \| 'delete_restricted' \| 'duplicate_record' \| 'lock_conflict' \| 'method_not_allowed' \| 'precondition_required' \| 'rate_limit_exceeded' \| 'quota_exceeded' \| 'concurrent_limit_exceeded' \| 'internal_error' \| 'database_error' \| 'timeout' \| 'service_unavailable' \| 'not_implemented' \| 'external_service_error' \| 'integration_error' \| 'webhook_delivery_failed' \| 'batch_partial_failure' \| 'batch_complete_failure' \| 'transaction_failed'>` | ✅ | Machine-readable error code |
| **message** | `string` | ✅ | Human-readable error message |
| **category** | `Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>` | optional | Error category |
| **httpStatus** | `number` | optional | HTTP status code |
@@ -53,7 +53,7 @@ const result = EnhancedApiError.parse(data);
| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy |
| **retryAfter** | `number` | optional | Seconds to wait before retrying |
| **details** | `any` | optional | Additional error context |
-| **fieldErrors** | `{ field: string; code: Enum<'validation_error' \| 'invalid_field' \| 'missing_required_field' \| 'invalid_format' \| 'value_too_long' \| 'value_too_short' \| 'value_out_of_range' \| 'invalid_reference' \| 'duplicate_value' \| 'invalid_query' \| 'invalid_filter' \| 'invalid_sort' \| 'max_records_exceeded' \| 'unauthenticated' \| 'invalid_credentials' \| 'expired_token' \| 'invalid_token' \| 'session_expired' \| 'mfa_required' \| 'email_not_verified' \| 'permission_denied' \| 'insufficient_privileges' \| 'field_not_accessible' \| 'record_not_accessible' \| 'license_required' \| 'ip_restricted' \| 'time_restricted' \| 'resource_not_found' \| 'object_not_found' \| 'record_not_found' \| 'field_not_found' \| 'endpoint_not_found' \| 'resource_conflict' \| 'concurrent_modification' \| 'delete_restricted' \| 'duplicate_record' \| 'lock_conflict' \| 'rate_limit_exceeded' \| 'quota_exceeded' \| 'concurrent_limit_exceeded' \| 'internal_error' \| 'database_error' \| 'timeout' \| 'service_unavailable' \| 'not_implemented' \| 'external_service_error' \| 'integration_error' \| 'webhook_delivery_failed' \| 'batch_partial_failure' \| 'batch_complete_failure' \| 'transaction_failed'>; message: string; value?: any; … }[]` | optional | Field-specific validation errors |
+| **fieldErrors** | `{ field: string; code: Enum<'validation_error' \| 'invalid_field' \| 'missing_required_field' \| 'invalid_format' \| 'value_too_long' \| 'value_too_short' \| 'value_out_of_range' \| 'invalid_reference' \| 'duplicate_value' \| 'invalid_query' \| 'invalid_filter' \| 'invalid_sort' \| 'max_records_exceeded' \| 'unauthenticated' \| 'invalid_credentials' \| 'expired_token' \| 'invalid_token' \| 'session_expired' \| 'mfa_required' \| 'email_not_verified' \| 'permission_denied' \| 'insufficient_privileges' \| 'field_not_accessible' \| 'record_not_accessible' \| 'license_required' \| 'ip_restricted' \| 'time_restricted' \| 'resource_not_found' \| 'object_not_found' \| 'record_not_found' \| 'field_not_found' \| 'endpoint_not_found' \| 'resource_conflict' \| 'concurrent_modification' \| 'delete_restricted' \| 'duplicate_record' \| 'lock_conflict' \| 'method_not_allowed' \| 'precondition_required' \| 'rate_limit_exceeded' \| 'quota_exceeded' \| 'concurrent_limit_exceeded' \| 'internal_error' \| 'database_error' \| 'timeout' \| 'service_unavailable' \| 'not_implemented' \| 'external_service_error' \| 'integration_error' \| 'webhook_delivery_failed' \| 'batch_partial_failure' \| 'batch_complete_failure' \| 'transaction_failed'>; message: string; value?: any; … }[]` | optional | Field-specific validation errors |
| **timestamp** | `string` | optional | When the error occurred |
| **requestId** | `string` | optional | Request ID for tracking |
| **traceId** | `string` | optional | Distributed trace ID |
@@ -70,7 +70,7 @@ const result = EnhancedApiError.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `'false'` | ✅ | Always false for error responses |
-| **error** | `{ code: Enum<'validation_error' \| 'invalid_field' \| 'missing_required_field' \| 'invalid_format' \| 'value_too_long' \| 'value_too_short' \| 'value_out_of_range' \| 'invalid_reference' \| 'duplicate_value' \| 'invalid_query' \| 'invalid_filter' \| 'invalid_sort' \| 'max_records_exceeded' \| 'unauthenticated' \| 'invalid_credentials' \| 'expired_token' \| 'invalid_token' \| 'session_expired' \| 'mfa_required' \| 'email_not_verified' \| 'permission_denied' \| 'insufficient_privileges' \| 'field_not_accessible' \| 'record_not_accessible' \| 'license_required' \| 'ip_restricted' \| 'time_restricted' \| 'resource_not_found' \| 'object_not_found' \| 'record_not_found' \| 'field_not_found' \| 'endpoint_not_found' \| 'resource_conflict' \| 'concurrent_modification' \| 'delete_restricted' \| 'duplicate_record' \| 'lock_conflict' \| 'rate_limit_exceeded' \| 'quota_exceeded' \| 'concurrent_limit_exceeded' \| 'internal_error' \| 'database_error' \| 'timeout' \| 'service_unavailable' \| 'not_implemented' \| 'external_service_error' \| 'integration_error' \| 'webhook_delivery_failed' \| 'batch_partial_failure' \| 'batch_complete_failure' \| 'transaction_failed'>; message: string; category?: Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>; httpStatus?: number; … }` | ✅ | Error details |
+| **error** | `{ code: Enum<'validation_error' \| 'invalid_field' \| 'missing_required_field' \| 'invalid_format' \| 'value_too_long' \| 'value_too_short' \| 'value_out_of_range' \| 'invalid_reference' \| 'duplicate_value' \| 'invalid_query' \| 'invalid_filter' \| 'invalid_sort' \| 'max_records_exceeded' \| 'unauthenticated' \| 'invalid_credentials' \| 'expired_token' \| 'invalid_token' \| 'session_expired' \| 'mfa_required' \| 'email_not_verified' \| 'permission_denied' \| 'insufficient_privileges' \| 'field_not_accessible' \| 'record_not_accessible' \| 'license_required' \| 'ip_restricted' \| 'time_restricted' \| 'resource_not_found' \| 'object_not_found' \| 'record_not_found' \| 'field_not_found' \| 'endpoint_not_found' \| 'resource_conflict' \| 'concurrent_modification' \| 'delete_restricted' \| 'duplicate_record' \| 'lock_conflict' \| 'method_not_allowed' \| 'precondition_required' \| 'rate_limit_exceeded' \| 'quota_exceeded' \| 'concurrent_limit_exceeded' \| 'internal_error' \| 'database_error' \| 'timeout' \| 'service_unavailable' \| 'not_implemented' \| 'external_service_error' \| 'integration_error' \| 'webhook_delivery_failed' \| 'batch_partial_failure' \| 'batch_complete_failure' \| 'transaction_failed'>; message: string; category?: Enum<'validation' \| 'authentication' \| 'authorization' \| 'not_found' \| 'conflict' \| 'rate_limit' \| 'server' \| 'external' \| 'maintenance'>; httpStatus?: number; … }` | ✅ | Error details |
| **meta** | `{ timestamp?: string; requestId?: string; traceId?: string }` | optional | Response metadata |
@@ -83,7 +83,7 @@ const result = EnhancedApiError.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **field** | `string` | ✅ | Field path (supports dot notation) |
-| **code** | `Enum<'validation_error' \| 'invalid_field' \| 'missing_required_field' \| 'invalid_format' \| 'value_too_long' \| 'value_too_short' \| 'value_out_of_range' \| 'invalid_reference' \| 'duplicate_value' \| 'invalid_query' \| 'invalid_filter' \| 'invalid_sort' \| 'max_records_exceeded' \| 'unauthenticated' \| 'invalid_credentials' \| 'expired_token' \| 'invalid_token' \| 'session_expired' \| 'mfa_required' \| 'email_not_verified' \| 'permission_denied' \| 'insufficient_privileges' \| 'field_not_accessible' \| 'record_not_accessible' \| 'license_required' \| 'ip_restricted' \| 'time_restricted' \| 'resource_not_found' \| 'object_not_found' \| 'record_not_found' \| 'field_not_found' \| 'endpoint_not_found' \| 'resource_conflict' \| 'concurrent_modification' \| 'delete_restricted' \| 'duplicate_record' \| 'lock_conflict' \| 'rate_limit_exceeded' \| 'quota_exceeded' \| 'concurrent_limit_exceeded' \| 'internal_error' \| 'database_error' \| 'timeout' \| 'service_unavailable' \| 'not_implemented' \| 'external_service_error' \| 'integration_error' \| 'webhook_delivery_failed' \| 'batch_partial_failure' \| 'batch_complete_failure' \| 'transaction_failed'>` | ✅ | Error code for this field |
+| **code** | `Enum<'validation_error' \| 'invalid_field' \| 'missing_required_field' \| 'invalid_format' \| 'value_too_long' \| 'value_too_short' \| 'value_out_of_range' \| 'invalid_reference' \| 'duplicate_value' \| 'invalid_query' \| 'invalid_filter' \| 'invalid_sort' \| 'max_records_exceeded' \| 'unauthenticated' \| 'invalid_credentials' \| 'expired_token' \| 'invalid_token' \| 'session_expired' \| 'mfa_required' \| 'email_not_verified' \| 'permission_denied' \| 'insufficient_privileges' \| 'field_not_accessible' \| 'record_not_accessible' \| 'license_required' \| 'ip_restricted' \| 'time_restricted' \| 'resource_not_found' \| 'object_not_found' \| 'record_not_found' \| 'field_not_found' \| 'endpoint_not_found' \| 'resource_conflict' \| 'concurrent_modification' \| 'delete_restricted' \| 'duplicate_record' \| 'lock_conflict' \| 'method_not_allowed' \| 'precondition_required' \| 'rate_limit_exceeded' \| 'quota_exceeded' \| 'concurrent_limit_exceeded' \| 'internal_error' \| 'database_error' \| 'timeout' \| 'service_unavailable' \| 'not_implemented' \| 'external_service_error' \| 'integration_error' \| 'webhook_delivery_failed' \| 'batch_partial_failure' \| 'batch_complete_failure' \| 'transaction_failed'>` | ✅ | Error code for this field |
| **message** | `string` | ✅ | Human-readable error message |
| **value** | `any` | optional | The invalid value that was provided |
| **constraint** | `any` | optional | The constraint that was violated (e.g., max length) |
@@ -132,6 +132,8 @@ const result = EnhancedApiError.parse(data);
* `delete_restricted`
* `duplicate_record`
* `lock_conflict`
+* `method_not_allowed`
+* `precondition_required`
* `rate_limit_exceeded`
* `quota_exceeded`
* `concurrent_limit_exceeded`
diff --git a/content/docs/references/api/export.mdx b/content/docs/references/api/export.mdx
index 3cb2fb67ae..badf0cfc82 100644
--- a/content/docs/references/api/export.mdx
+++ b/content/docs/references/api/export.mdx
@@ -59,7 +59,7 @@ const result = CreateExportJobRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; estimatedRecords?: integer; createdAt: string }` | ✅ | |
@@ -159,7 +159,7 @@ const result = CreateExportJobRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ jobId: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; format: Enum<'csv' \| 'json' \| 'jsonl' \| 'xlsx' \| 'parquet'>; totalRecords?: integer; … }` | ✅ | |
@@ -233,7 +233,7 @@ const result = CreateExportJobRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ jobId: string; downloadUrl: string; fileName: string; fileSize: integer; … }` | ✅ | |
@@ -451,7 +451,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ totalRecords: integer; validRecords: integer; invalidRecords: integer; duplicateRecords: integer; … }` | ✅ | |
@@ -490,7 +490,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ jobs: { jobId: string; object: string; status: Enum<'pending' \| 'processing' \| 'completed' \| 'failed' \| 'cancelled' \| 'expired'>; format: Enum<'csv' \| 'json' \| 'jsonl' \| 'xlsx' \| 'parquet'>; … }[]; nextCursor?: string; hasMore: boolean }` | ✅ | |
@@ -548,7 +548,7 @@ Type: `{ sourceField: string; targetField: string; targetLabel?: string; transfo
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ id: string; name: string; enabled: boolean; nextRunAt?: string; … }` | ✅ | |
diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx
index c5dcd26ba5..40299a7adc 100644
--- a/content/docs/references/api/metadata.mdx
+++ b/content/docs/references/api/metadata.mdx
@@ -66,7 +66,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ name: string; label: string; version?: string; description?: string; … }` | ✅ | Full App Configuration |
@@ -80,7 +80,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ name: string; label: string; icon?: string; description?: string }[]` | ✅ | List of available concepts (Objects, Apps, Flows) |
@@ -94,7 +94,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ total: integer; succeeded: integer; failed: integer; errors?: { type: string; name: string; error: string }[] }` | ✅ | Bulk operation result |
@@ -119,7 +119,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ type: string; name: string }` | ✅ | |
@@ -133,7 +133,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items this item depends on |
@@ -147,7 +147,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ sourceType: string; sourceName: string; targetType: string; targetName: string; … }[]` | ✅ | Items that depend on this item |
@@ -161,7 +161,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `Record` | optional | Effective metadata with all overlays applied |
@@ -175,7 +175,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ exists: boolean }` | ✅ | |
@@ -202,7 +202,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `any` | ✅ | Exported metadata bundle |
@@ -230,7 +230,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ total: integer; imported: integer; skipped: integer; failed: integer; … }` | ✅ | Import result |
@@ -244,7 +244,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ type: string; name: string; definition: Record }` | ✅ | Metadata item |
@@ -258,7 +258,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `Record[]` | ✅ | Array of metadata definitions |
@@ -272,7 +272,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `string[]` | ✅ | Array of metadata item names |
@@ -286,7 +286,7 @@ const result = AppDefinitionResponse.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ id: string; baseType: string; baseName: string; packageId?: string; … }` | optional | Overlay definition, undefined if none |
@@ -350,7 +350,7 @@ Metadata query with filtering, sorting, and pagination
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ items: { type: string; name: string; namespace?: string; label?: string; … }[]; total: integer; page: integer; pageSize: integer }` | ✅ | Paginated query result |
@@ -378,7 +378,7 @@ Metadata query with filtering, sorting, and pagination
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ type: string; label: string; description?: string; filePatterns: string[]; … }` | optional | Type info |
@@ -392,7 +392,7 @@ Metadata query with filtering, sorting, and pagination
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `string[]` | ✅ | Registered metadata type identifiers |
@@ -418,7 +418,7 @@ Metadata query with filtering, sorting, and pagination
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ valid: boolean; errors?: { path: string; message: string; code?: string }[]; warnings?: { path: string; message: string }[] }` | ✅ | Validation result |
@@ -432,7 +432,7 @@ Metadata query with filtering, sorting, and pagination
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ name: string; label?: string; pluralLabel?: string; description?: string; … }` | ✅ | Full Object Schema |
diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx
index 1c1d0f4672..a95243feec 100644
--- a/content/docs/references/api/package-api.mdx
+++ b/content/docs/references/api/package-api.mdx
@@ -65,7 +65,7 @@ Get installed package response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }` | ✅ | Installed package details |
@@ -97,7 +97,7 @@ List installed packages response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ packages: { manifest: object; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; installedAt?: string; … }[]; total?: integer; nextCursor?: string; hasMore: boolean }` | ✅ | |
@@ -151,7 +151,7 @@ Install package response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: { type: 'namespace_conflict'; requestedNamespace: string; conflictingPackageId: string; conflictingPackageName: string; … }[]; message?: string }` | ✅ | |
@@ -193,7 +193,7 @@ Rollback package response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ success: boolean; restoredVersion?: string; message?: string }` | ✅ | |
@@ -228,7 +228,7 @@ Upgrade package response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ success: boolean; phase: string; plan?: object; snapshotId?: string; … }` | ✅ | |
@@ -258,7 +258,7 @@ Resolve dependencies response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ dependencies: { packageId: string; requiredRange: string; resolvedVersion?: string; installedVersion?: string; … }[]; canProceed: boolean; requiredActions: { type: Enum<'install' \| 'upgrade' \| 'confirm_conflict'>; packageId: string; description: string }[]; installOrder: string[]; … }` | ✅ | Dependency resolution result with topological sort |
@@ -285,7 +285,7 @@ Uninstall package response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ packageId: string; success: boolean; message?: string }` | ✅ | |
@@ -317,7 +317,7 @@ Upload artifact response
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ success: boolean; artifactRef?: object; submissionId?: string; message?: string }` | ✅ | |
diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx
index 8cbb43d5c6..c10076e8d6 100644
--- a/content/docs/references/api/protocol.mdx
+++ b/content/docs/references/api/protocol.mdx
@@ -288,13 +288,13 @@ const result = AiAgentCapabilities.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed |
| **total** | `number` | ✅ | Total number of records in the batch |
| **succeeded** | `number` | ✅ | Number of records that succeeded |
| **failed** | `number` | ✅ | Number of records that failed |
-| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; details?: any; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record |
+| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record |
---
@@ -461,13 +461,13 @@ const result = AiAgentCapabilities.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed |
| **total** | `number` | ✅ | Total number of records in the batch |
| **succeeded** | `number` | ✅ | Number of records that succeeded |
| **failed** | `number` | ✅ | Number of records that failed |
-| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; details?: any; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record |
+| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record |
---
@@ -1417,13 +1417,13 @@ const result = AiAgentCapabilities.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **operation** | `Enum<'create' \| 'update' \| 'upsert' \| 'delete'>` | optional | Operation type that was performed |
| **total** | `number` | ✅ | Total number of records in the batch |
| **succeeded** | `number` | ✅ | Number of records that succeeded |
| **failed** | `number` | ✅ | Number of records that failed |
-| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; details?: any; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record |
+| **results** | `{ id?: string; success: boolean; errors?: { code: string; message: string; category?: string; httpStatus?: integer; … }[]; data?: Record; … }[]` | ✅ | Detailed results for each record |
---
diff --git a/content/docs/references/api/storage.mdx b/content/docs/references/api/storage.mdx
index d1616db0b1..47bf3006ad 100644
--- a/content/docs/references/api/storage.mdx
+++ b/content/docs/references/api/storage.mdx
@@ -48,7 +48,7 @@ const result = CompleteChunkedUploadRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ fileId: string; key: string; size: integer; mimeType: string; … }` | ✅ | |
@@ -74,7 +74,7 @@ const result = CompleteChunkedUploadRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ url: string }` | ✅ | |
@@ -103,7 +103,7 @@ const result = CompleteChunkedUploadRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ path: string; name: string; size: integer; mimeType: string; … }` | ✅ | Uploaded file metadata |
@@ -149,7 +149,7 @@ const result = CompleteChunkedUploadRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ uploadId: string; resumeToken: string; fileId: string; totalChunks: integer; … }` | ✅ | |
@@ -163,7 +163,7 @@ const result = CompleteChunkedUploadRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ uploadUrl: string; downloadUrl?: string; fileId: string; method: Enum<'PUT' \| 'POST'>; … }` | ✅ | |
@@ -177,7 +177,7 @@ const result = CompleteChunkedUploadRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ key: string }` | ✅ | |
@@ -204,7 +204,7 @@ const result = CompleteChunkedUploadRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ chunkIndex: integer; eTag: string; bytesReceived: integer }` | ✅ | |
@@ -218,7 +218,7 @@ const result = CompleteChunkedUploadRequest.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **success** | `boolean` | ✅ | Operation success status |
-| **error** | `{ code: string; message: string; category?: string; details?: any; … }` | optional | Error details if success is false |
+| **error** | `{ code: string; message: string; category?: string; httpStatus?: integer; … }` | optional | Error details if success is false |
| **meta** | `{ timestamp: string; duration?: number; requestId?: string; traceId?: string }` | optional | Response metadata |
| **data** | `{ uploadId: string; fileId: string; filename: string; totalSize: integer; … }` | ✅ | |
diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx
index 87e690d803..db2b36a6ca 100644
--- a/content/docs/releases/v17.mdx
+++ b/content/docs/releases/v17.mdx
@@ -457,6 +457,45 @@ now tenant-scoped, matching the sequence.
were declared, put on the query string by the SDK, and read by neither serving
surface; passing `keys` shrank nothing and reported nothing.
+### The dispatcher's `error.code` is the semantic code, not the HTTP status (#3842)
+
+Everything the runtime dispatcher serves — `/meta/*`, `/actions/*`,
+`/packages/*`, `/automation/*`, `/analytics/*`, `/ready`, the route-not-found
+404 — used to answer with the HTTP status in `error.code`, the field
+`ApiErrorSchema` declares as a semantic string. The real code had to live
+somewhere else and did, in three different somewhere-elses: `error.details.code`,
+`error.details.type`, and a sibling `error.type`.
+
+```jsonc
+// before // after
+{ "error": { { "error": {
+ "message": "…", "code": "PERMISSION_DENIED",
+ "code": 403, "message": "…",
+ "details": { "code": "PERMISSION_DENIED" } "httpStatus": 403
+} } } }
+```
+
+- **`error.code`** is the semantic string. **`error.httpStatus`** is the number.
+ **`error.details`** is context only. Replace a
+ `body.error.details?.code ?? body.error.type` read with `body.error.code`, and
+ a `body.error.code` read (for the status) with `body.error.httpStatus`.
+- **SDK callers need no change.** `ObjectStackClient` already normalised this —
+ `err.code` semantic, `err.httpStatus` numeric — and still reads the old shape,
+ so a client newer than its server behaves identically.
+- **No code was renamed.** `PERMISSION_DENIED`, `ROUTE_NOT_FOUND`,
+ `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, `VALIDATION_FAILED` and
+ `unauthenticated` all reach the wire spelled exactly as before; only their
+ field changed. Reconciling the platform's two code vocabularies is #3841.
+ A branch with no code of its own now derives a `StandardErrorCode` from the
+ status (`403` → `permission_denied`), spelled in one map in the spec.
+- **Spec:** `ApiErrorSchema` gains optional `httpStatus`; `StandardErrorCode`
+ gains `method_not_allowed` and `precondition_required` (both additive).
+ `DispatcherErrorCode` changes members from `'404' | '405' | '501' | '503'` to
+ the four semantic spellings the removed `error.type` declared, and
+ `DispatcherErrorResponseSchema.error.code` becomes a string — it had declared
+ the opposite of `ApiErrorSchema` for the same field, which is what let the
+ deviation stand.
+
### Dead spec clusters removed
Each of these parsed and did nothing. None has a runtime consumer; delete the
diff --git a/docs/audits/2026-07-dispatcher-client-route-coverage.md b/docs/audits/2026-07-dispatcher-client-route-coverage.md
index 47106ace9b..9b212266b1 100644
--- a/docs/audits/2026-07-dispatcher-client-route-coverage.md
+++ b/docs/audits/2026-07-dispatcher-client-route-coverage.md
@@ -290,11 +290,23 @@ Four error dialects were live in-repo at the time:
| `settings-routes.ts`, `share-link-routes.ts` | `{ error: { code, message } }` — no `success` |
| service-i18n, service-storage | `{ error: }`, sometimes `+ code` at the top level |
-#3675 moved the last row to the contract. The rest are unchanged, and the
-dispatcher's deviation is now pinned to exactly one field (`error.code` carries
-the HTTP status where a semantic string is declared) by a test rather than by
-prose — it parks the real code in `details` to work around its own occupied
-field, which is the tell.
+#3675 moved the last row to the contract, and #3842 moved the dispatcher row:
+`error.code` is the declared semantic string, the HTTP status has its own
+`httpStatus`, and the three parking spots the occupied field forced
+(`details.code`, `details.type`, `error.type`) are gone. Its guard is
+`error-envelope.conformance.test.ts` in `packages/runtime`, built the same two
+ways as the storage/i18n suites — drive every branch, then scan the source so a
+new branch cannot reintroduce the drift. `rest-server.ts` and the two sibling
+services keep their dialects; the remaining envelope gaps are tracked in #3843.
+
+Note what the pin bought. #3687 wrote the deviation down as an executable
+assertion (`issues.map(path)` `toEqual(['code'])`) instead of a comment, with
+the instruction to DELETE rather than update it once the dispatcher was fixed.
+That is exactly how it played out: the assertion failed the moment the fix
+landed, named the one field to look at, and its replacement asserts the positive
+(`safeParse` succeeds). A pin is worth writing when a known deviation cannot be
+fixed yet — it dates the debt and fails loudly when either the debt grows or
+someone pays it off.
The generalisable lesson matches §11's: a guard only covers the question it
asks. "The route exists" and "the route answers in the declared shape" are two
diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts
index c9740edb50..4eb38442dc 100644
--- a/packages/client/src/client.test.ts
+++ b/packages/client/src/client.test.ts
@@ -1478,8 +1478,14 @@ describe('HTTP error shaping — envelope normalisation', () => {
/** What @objectstack/rest's `mapDataError` puts on the wire. */
const FLAT = { error: 'Validation failed', code: 'VALIDATION_FAILED', fields: FIELDS };
- /** What the runtime dispatcher puts on the wire since #3918. */
- const WRAPPED = {
+ /**
+ * What the runtime dispatcher put on the wire between #3918 and #3842: the
+ * HTTP status in `code`, the real code parked in `details`. Kept as a
+ * fixture because the SDK must keep reading it — a client build talks to
+ * whatever server version it is pointed at, so this is a live legacy-server
+ * shape, not a stale test.
+ */
+ const WRAPPED_LEGACY = {
success: false,
error: {
message: 'Validation failed',
@@ -1488,6 +1494,17 @@ describe('HTTP error shaping — envelope normalisation', () => {
},
};
+ /** What the runtime dispatcher puts on the wire since #3842. */
+ const WRAPPED = {
+ success: false,
+ error: {
+ code: 'VALIDATION_FAILED',
+ message: 'Validation failed',
+ httpStatus: 400,
+ details: { fields: FIELDS },
+ },
+ };
+
it('exposes the SEMANTIC code from the flat envelope', async () => {
const { client } = createMockClient(FLAT, 400);
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
@@ -1507,6 +1524,31 @@ describe('HTTP error shaping — envelope normalisation', () => {
expect(caught.httpStatus).toBe(400);
});
+ it('reads the pre-#3842 wrapped shape identically (older server, newer SDK)', async () => {
+ // #3842 cured this at the producer, but an SDK build talks to whatever
+ // server it is pointed at, so the `details.code` hop must keep working.
+ const { client } = createMockClient(WRAPPED_LEGACY, 400);
+ const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
+
+ expect(caught.code).toBe('VALIDATION_FAILED');
+ expect(caught.fields).toEqual(FIELDS);
+ expect(caught.httpStatus).toBe(400);
+ });
+
+ it('reports the same code from all three envelopes for the same failure', async () => {
+ // The asymmetry #3636 → #3675 → #3689 → #3842 has been closing: which
+ // surface answered must stop being observable to the caller.
+ const codes = await Promise.all(
+ [FLAT, WRAPPED, WRAPPED_LEGACY].map((body) =>
+ createMockClient(body, 400).client.data
+ .delete('pm_base', 'rec_1')
+ .catch((e: any) => e.code),
+ ),
+ );
+
+ expect(codes).toEqual(['VALIDATION_FAILED', 'VALIDATION_FAILED', 'VALIDATION_FAILED']);
+ });
+
it('exposes `fields[]` at the same place for BOTH envelopes', async () => {
const flat: any = await createMockClient(FLAT, 400).client.data
.delete('pm_base', 'rec_1').catch((e) => e);
@@ -1551,7 +1593,9 @@ describe('HTTP error shaping — envelope normalisation', () => {
const { client } = createMockClient(WRAPPED, 400);
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
- expect(caught.details).toEqual({ code: 'VALIDATION_FAILED', fields: FIELDS });
+ // Post-#3842 the code is no longer duplicated in here — it is the
+ // `error.code` the caller branches on, and `details` is context only.
+ expect(caught.details).toEqual({ fields: FIELDS });
});
it('still honours a top-level `details` when the server sends one', async () => {
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index b520ac68a5..0b94f64159 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -4437,17 +4437,25 @@ export class ObjectStackClient {
// @objectstack/rest, flat:
// { error, code: 'VALIDATION_FAILED', fields: [...] }
// runtime dispatcher, wrapped:
- // { success: false, error: { message, code: 400,
- // details: { code: 'VALIDATION_FAILED', fields: [...] } } }
+ // { success: false, error: { code: 'VALIDATION_FAILED', message,
+ // httpStatus: 400, details: { fields: [...] } } }
//
- // Note `error.code` in the WRAPPED form is the HTTP status, not a
- // semantic code. Reading it straight into `err.code` handed callers the
- // number 400 where the flat form handed them 'VALIDATION_FAILED', so the
- // branch our own docs teach —
+ // `error.code` in the WRAPPED form used to be the HTTP STATUS, with the
+ // real code parked in `error.details.code`. Reading it straight into
+ // `err.code` handed callers the number 400 where the flat form handed
+ // them 'VALIDATION_FAILED', so the branch our own docs teach —
// `if (err.code === 'VALIDATION_FAILED') err.fields.forEach(…)`
- // — simply never matched on a dispatcher-served surface. #3918 put the
- // field list on the wire for those routes; this is what lets a caller
- // reach it without knowing which surface answered.
+ // — simply never matched on a dispatcher-served surface. #3842 fixed
+ // that at the producer (Prime Directive #12): `error.code` is the
+ // semantic string on both surfaces now, and the number has its own
+ // `error.httpStatus`.
+ //
+ // The `details.code` hop stays in the chain regardless, and is NOT debt:
+ // an SDK build talks to whatever server version it is pointed at, so a
+ // client newer than its server must still find the code where that
+ // server put it. Same reasoning as the console's two-dialect read in
+ // objectui#2869. It is now a legacy-server fallback rather than the
+ // primary path, so the order below is unchanged but its meaning is.
//
// So: `err.code` is always the semantic STRING (the numeric status is on
// `err.httpStatus`, where it always was), and `err.fields` is always the
diff --git a/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts b/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts
index 7591d7f0a5..2ac63386bd 100644
--- a/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts
+++ b/packages/runtime/src/dispatcher-plugin.ready.integration.test.ts
@@ -79,7 +79,10 @@ describe('GET /ready over a real HTTP server (integration)', () => {
expect(res.status).toBe(503);
const body = await res.json();
expect(body.success).toBe(false);
- expect(body.error.code).toBe(503);
+ // [#3842] The status moved to `httpStatus`; `code` is the semantic string,
+ // derived from 503 since /ready carries no code of its own.
+ expect(body.error.code).toBe('service_unavailable');
+ expect(body.error.httpStatus).toBe(503);
expect(body.error.details.state).toBe('stopping');
} finally {
(kernel as any).getState = realGetState;
diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts
index c77562dd4c..dc17025307 100644
--- a/packages/runtime/src/dispatcher-plugin.ts
+++ b/packages/runtime/src/dispatcher-plugin.ts
@@ -2,8 +2,10 @@
import { Plugin, PluginContext, IHttpServer } from '@objectstack/core';
import { looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
+import { DispatcherErrorCode } from '@objectstack/spec/api';
import { HttpDispatcher, HttpDispatcherResult } from './http-dispatcher.js';
import { validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js';
+import { buildApiError } from './error-envelope.js';
import {
buildSecurityHeaders,
type SecurityHeadersOptions,
@@ -340,12 +342,14 @@ function sendResultBase(
applySecurityHeaders();
res.json({
success: false,
- error: {
+ error: buildApiError({
+ code: DispatcherErrorCode.enum.ROUTE_NOT_FOUND,
message: 'Not Found',
- code: 404,
- type: 'ROUTE_NOT_FOUND',
- hint: 'No handler matched this request. Check the API discovery endpoint for available routes.',
- },
+ httpStatus: 404,
+ extra: {
+ hint: 'No handler matched this request. Check the API discovery endpoint for available routes.',
+ },
+ }),
});
}
@@ -390,11 +394,11 @@ function sendResultBase(
*/
function errorResponseBase(err: any, res: any, securityHeaders?: Record): void {
const validation = validationFailureDetails(err);
- const code =
+ const httpStatus =
(typeof err?.status === 'number' ? err.status : undefined) ??
(typeof err?.statusCode === 'number' ? err.statusCode : undefined) ??
(validation ? VALIDATION_FAILED_STATUS : 500);
- res.status(code);
+ res.status(httpStatus);
if (securityHeaders) {
for (const [k, v] of Object.entries(securityHeaders)) {
res.header(k, v);
@@ -404,7 +408,7 @@ function errorResponseBase(err: any, res: any, securityHeaders?: Record= 500) {
+ if (httpStatus >= 500) {
try {
(res as any).__obsRecordedError = err;
} catch {
@@ -413,12 +417,23 @@ function errorResponseBase(err: any, res: any, securityHeaders?: Record= 500 && looksLikeInternalErrorLeak(raw)
+ httpStatus >= 500 && looksLikeInternalErrorLeak(raw)
? INTERNAL_ERROR_MESSAGE
: raw || 'Internal Server Error';
+ // [#3842] A thrown error's own `.code` finally has somewhere to go. This
+ // exit used to drop it outright — `HttpDispatcher.errorFromThrown` at least
+ // parked it in `details` — because `error.code` was occupied by the status.
+ // Both exits now put it in the declared field, so the same SDK method
+ // reports the same code whichever one answered. `validation` last, so
+ // `VALIDATION_FAILED` wins for an error matched by `name` alone, exactly as
+ // it does in `errorFromThrown`.
+ const details =
+ err?.code || validation
+ ? { ...(err?.code ? { code: err.code } : {}), ...(validation ?? {}) }
+ : undefined;
res.json({
success: false,
- error: { message, code, ...(validation ? { details: validation } : {}) },
+ error: buildApiError({ message, httpStatus, details }),
});
}
diff --git a/packages/runtime/src/dispatcher-validation-error.real.test.ts b/packages/runtime/src/dispatcher-validation-error.real.test.ts
index 7b20df1da0..f124df55c7 100644
--- a/packages/runtime/src/dispatcher-validation-error.real.test.ts
+++ b/packages/runtime/src/dispatcher-validation-error.real.test.ts
@@ -109,7 +109,9 @@ describe('#3918 — both exits serve the real ValidationError as 400 + fields[]'
const res = await publishDrafts(new ValidationError(FIELDS));
expect(res.status).toBe(400);
- expect(res.body.error.details).toEqual({ code: 'VALIDATION_FAILED', fields: FIELDS });
+ // [#3842] `VALIDATION_FAILED` moved from `details` into `error.code`.
+ expect(res.body.error.code).toBe('VALIDATION_FAILED');
+ expect(res.body.error.details).toEqual({ fields: FIELDS });
// The class builds its message from the human field messages — that is
// what a toast shows, so it must survive intact rather than being
// replaced by the 5xx sanitiser.
@@ -120,7 +122,8 @@ describe('#3918 — both exits serve the real ValidationError as 400 + fields[]'
const res = await analyticsQuery(new ValidationError(FIELDS));
expect(res.statusCode).toBe(400);
- expect(res.body.error.details).toEqual({ code: 'VALIDATION_FAILED', fields: FIELDS });
+ expect(res.body.error.code).toBe('VALIDATION_FAILED');
+ expect(res.body.error.details).toEqual({ fields: FIELDS });
expect(res.body.error.message).toContain('name is required');
// Not a server fault: the errorReporter side-channel stays clear.
expect(res.__obsRecordedError).toBeUndefined();
diff --git a/packages/runtime/src/dispatcher-validation-error.test.ts b/packages/runtime/src/dispatcher-validation-error.test.ts
index db8fee38b9..8ce592062b 100644
--- a/packages/runtime/src/dispatcher-validation-error.test.ts
+++ b/packages/runtime/src/dispatcher-validation-error.test.ts
@@ -91,16 +91,21 @@ describe('#3918 — HttpDispatcher.errorFromThrown maps VALIDATION_FAILED', () =
const res = await publishPackage(makeValidationError());
expect(res.status).toBe(400);
- expect(res.body.error.code).toBe(400);
+ expect(res.body.error.httpStatus).toBe(400);
+ });
+
+ it('reports VALIDATION_FAILED in `error.code` (#3842)', async () => {
+ // Was `details.code`, because `error.code` held the status. The string
+ // is unchanged — this moved the field, not the vocabulary.
+ const res = await publishPackage(makeValidationError());
+
+ expect(res.body.error.code).toBe('VALIDATION_FAILED');
});
it('passes `fields[]` through in `details` so the UI can anchor each error', async () => {
const res = await publishPackage(makeValidationError());
- expect(res.body.error.details).toEqual({
- code: 'VALIDATION_FAILED',
- fields: FIELDS,
- });
+ expect(res.body.error.details).toEqual({ fields: FIELDS });
});
it('keeps the human message intact (400 never reaches the 5xx sanitiser)', async () => {
@@ -123,14 +128,14 @@ describe('#3918 — HttpDispatcher.errorFromThrown maps VALIDATION_FAILED', () =
expect(res.body.error.details.fields).toEqual(err.fields);
});
- it('pins `details.code` to VALIDATION_FAILED when matched by `name` alone', async () => {
+ it('pins `error.code` to VALIDATION_FAILED when matched by `name` alone', async () => {
const err = new Error('name is required');
err.name = 'ValidationError';
(err as any).fields = [{ field: 'name', code: 'required', message: 'name is required' }];
const res = await publishPackage(err);
expect(res.status).toBe(400);
- expect(res.body.error.details.code).toBe('VALIDATION_FAILED');
+ expect(res.body.error.code).toBe('VALIDATION_FAILED');
});
it('defaults `fields` to [] when the error carries none', async () => {
@@ -162,8 +167,10 @@ describe('#3918 — HttpDispatcher.errorFromThrown maps VALIDATION_FAILED', () =
const res = await publishPackage(err);
expect(res.status).toBe(500);
+ // [#3842] `STORAGE_FAILURE` is the error's own code and now reaches the
+ // declared field instead of `details`; `issues` stays context.
+ expect(res.body.error.code).toBe('STORAGE_FAILURE');
expect(res.body.error.details).toEqual({
- code: 'STORAGE_FAILURE',
issues: [{ path: 'a', message: 'b', code: 'c' }],
});
expect(res.body.error.details.fields).toBeUndefined();
@@ -242,16 +249,22 @@ describe('#3918 — dispatcher-plugin errorResponseBase maps VALIDATION_FAILED',
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
- expect(res.body.error.code).toBe(400);
+ expect(res.body.error.httpStatus).toBe(400);
+ });
+
+ it('reports VALIDATION_FAILED in `error.code` — same as the returned exit (#3842)', async () => {
+ // This exit used to drop a thrown error's code entirely: `errorResponseBase`
+ // never read `err.code`, because the field it would have gone in held the
+ // status. The two exits of the same surface now agree.
+ const res = await postAnalyticsQuery(makeValidationError());
+
+ expect(res.body.error.code).toBe('VALIDATION_FAILED');
});
it('carries `fields[]` — the body used to be `{message, code}` only', async () => {
const res = await postAnalyticsQuery(makeValidationError());
- expect(res.body.error.details).toEqual({
- code: 'VALIDATION_FAILED',
- fields: FIELDS,
- });
+ expect(res.body.error.details).toEqual({ fields: FIELDS });
});
it('keeps the human message — the 500 fallback used to replace it', async () => {
@@ -279,11 +292,18 @@ describe('#3918 — dispatcher-plugin errorResponseBase maps VALIDATION_FAILED',
expect(res.body.error.details.fields).toEqual(FIELDS);
});
- it('leaves non-validation errors with the exact two-key body they had', async () => {
+ it('gives a codeless error the derived code and nothing else', async () => {
+ // Was `{ message, code: 500 }` exactly. A plain `Error` carries no
+ // semantic code, so #3842 derives one from the status; `details` stays
+ // absent rather than becoming an empty object.
const res = await postAnalyticsQuery(new Error('analytics engine unavailable'));
expect(res.statusCode).toBe(500);
- expect(res.body.error).toEqual({ message: 'analytics engine unavailable', code: 500 });
+ expect(res.body.error).toEqual({
+ code: 'internal_error',
+ message: 'analytics engine unavailable',
+ httpStatus: 500,
+ });
});
});
diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts
index b676ec3b94..87c0c85401 100644
--- a/packages/runtime/src/domain-handler-registry.test.ts
+++ b/packages/runtime/src/domain-handler-registry.test.ts
@@ -331,7 +331,8 @@ describe('HttpDispatcher extracted domains (PR-4: share-links)', () => {
const result = await makeDispatcher({ shareLinks })
.handleShareLinks('', 'POST', { object: 'account', recordId: 'r1' }, {}, {} as any);
expect(result.response?.status).toBe(401);
- expect(result.response?.body?.error?.details?.code).toBe('UNAUTHENTICATED');
+ // [#3842] Was `details.code`, parked there because `error.code` held 401.
+ expect(result.response?.body?.error?.code).toBe('UNAUTHENTICATED');
});
it('public resolve route serves the record through the request-kernel engine with redaction', async () => {
@@ -361,7 +362,9 @@ describe('HttpDispatcher extracted domains (PR-4: share-links)', () => {
const context: any = { executionContext: { userId: 'u1' } };
const result = await makeDispatcher({ shareLinks }).handleShareLinks('/a/b/c', 'GET', undefined, {}, context);
expect(result.response?.status).toBe(404);
- expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND');
+ // [#3842] Was `error.type` — a third spelling, sibling to a numeric
+ // `error.code`. Now the declared field.
+ expect(result.response?.body?.error?.code).toBe('ROUTE_NOT_FOUND');
});
});
diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts
index 4bb61d22bc..ada05c01f8 100644
--- a/packages/runtime/src/domain-handler-registry.ts
+++ b/packages/runtime/src/domain-handler-registry.ts
@@ -98,14 +98,24 @@ export interface DomainHandlerDeps {
getRequestKernelService(name: string): Promise;
/** Standard success envelope. */
success(data: any, meta?: any): { status: number; body: any };
- /** Standard error envelope. */
- error(message: string, code?: number, details?: any): { status: number; body: any };
+ /**
+ * Standard error envelope: `{ success: false, error: { code, message,
+ * httpStatus, details? } }`.
+ *
+ * The second argument is the HTTP STATUS (#3842 renamed it from `code`,
+ * which is what it was misleadingly called while it was also what landed in
+ * `error.code`). `error.code` is the semantic string: pass yours as
+ * `details.code` and it is promoted into the field, otherwise one is derived
+ * from the status. See `./error-envelope.ts`.
+ */
+ error(message: string, httpStatus?: number, details?: any): { status: number; body: any };
/** Standard ROUTE_NOT_FOUND envelope (404 + discovery hint). */
routeNotFound(route: string): { status: number; body: any };
/**
* Error envelope derived from a thrown value: honours `.status` /
- * `.statusCode`, carries spec-validation `issues` and `.code` through as
- * details (the ADR-0033 publish surface relies on field-anchored 422s).
+ * `.statusCode`, carries spec-validation `issues` through as details, and
+ * lifts the error's own `.code` into `error.code` (the ADR-0033 publish
+ * surface relies on field-anchored 422s).
*/
errorFromThrown(e: any, fallbackStatus?: number): { status: number; body: any };
/** Active organization id from the request session (undefined if anonymous / no auth). */
diff --git a/packages/runtime/src/domains/actions-fault-vs-rejection.test.ts b/packages/runtime/src/domains/actions-fault-vs-rejection.test.ts
index a34d29c141..2e3f25da20 100644
--- a/packages/runtime/src/domains/actions-fault-vs-rejection.test.ts
+++ b/packages/runtime/src/domains/actions-fault-vs-rejection.test.ts
@@ -202,6 +202,7 @@ describe('an unexpected FAULT is a 500', () => {
expect(response.status).toBe(403);
expect(response.body.error.message).toBe('Record is outside your sharing scope');
- expect(response.body.error.details?.code).toBe('FORBIDDEN');
+ // [#3842] The thrower's own code reaches the declared field now.
+ expect(response.body.error.code).toBe('FORBIDDEN');
});
});
diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts
index 79a606f201..2af65bf19a 100644
--- a/packages/runtime/src/domains/ai.ts
+++ b/packages/runtime/src/domains/ai.ts
@@ -46,13 +46,9 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
if (method === 'GET' && subPath === '/ai/agents') {
return { handled: true, response: { status: 200, body: { agents: [] } } };
}
- return {
- handled: true,
- response: {
- status: 404,
- body: { success: false, error: { message: 'AI service is not configured', code: 404 } },
- },
- };
+ // [#3842] Was a hand-rolled envelope with the status in `code`. It has
+ // no header or shape of its own, so it is simply the shared exit now.
+ return { handled: true, response: deps.error('AI service is not configured', 404) };
}
// The AI service exposes route definitions via buildAIRoutes.
@@ -81,13 +77,7 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string,
}> | undefined;
if (!routes) {
- return {
- handled: true,
- response: {
- status: 503,
- body: { success: false, error: { message: 'AI service routes not yet initialized', code: 503 } },
- },
- };
+ return { handled: true, response: deps.error('AI service routes not yet initialized', 503) };
}
for (const route of routes) {
diff --git a/packages/runtime/src/domains/error-passthrough.test.ts b/packages/runtime/src/domains/error-passthrough.test.ts
index b9429ed154..bd549126c0 100644
--- a/packages/runtime/src/domains/error-passthrough.test.ts
+++ b/packages/runtime/src/domains/error-passthrough.test.ts
@@ -86,17 +86,16 @@ describe('#3918 follow-up — domain catches honour `status` instead of forcing
expect(res.status).toBe(404);
// A 4xx message is a deliberate answer and must reach the caller.
expect(res.body.error.message).toContain("Object 'ghost' is not registered");
- expect(res.body.error.details?.code).toBe('OBJECT_NOT_FOUND');
+ // [#3842] Was `details.code` — the parking spot the status forced.
+ expect(res.body.error.code).toBe('OBJECT_NOT_FOUND');
});
it(`${label}: a ValidationError keeps its 400 and its fields[]`, async () => {
const res = await dispatchWith(method, path, validationError());
expect(res.status).toBe(400);
- expect(res.body.error.details).toEqual({
- code: 'VALIDATION_FAILED',
- fields: FIELDS,
- });
+ expect(res.body.error.code).toBe('VALIDATION_FAILED');
+ expect(res.body.error.details).toEqual({ fields: FIELDS });
});
it(`${label}: an ordinary error still falls back to 500`, async () => {
diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts
index 4e7bd1b6f0..65855385ad 100644
--- a/packages/runtime/src/domains/mcp.ts
+++ b/packages/runtime/src/domains/mcp.ts
@@ -10,6 +10,7 @@
import { isMcpServerEnabled } from '@objectstack/types';
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
+import { buildApiError } from '../error-envelope.js';
import * as actionExec from '../action-execution.js';
import { isSystemObjectName } from '../action-execution.js';
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
@@ -158,12 +159,25 @@ export async function handleMcpSkillRequest(deps: DomainHandlerDeps, method: str
return { handled: true, response: deps.error('MCP server is not enabled for this environment', 404) };
}
if (method !== 'GET') {
+ // Hand-rolled rather than `deps.error(...)` only because it carries an
+ // `Allow` header; the BODY still goes through the one builder (#3842),
+ // so this branch cannot drift back to a numeric `code`. The code is
+ // DERIVED (`method_not_allowed`), not spelled here — the other two 405
+ // sites (`domains/actions.ts`, `domains/keys.ts`) go through
+ // `deps.error` and derive theirs, and one dispatcher answering the same
+ // status two ways is the drift this issue is closing.
return {
handled: true,
response: {
status: 405,
headers: { Allow: 'GET' },
- body: { success: false, error: { message: 'Method not allowed — use GET', code: 405 } },
+ body: {
+ success: false,
+ error: buildApiError({
+ message: 'Method not allowed — use GET',
+ httpStatus: 405,
+ }),
+ },
},
};
}
diff --git a/packages/runtime/src/error-envelope.conformance.test.ts b/packages/runtime/src/error-envelope.conformance.test.ts
new file mode 100644
index 0000000000..5011d681b7
--- /dev/null
+++ b/packages/runtime/src/error-envelope.conformance.test.ts
@@ -0,0 +1,229 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * Error-envelope conformance for the runtime dispatcher stack (#3842).
+ *
+ * #3687 gave `service-storage` and `service-i18n` a suite of this shape and
+ * left the dispatcher pinned instead of fixed, because `error.code` there was
+ * the HTTP status and moving it needed a consumer sweep. The sweep happened;
+ * this is the guard that replaces the pin.
+ *
+ * Two directions, same as the storage/i18n suites:
+ *
+ * 1. **Runtime** — every distinct way this stack produces an error code is
+ * DRIVEN through the real dispatcher and the body parsed against
+ * `BaseResponseSchema` / `ApiErrorSchema` **imported from `packages/spec`**,
+ * not a local restatement that could drift from the schema it claims to
+ * check. There are four such ways (derived from status, promoted from
+ * `details.code`, spelled from `DispatcherErrorCode`, lifted off a thrown
+ * error) and one case per parking spot the fix collapsed.
+ *
+ * 2. **Source scan** — the modules are scanned so a NEW branch cannot quietly
+ * reintroduce a numeric `code`, a `type`-as-code sibling, or a hand-rolled
+ * envelope. Without this the suite would only ever cover the branches that
+ * existed the day it was written — which is exactly how four sites drifted
+ * into three different parking spots in the first place.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { ApiErrorSchema, BaseResponseSchema, DispatcherErrorCode } from '@objectstack/spec/api';
+import { HttpDispatcher } from './http-dispatcher.js';
+import { buildApiError, splitSemanticCode } from './error-envelope.js';
+
+/** Minimal kernel — these branches fail before any service is reached. */
+function makeDispatcher(kernel: any = { context: { getService: () => null } }) {
+ return new HttpDispatcher(kernel as any);
+}
+
+/**
+ * The assertions every dispatcher error body must satisfy, whatever produced
+ * it. Spelled once so a new case cannot accidentally check less than the others.
+ */
+function expectConformantError(response: { status: number; body: any } | undefined) {
+ expect(response, 'branch produced no response').toBeTruthy();
+ const body = response!.body;
+
+ expect(BaseResponseSchema.safeParse(body).success).toBe(true);
+ expect(body.success).toBe(false);
+
+ const parsed = ApiErrorSchema.safeParse(body.error);
+ expect(parsed.error?.issues ?? []).toEqual([]);
+ expect(parsed.success).toBe(true);
+
+ // The bug in one line: `code` is a semantic string, never the status.
+ expect(typeof body.error.code).toBe('string');
+ expect(body.error.code).not.toBe(String(response!.status));
+ expect(body.error.httpStatus).toBe(response!.status);
+
+ // And the parking spots are empty — `details` is genuine context only.
+ expect(body.error.type).toBeUndefined();
+ expect(body.error.details?.code).toBeUndefined();
+ expect(body.error.details?.type).toBeUndefined();
+
+ return body.error;
+}
+
+describe('#3842 — every dispatcher error exit answers in the declared envelope', () => {
+ it('derives a catalogued code when the branch has none of its own (400)', async () => {
+ const result = await makeDispatcher().handleActions('', 'POST', {}, { request: {} });
+
+ const error = expectConformantError(result.response);
+ expect(error.code).toBe('validation_error');
+ expect(error.message).toBe('Path must be /actions/:object/:action');
+ });
+
+ it('derives a catalogued code for a 405 and a 501 too', async () => {
+ // The two statuses `StandardErrorCode` had no member for until #3842 —
+ // without them a 405 would have derived the generic 4xx bucket and read
+ // as a validation failure.
+ const notAllowed = await makeDispatcher().handleActions('/task/close', 'GET', {}, { request: {} });
+ expect(expectConformantError(notAllowed.response).code).toBe('method_not_allowed');
+
+ const notImplemented = await makeDispatcher().handleI18n('/labels/account', 'GET', {}, { request: {} });
+ expect(expectConformantError(notImplemented.response).code).toBe('not_implemented');
+ });
+
+ it('derives a catalogued code for a 503 (the /ready probe)', async () => {
+ const kernel: any = { context: { getService: () => null }, getState: () => 'stopping' };
+ const result = await makeDispatcher(kernel).dispatch('GET', '/ready', {}, {}, { request: {} });
+
+ const error = expectConformantError(result.response);
+ expect(error.code).toBe('service_unavailable');
+ // Genuine context survives the split — only the code was lifted out.
+ expect(error.details).toEqual({ state: 'stopping' });
+ });
+
+ it('spells a route-resolution failure from the spec enum, not a third field', async () => {
+ const result = await makeDispatcher().dispatch('POST', '', {}, {}, { request: {} });
+
+ const error = expectConformantError(result.response);
+ expect(error.code).toBe(DispatcherErrorCode.enum.ROUTE_NOT_FOUND);
+ // `route` / `hint` stay siblings — `DispatcherErrorResponseSchema`
+ // declares them as part of this error, not as `details` context.
+ expect(typeof error.hint).toBe('string');
+ });
+
+ it('promotes a gate code out of `details` into the declared field', async () => {
+ // The `PROJECT_MEMBERSHIP_REQUIRED` gate — the one site that used
+ // `details.type`, the third of the three parking spots.
+ const ql = { find: vi.fn().mockResolvedValue([]) };
+ const kernel: any = {
+ context: {
+ getService: (name: string) => {
+ if (name === 'auth') {
+ return { api: { getSession: async () => ({ user: { id: 'user-1' } }) } };
+ }
+ if (name === 'objectql') return ql;
+ return null;
+ },
+ },
+ };
+ const dispatcher = new HttpDispatcher(kernel, undefined, { enforceProjectMembership: true });
+ const response = await (dispatcher as any).enforceProjectMembership(
+ { request: { headers: {} }, environmentId: 'proj-private' },
+ '/api/v1/environments/proj-private/data/task',
+ );
+
+ const error = expectConformantError(response);
+ expect(error.code).toBe('PROJECT_MEMBERSHIP_REQUIRED');
+ expect(error.details).toEqual({ environmentId: 'proj-private', userId: 'user-1' });
+ });
+
+ it('lifts a thrown error’s own code into the declared field', async () => {
+ const thrown = Object.assign(new Error('publish backend unavailable'), {
+ code: 'STORAGE_FAILURE',
+ status: 502,
+ });
+ const response = (makeDispatcher() as any).errorFromThrown(thrown);
+
+ const error = expectConformantError(response);
+ expect(error.code).toBe('STORAGE_FAILURE');
+ });
+
+ it('keeps the `Allow` header on the MCP 405 while sharing the body builder', async () => {
+ const result = await makeDispatcher().handleMcpSkill('POST', { request: {} } as any);
+
+ expect(result.response?.headers).toEqual({ Allow: 'GET' });
+ const error = expectConformantError(result.response);
+ expect(error.code).toBe('method_not_allowed');
+ });
+});
+
+describe('#3842 — buildApiError precedence', () => {
+ it('prefers an explicit code over a promoted one over a derived one', () => {
+ expect(buildApiError({ message: 'm', httpStatus: 403, code: 'EXPLICIT' }).code).toBe('EXPLICIT');
+ expect(buildApiError({ message: 'm', httpStatus: 403, details: { code: 'PROMOTED' } }).code)
+ .toBe('PROMOTED');
+ expect(buildApiError({ message: 'm', httpStatus: 403 }).code).toBe('permission_denied');
+ });
+
+ it('drops `details` entirely when the code was all it carried', () => {
+ // An empty object left behind would read as "there is context here".
+ expect(buildApiError({ message: 'm', httpStatus: 403, details: { code: 'X' } }))
+ .not.toHaveProperty('details');
+ });
+
+ it('leaves a non-string `code` in `details` rather than promoting it', () => {
+ // A numeric `code` in a details payload is context (a driver errno, say),
+ // not a semantic code — promoting it would put a number straight back
+ // into the field this whole change exists to keep a string.
+ const error = buildApiError({ message: 'm', httpStatus: 500, details: { code: 42 } });
+ expect(error.code).toBe('internal_error');
+ expect(error.details).toEqual({ code: 42 });
+ });
+
+ it('passes a non-object `details` through untouched', () => {
+ expect(splitSemanticCode('a string')).toEqual({ details: 'a string' });
+ expect(splitSemanticCode(undefined)).toEqual({ details: undefined });
+ });
+});
+
+describe('#3842 — no dispatcher module may reintroduce the drift', () => {
+ // Comments stripped first: these modules' own prose quotes the old shape,
+ // and a doc comment is not a code path.
+ const read = (file: string) =>
+ readFileSync(new URL(file, import.meta.url), 'utf8')
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/\/\/[^\n]*/g, '');
+
+ /** Every module that can put a body on this wire surface. */
+ const MODULES = [
+ './http-dispatcher.ts',
+ './dispatcher-plugin.ts',
+ './domain-handler-registry.ts',
+ './domains/ai.ts',
+ './domains/mcp.ts',
+ ];
+
+ for (const file of MODULES) {
+ it(`${file} never writes a numeric \`code\``, () => {
+ const hits = [...read(file).matchAll(/\bcode:\s*\d/g)];
+ expect(hits, `numeric code literals in ${file}: ${hits.map((m) => m[0]).join(', ')}`)
+ .toHaveLength(0);
+ });
+
+ it(`${file} never revives \`type\` as an error-code sibling`, () => {
+ const hits = [...read(file).matchAll(/\btype:\s*'[A-Z_]{4,}'/g)];
+ expect(hits, `type-as-code in ${file}: ${hits.map((m) => m[0]).join(', ')}`)
+ .toHaveLength(0);
+ });
+
+ it(`${file} builds every error body through the one builder`, () => {
+ // Each `success: false` must be the builder's, so the envelope keeps
+ // living in exactly one place no matter how many branches appear.
+ // The comma form is the object LITERAL; `success: false;` in a type
+ // annotation describes the shape rather than emitting one.
+ const source = read(file);
+ const envelopes = [...source.matchAll(/success:\s*false\s*,/g)].length;
+ const built = [...source.matchAll(/\b(buildApiError|apiErrorResponse)\s*\(/g)].length;
+ expect(built, `${file} has ${envelopes} error envelope(s) but ${built} builder call(s)`)
+ .toBeGreaterThanOrEqual(envelopes);
+ });
+ }
+
+ it('the builder itself is the only place the envelope shape is written', () => {
+ const source = read('./error-envelope.ts');
+ expect([...source.matchAll(/success:\s*false\s*,/g)]).toHaveLength(1);
+ });
+});
diff --git a/packages/runtime/src/error-envelope.ts b/packages/runtime/src/error-envelope.ts
new file mode 100644
index 0000000000..4b5e034301
--- /dev/null
+++ b/packages/runtime/src/error-envelope.ts
@@ -0,0 +1,134 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The ONE place the dispatcher stack builds an error body (#3842).
+ *
+ * ## What was wrong
+ *
+ * `HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote
+ * it straight into `error.code` — the field `ApiErrorSchema` declares as a
+ * semantic string:
+ *
+ * ```ts
+ * return { status: code, body: { success: false, error: { message, code, details } } };
+ * ```
+ *
+ * So a caller reading `error.code` got the number `403`, duplicating the response
+ * status and occupying the one slot it is meant to branch on. The real code then
+ * had to go somewhere else, and did — three somewhere-elses:
+ *
+ * | Site | Real code went to |
+ * |---|---|
+ * | auth gate, permission denial, anonymous denial | `details.code` |
+ * | project-membership gate | `details.type` |
+ * | `routeNotFound()` | `error.type` — a third spelling, sibling to the numeric `code` |
+ *
+ * Four sites, three parking spots, because the declared one was full. The
+ * dispatcher working around its own field is what marked the number as being in
+ * the wrong place.
+ *
+ * ## What this does
+ *
+ * `code` is the semantic string, `httpStatus` is the number, `details` is
+ * genuine context only:
+ *
+ * ```json
+ * { "success": false,
+ * "error": { "code": "PERMISSION_DENIED", "message": "…", "httpStatus": 403 } }
+ * ```
+ *
+ * A code already carried in `details.code` is PROMOTED into `error.code` rather
+ * than renamed, so `PASSWORD_EXPIRED` / `PERMISSION_DENIED` / `unauthenticated`
+ * / `VALIDATION_FAILED` reach the wire spelled exactly as they are today — this
+ * change moves a field, it does not pick a vocabulary (#3841 owns that). Only a
+ * site that has NO code of its own gets one derived from the status, from the
+ * single map in `packages/spec` (`standardErrorCodeForHttpStatus`), because
+ * `ApiErrorSchema.code` is required and something has to fill it.
+ *
+ * Every error exit on this surface routes through here — `HttpDispatcher.error`,
+ * `HttpDispatcher.routeNotFound`, `dispatcher-plugin`'s `errorResponseBase` and
+ * its inline 404, and the MCP 405 that needs its own `Allow` header. That is
+ * asserted by source scan in `error-envelope.conformance.test.ts`, so a new
+ * branch cannot quietly reintroduce a numeric `code`.
+ *
+ * Message sanitisation stays at the call sites: each has its own 5xx-leak rule
+ * and fallback message (#3867), and this module is deliberately only about which
+ * field carries what.
+ */
+
+import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
+
+/** The `error` member of a dispatcher error body — an `ApiError` superset. */
+export interface ApiErrorEnvelope {
+ /** Semantic, machine-readable code. Never the HTTP status. */
+ code: string;
+ message: string;
+ /** The response status, mirrored for callers that want it off the body. */
+ httpStatus: number;
+ details?: unknown;
+ [extra: string]: unknown;
+}
+
+export interface ApiErrorInput {
+ /** Already sanitised — see the module note on #3867. */
+ message: string;
+ httpStatus: number;
+ /**
+ * Semantic code. Wins over anything in `details`; omit to promote
+ * `details.code`, and failing that to derive one from `httpStatus`.
+ */
+ code?: string;
+ /** Structured context. A string `code` inside is promoted, not duplicated. */
+ details?: unknown;
+ /**
+ * Declared siblings of `code`/`message` that are part of this error rather
+ * than context — `route` / `hint` / `service` on a route-resolution failure
+ * (see `DispatcherErrorResponseSchema`).
+ */
+ extra?: Record;
+}
+
+/**
+ * Lift a semantic `code` out of a legacy `details` payload.
+ *
+ * `details.code` was the dispatcher's de-facto carrier for the real error code,
+ * so ~all existing call sites express it that way and keep working unchanged.
+ * Non-string `code` values are left alone: a `details` that happens to carry a
+ * numeric `code` is context, not a semantic code, and is passed through intact
+ * rather than silently promoted into a string field.
+ */
+export function splitSemanticCode(details: unknown): { code?: string; details?: unknown } {
+ if (!details || typeof details !== 'object' || Array.isArray(details)) {
+ return { details: details ?? undefined };
+ }
+ const { code, ...rest } = details as Record;
+ if (code !== undefined && (typeof code !== 'string' || code === '')) {
+ return { details };
+ }
+ return {
+ code: code as string | undefined,
+ // `undefined` rather than `{}` so a details payload that held nothing but
+ // the code disappears from the wire instead of leaving an empty object.
+ details: Object.keys(rest).length > 0 ? rest : undefined,
+ };
+}
+
+/** Build the `error` member. See {@link ApiErrorInput} for the precedence rules. */
+export function buildApiError(input: ApiErrorInput): ApiErrorEnvelope {
+ const { code: promoted, details } = splitSemanticCode(input.details);
+ return {
+ code: input.code ?? promoted ?? standardErrorCodeForHttpStatus(input.httpStatus),
+ message: input.message,
+ httpStatus: input.httpStatus,
+ ...(input.extra ?? {}),
+ ...(details !== undefined ? { details } : {}),
+ };
+}
+
+/** Build a full `{ status, body }` error response. */
+export function apiErrorResponse(input: ApiErrorInput): { status: number; body: { success: false; error: ApiErrorEnvelope } } {
+ return {
+ status: input.httpStatus,
+ body: { success: false, error: buildApiError(input) },
+ };
+}
diff --git a/packages/runtime/src/http-dispatcher.error-leak.test.ts b/packages/runtime/src/http-dispatcher.error-leak.test.ts
index 7545fe648f..927bafcbe1 100644
--- a/packages/runtime/src/http-dispatcher.error-leak.test.ts
+++ b/packages/runtime/src/http-dispatcher.error-leak.test.ts
@@ -83,10 +83,10 @@ describe('#3867 follow-up — HttpDispatcher.error() does not return raw driver
expect(result.response.body.error.message).toBe('metadata store is unavailable');
});
- it('preserves structured `details` (code / issues) while sanitising the message', async () => {
- // `details` carries the semantic code and per-field `issues` the UI maps
- // back to inputs; it is never free-form driver prose, so the guard must
- // not touch it.
+ it('preserves the structured code / issues while sanitising the message', async () => {
+ // The semantic code and the per-field `issues` the UI maps back to inputs
+ // are never free-form driver prose, so the 5xx guard must not touch them
+ // — it only ever replaces `message`.
const err = Object.assign(new Error(SQL_DUMP), {
status: 500,
code: 'STORAGE_FAILURE',
@@ -95,8 +95,9 @@ describe('#3867 follow-up — HttpDispatcher.error() does not return raw driver
const result: any = await putMeta(err);
expect(result.response.body.error.message).toBe('Internal server error');
+ // [#3842] The code is in the declared field; `details` keeps the issues.
+ expect(result.response.body.error.code).toBe('STORAGE_FAILURE');
expect(result.response.body.error.details).toMatchObject({
- code: 'STORAGE_FAILURE',
issues: [{ path: 'name', message: 'taken', code: 'duplicate' }],
});
});
diff --git a/packages/runtime/src/http-dispatcher.root.test.ts b/packages/runtime/src/http-dispatcher.root.test.ts
index 8d6f36730f..286ab689a7 100644
--- a/packages/runtime/src/http-dispatcher.root.test.ts
+++ b/packages/runtime/src/http-dispatcher.root.test.ts
@@ -68,6 +68,7 @@ describe('HttpDispatcher Root Handling', () => {
// The dispatcher now returns a typed 404 (ROUTE_NOT_FOUND) instead of { handled: false }
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(404);
- expect(result.response?.body?.error?.type).toBe('ROUTE_NOT_FOUND');
+ // [#3842] `ROUTE_NOT_FOUND` moved from `error.type` into `error.code`.
+ expect(result.response?.body?.error?.code).toBe('ROUTE_NOT_FOUND');
});
});
diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts
index 77a53e435b..fa03090963 100644
--- a/packages/runtime/src/http-dispatcher.test.ts
+++ b/packages/runtime/src/http-dispatcher.test.ts
@@ -139,7 +139,9 @@ describe('HttpDispatcher', () => {
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(422); // NOT the old hardcoded 400
const error = result.response?.body?.error;
- expect(error?.details?.code).toBe('invalid_metadata');
+ // [#3842] The spec-validation code is the `error.code`; the
+ // field-anchored issues stay in `details`, which is what they are.
+ expect(error?.code).toBe('invalid_metadata');
expect(error?.details?.issues).toEqual(err.issues);
expect(error?.details?.issues[0].path).toBe('fields.amount.type');
});
@@ -724,7 +726,9 @@ describe('HttpDispatcher', () => {
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(401);
// Shared anonymous-deny body shape (locks the seam migration).
- expect(result.response?.body?.error?.details?.code).toBe('unauthenticated');
+ // [#3842] `ANONYMOUS_DENY_CODE` reaches `error.code` now — it was
+ // parked in `details` while the status occupied the field.
+ expect(result.response?.body?.error?.code).toBe('unauthenticated');
expect(service.listAudienceBindingSuggestions).not.toHaveBeenCalled();
});
@@ -1915,20 +1919,18 @@ describe('HttpDispatcher', () => {
/**
* The dispatcher's error body is the SHAPE the autonomously-mounted
* i18n/storage services were aligned to in #3675 — nested `error`, with
- * the `success` flag. It is not yet the CONTRACT: `ApiErrorSchema`
- * declares `code` as a semantic string ('validation_error'), and this
- * emits the HTTP status as a number. The dispatcher already works
- * around its own field being occupied — `this.error(msg, 403, { code:
- * 'PERMISSION_DENIED' })` parks the real code in `details` — which is
- * the tell that the number is in the wrong place.
+ * the `success` flag — and, as of #3842, the CONTRACT as well.
*
- * Pinned rather than fixed: `error.code` is read across the SDK, the
- * console and the dogfood suite, so moving it is its own change.
- * `toEqual(['code'])` is deliberately exact — if a second field starts
- * deviating this fails, and if the dispatcher is fixed it also fails
- * and this pin should be deleted.
+ * #3687 pinned the one deviating field here rather than moving it, with
+ * the instruction that the pin be DELETED once the dispatcher was fixed
+ * rather than updated. That is what happened: the assertion is now that
+ * `ApiErrorSchema` parses, spelled against the schema imported from
+ * `packages/spec` so it tracks the contract if the contract moves. The
+ * exhaustive per-branch version lives in
+ * `error-envelope.conformance.test.ts`; this one keeps the guard at the
+ * scene of the original pin.
*/
- it('pins the ONE field where the dispatcher deviates from ApiErrorSchema (#3675)', async () => {
+ it('emits an ApiErrorSchema-conformant error body (#3842, was the #3675 pin)', async () => {
const result = await dispatcher.handleI18n('/translations', 'GET', {}, { request: {} });
const body = result.response?.body as { success?: boolean; error?: unknown };
@@ -1936,9 +1938,12 @@ describe('HttpDispatcher', () => {
expect(typeof body.error).toBe('object');
const parsed = ApiErrorSchema.safeParse(body.error);
- expect(parsed.success).toBe(false);
- expect(parsed.error!.issues.map((i) => i.path.join('.'))).toEqual(['code']);
- expect((body.error as { code: unknown }).code).toBe(400);
+ expect(parsed.error?.issues ?? []).toEqual([]);
+ expect(parsed.success).toBe(true);
+ // The status is on `httpStatus`; `code` is the semantic string it
+ // used to displace — derived here, since this branch has no code of
+ // its own to carry.
+ expect(body.error).toMatchObject({ code: 'validation_error', httpStatus: 400 });
});
/**
@@ -2519,7 +2524,14 @@ describe('HttpDispatcher', () => {
);
expect(result).not.toBeNull();
expect(result.status).toBe(403);
- expect(result.body.error.details.type).toBe('PROJECT_MEMBERSHIP_REQUIRED');
+ // [#3842] Was `details.type` — the only site using that spelling,
+ // parked there because `error.code` held the status. `details` keeps
+ // the two genuine context fields.
+ expect(result.body.error.code).toBe('PROJECT_MEMBERSHIP_REQUIRED');
+ expect(result.body.error.details).toEqual({
+ environmentId: 'proj-private',
+ userId: 'user-1',
+ });
expect(memberQL.find).toHaveBeenCalledWith('sys_environment_member', expect.objectContaining({
where: { environment_id: 'proj-private', user_id: 'user-1' },
}));
diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts
index 0d99145aa3..582d27bd2e 100644
--- a/packages/runtime/src/http-dispatcher.ts
+++ b/packages/runtime/src/http-dispatcher.ts
@@ -6,7 +6,8 @@ import {
import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types';
import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability';
import { CoreServiceName } from '@objectstack/spec/system';
-import { readServiceSelfInfo } from '@objectstack/spec/api';
+import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api';
+import { apiErrorResponse } from './error-envelope.js';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from './domain-handler-registry.js';
import * as actionExec from './action-execution.js';
@@ -465,33 +466,43 @@ export class HttpDispatcher {
* Scoped to 5xx: a 4xx message is a deliberate business/validation answer
* (`Path must be /actions/:object/:action`, a hook's own `throw`, a
* `saveMetaItem` field error) and must reach the caller intact. `details`
- * is left alone — it carries structured `code`/`issues` the UI maps to
- * fields, never free-form driver prose.
+ * is left alone apart from the code promotion below — it carries structured
+ * `issues`/`fields` the UI maps to fields, never free-form driver prose.
*
* The unsanitised error is not lost: callers that THREW still hand the
* original to `errorReporter` via `__obsRecordedError`, and every 5xx is
* logged server-side.
+ *
+ * [#3842] The second parameter is the HTTP status and always was — it is now
+ * named for what it is, and lands in `error.httpStatus` instead of
+ * `error.code`, which goes back to being the semantic string
+ * `ApiErrorSchema` declares. Call sites are unchanged: a site that already
+ * expressed its real code as `details.code` gets it promoted into
+ * `error.code` by the shared builder, and a site that has none gets one
+ * derived from the status. See `./error-envelope.ts`.
*/
- private error(message: string, code: number = 500, details?: any) {
+ private error(message: string, httpStatus: number = 500, details?: any) {
const safe =
- code >= 500 && looksLikeInternalErrorLeak(message)
+ httpStatus >= 500 && looksLikeInternalErrorLeak(message)
? INTERNAL_ERROR_MESSAGE
: message;
- return {
- status: code,
- body: { success: false, error: { message: safe, code, details } }
- };
+ return apiErrorResponse({ message: safe, httpStatus, details });
}
/**
* Build an error response from a THROWN service/protocol error, preserving
* the error's own HTTP `status` and — critically — any structured `issues`
* array (e.g. spec-validation `{ path, message, code }[]` from
- * `protocol.saveMetaItem`). The plain `error(msg, code)` path collapses a
+ * `protocol.saveMetaItem`). The plain `error(msg, status)` path collapses a
* validation failure to a single message, so the UI can only show a generic
- * banner; carrying `issues` (and the semantic `code`) in `details` lets it
- * map each error back to the offending field. Falls back to `fallbackStatus`
- * and behaves exactly like `error()` for errors that carry neither.
+ * banner; carrying `issues` in `details` lets it map each error back to the
+ * offending field. Falls back to `fallbackStatus` and behaves exactly like
+ * `error()` for errors that carry neither.
+ *
+ * [#3842] The error's own `.code` still travels as `details.code` from here,
+ * which is the carrier `buildApiError` promotes into `error.code` — so it
+ * ends up in the declared field without this method needing to know how the
+ * envelope is assembled.
*
* [#3918] A record-level `ValidationError` is the third structured shape,
* and it used to fall through BOTH branches: it carries no `.status` (so a
@@ -526,21 +537,24 @@ export class HttpDispatcher {
/**
* 404 Route Not Found — no route is registered for this path.
+ *
+ * [#3842] `ROUTE_NOT_FOUND` used to sit in `error.type` — a THIRD spelling,
+ * sibling to a numeric `error.code` — because `code` was taken by the status.
+ * It is now the `code`, spelled from the spec's own `DispatcherErrorCode` so
+ * the string has exactly one definition. `route` and `hint` stay siblings:
+ * `DispatcherErrorResponseSchema` declares them as part of this error, not as
+ * `details` context.
*/
private routeNotFound(route: string) {
- return {
- status: 404,
- body: {
- success: false,
- error: {
- code: 404,
- message: `Route Not Found: ${route}`,
- type: 'ROUTE_NOT_FOUND' as const,
- route,
- hint: 'No route is registered for this path. Check the API discovery endpoint for available routes.',
- },
+ return apiErrorResponse({
+ code: DispatcherErrorCode.enum.ROUTE_NOT_FOUND,
+ message: `Route Not Found: ${route}`,
+ httpStatus: 404,
+ extra: {
+ route,
+ hint: 'No route is registered for this path. Check the API discovery endpoint for available routes.',
},
- };
+ });
}
/** Thin delegate — body extracted to `./action-execution.ts` (D11③ PR-8). */
@@ -798,10 +812,15 @@ export class HttpDispatcher {
return null;
}
+ // [#3842] `PROJECT_MEMBERSHIP_REQUIRED` was parked in `details.type`
+ // — the fourth site, and the only one to use that spelling — because
+ // `error.code` held the status. It travels as `details.code` now, the
+ // one carrier `buildApiError` promotes into `error.code`; the two
+ // genuine context fields stay in `details`.
return this.error(
`Forbidden: user ${userId} is not a member of project ${environmentId}`,
403,
- { environmentId, userId, type: 'PROJECT_MEMBERSHIP_REQUIRED' },
+ { code: 'PROJECT_MEMBERSHIP_REQUIRED', environmentId, userId },
);
} catch (err) {
// Control-plane lookup failure — log and fail open rather than
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index 731171e585..a469e9e6b9 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -2627,6 +2627,7 @@
"HttpFindQueryParamsSchema (const)",
"HttpMethod (type)",
"HttpStatusCode (type)",
+ "HttpStatusErrorCodeMap (const)",
"I18nProtocol (interface)",
"IMPORT_JOB_MAX_ROWS (const)",
"IdRequest (type)",
@@ -3081,7 +3082,8 @@
"WorkflowTransitionResponseSchema (const)",
"getAuthEndpointUrl (function)",
"getDefaultRouteRegistrations (function)",
- "readServiceSelfInfo (function)"
+ "readServiceSelfInfo (function)",
+ "standardErrorCodeForHttpStatus (function)"
],
"./ui": [
"ACTION_LOCATIONS (const)",
diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json
index 0530f97286..14025e09bf 100644
--- a/packages/spec/authorable-surface.json
+++ b/packages/spec/authorable-surface.json
@@ -521,6 +521,7 @@
"api/ApiError:category",
"api/ApiError:code",
"api/ApiError:details",
+ "api/ApiError:httpStatus",
"api/ApiError:message",
"api/ApiError:requestId",
"api/ApiMapping:source",
diff --git a/packages/spec/src/api/contract.zod.ts b/packages/spec/src/api/contract.zod.ts
index cde395223e..cc06462ff3 100644
--- a/packages/spec/src/api/contract.zod.ts
+++ b/packages/spec/src/api/contract.zod.ts
@@ -12,6 +12,20 @@ export const ApiErrorSchema = lazySchema(() => z.object({
code: z.string().describe('Error code (e.g. validation_error)'),
message: z.string().describe('Readable error message'),
category: z.string().optional().describe('Error category (e.g. validation, authorization)'),
+ /**
+ * The numeric HTTP status, when a producer chooses to mirror it into the body.
+ *
+ * Declared (#3842) because the runtime dispatcher had nowhere to put it and
+ * used `code` — the semantic slot above — instead, handing callers `403` where
+ * the contract promises a string and forcing the real code into `details`.
+ * `EnhancedApiErrorSchema.httpStatus` set the precedent; this is the same
+ * field on the base envelope.
+ *
+ * Optional and redundant on purpose: the response status is authoritative, so
+ * a producer that emits only the semantic `code` is fully conformant. Callers
+ * should branch on `code`, not on this.
+ */
+ httpStatus: z.number().int().optional().describe('HTTP status of the response carrying this error'),
details: z.unknown().optional().describe('Additional error context (e.g. field validation errors)'),
requestId: z.string().optional().describe('Request ID for tracking'),
}));
diff --git a/packages/spec/src/api/dispatcher.test.ts b/packages/spec/src/api/dispatcher.test.ts
index 57aa35ddd2..99466eb905 100644
--- a/packages/spec/src/api/dispatcher.test.ts
+++ b/packages/spec/src/api/dispatcher.test.ts
@@ -7,6 +7,7 @@ import {
type DispatcherRoute,
type DispatcherConfig,
} from './dispatcher.zod';
+import { ApiErrorSchema } from './contract.zod';
describe('DispatcherRouteSchema', () => {
it('should accept valid route with all fields', () => {
@@ -128,14 +129,23 @@ describe('DispatcherConfigSchema', () => {
// ============================================================================
describe('DispatcherErrorCode', () => {
- it('should accept all valid error codes', () => {
- ['404', '405', '501', '503'].forEach(code => {
+ it('should accept all four route-resolution failure modes', () => {
+ ['ROUTE_NOT_FOUND', 'METHOD_NOT_ALLOWED', 'NOT_IMPLEMENTED', 'SERVICE_UNAVAILABLE'].forEach(code => {
expect(() => DispatcherErrorCode.parse(code)).not.toThrow();
});
});
it('should reject invalid codes', () => {
- expect(() => DispatcherErrorCode.parse('200')).toThrow();
+ expect(() => DispatcherErrorCode.parse('SOMETHING_ELSE')).toThrow();
+ });
+
+ it('should reject the HTTP-status spellings it used to hold (#3842)', () => {
+ // The members were `'404' | '405' | '501' | '503'` while `error.code`
+ // carried the numeric status. `code` is semantic now, so a status is not a
+ // code — it belongs in `httpStatus`.
+ ['404', '405', '501', '503'].forEach(status => {
+ expect(() => DispatcherErrorCode.parse(status)).toThrow();
+ });
});
});
@@ -144,9 +154,9 @@ describe('DispatcherErrorResponseSchema', () => {
expect(() => DispatcherErrorResponseSchema.parse({
success: false,
error: {
- code: 404,
+ code: 'ROUTE_NOT_FOUND',
message: 'Route Not Found: /api/v1/unknown',
- type: 'ROUTE_NOT_FOUND',
+ httpStatus: 404,
route: '/api/v1/unknown',
},
})).not.toThrow();
@@ -156,9 +166,9 @@ describe('DispatcherErrorResponseSchema', () => {
expect(() => DispatcherErrorResponseSchema.parse({
success: false,
error: {
- code: 501,
+ code: 'NOT_IMPLEMENTED',
message: 'Not Implemented',
- type: 'NOT_IMPLEMENTED',
+ httpStatus: 501,
service: 'workflow',
hint: 'Install plugin-workflow',
},
@@ -169,11 +179,34 @@ describe('DispatcherErrorResponseSchema', () => {
expect(() => DispatcherErrorResponseSchema.parse({
success: false,
error: {
- code: 503,
+ code: 'SERVICE_UNAVAILABLE',
message: 'Service Unavailable: ai',
- type: 'SERVICE_UNAVAILABLE',
+ httpStatus: 503,
service: 'ai',
},
})).not.toThrow();
});
+
+ it('should reject the numeric `code` it used to declare (#3842)', () => {
+ // The regression this whole change exists to prevent: a producer putting the
+ // HTTP status back into the field callers branch on.
+ expect(() => DispatcherErrorResponseSchema.parse({
+ success: false,
+ error: { code: 404, message: 'Route Not Found: /api/v1/unknown' },
+ })).toThrow();
+ });
+
+ it('agrees with the base ApiErrorSchema on what a dispatcher error is', () => {
+ // The two schemas used to disagree about `code` — number here, string there
+ // — which is what legitimised the dispatcher's deviation. A body valid under
+ // one must now be valid under the other.
+ const error = {
+ code: 'ROUTE_NOT_FOUND',
+ message: 'Route Not Found: /api/v1/unknown',
+ httpStatus: 404,
+ route: '/api/v1/unknown',
+ };
+ expect(() => DispatcherErrorResponseSchema.parse({ success: false, error })).not.toThrow();
+ expect(ApiErrorSchema.safeParse(error).success).toBe(true);
+ });
});
diff --git a/packages/spec/src/api/dispatcher.zod.ts b/packages/spec/src/api/dispatcher.zod.ts
index ea568a5e08..259e6ed0ea 100644
--- a/packages/spec/src/api/dispatcher.zod.ts
+++ b/packages/spec/src/api/dispatcher.zod.ts
@@ -141,23 +141,28 @@ export type DispatcherConfigInput = z.input;
// ============================================================================
/**
- * Semantic HTTP error codes used by the Dispatcher.
- *
- * The dispatcher MUST distinguish between these four failure modes so that
+ * The four route-resolution failure modes the dispatcher MUST distinguish, so
* clients (and developers) can understand *why* an API call failed:
*
- * - `404` – Route Not Found: no route is registered for this path.
- * - `405` – Method Not Allowed: route exists but the HTTP method is not supported.
- * - `501` – Not Implemented: route is declared but the handler is a stub / not yet coded.
- * - `503` – Service Unavailable: service exists but is temporarily down or not loaded.
+ * - `ROUTE_NOT_FOUND` (404) – no route is registered for this path.
+ * - `METHOD_NOT_ALLOWED` (405) – route exists but the HTTP method is not supported.
+ * - `NOT_IMPLEMENTED` (501) – route is declared but the handler is a stub / not yet coded.
+ * - `SERVICE_UNAVAILABLE` (503) – service exists but is temporarily down or not loaded.
*
- * Note: These are string representations of HTTP status codes for use in enum
- * matching. The `DispatcherErrorResponseSchema.error.code` field carries the
- * numeric HTTP status code for direct use in HTTP responses.
+ * [#3842] These members used to be the *strings* `'404' | '405' | '501' | '503'`,
+ * because `DispatcherErrorResponseSchema.error.code` carried the numeric HTTP
+ * status and this enum existed to match against it. `error.code` now carries the
+ * semantic string the base `ApiErrorSchema` has always declared (the number moved
+ * to `httpStatus`), so this enum holds the semantic spellings — the same four
+ * that used to sit in the now-removed `error.type`, moved verbatim. Match on the
+ * status via `httpStatus` or the response status, not via a code.
*/
-export const DispatcherErrorCode = z.enum(['404', '405', '501', '503']).describe(
- '404 = route not found, 405 = method not allowed, 501 = not implemented (stub), 503 = service unavailable'
-);
+export const DispatcherErrorCode = z.enum([
+ 'ROUTE_NOT_FOUND',
+ 'METHOD_NOT_ALLOWED',
+ 'NOT_IMPLEMENTED',
+ 'SERVICE_UNAVAILABLE',
+]).describe('Route-resolution failure mode emitted in `error.code`');
export type DispatcherErrorCode = z.infer;
@@ -167,30 +172,36 @@ export type DispatcherErrorCode = z.infer;
* Standardised error envelope returned by the dispatcher when a request cannot
* be fulfilled. Adapters MUST use this shape (or a superset) for all non-2xx
* responses so that clients can programmatically distinguish failure modes.
+ *
+ * [#3842] This is a superset of `ApiErrorSchema`, not a rival dialect. It used
+ * to declare `code` as the numeric HTTP status and put the machine-readable
+ * spelling in a sibling `type` — which is where the dispatcher's deviation from
+ * `ApiErrorSchema` was legitimised. `code` is now the semantic string both
+ * schemas agree on, `httpStatus` carries the number, and `type` is gone.
*/
export const DispatcherErrorResponseSchema = z.object({
/** Always `false` for error responses */
success: z.literal(false),
error: z.object({
- /** HTTP status code */
- code: z.number().int().describe('HTTP status code (404, 405, 501, 503, …)'),
- /** Human-readable error message */
- message: z.string().describe('Human-readable error message'),
/**
- * Machine-readable error type for programmatic branching.
+ * Machine-readable error code for programmatic branching. Route-resolution
+ * failures use a {@link DispatcherErrorCode}; every other failure carries
+ * the producer's own code (or a `StandardErrorCode` derived from the status
+ * — see `standardErrorCodeForHttpStatus`), so this stays an open string.
*/
- type: z.enum([
- 'ROUTE_NOT_FOUND',
- 'METHOD_NOT_ALLOWED',
- 'NOT_IMPLEMENTED',
- 'SERVICE_UNAVAILABLE',
- ]).optional().describe('Machine-readable error type'),
+ code: z.string().describe('Machine-readable error code (e.g. ROUTE_NOT_FOUND, permission_denied)'),
+ /** Human-readable error message */
+ message: z.string().describe('Human-readable error message'),
+ /** HTTP status mirrored into the body (the response status is authoritative) */
+ httpStatus: z.number().int().optional().describe('HTTP status code (404, 405, 501, 503, …)'),
/** Route that was requested */
route: z.string().optional().describe('Requested route path'),
/** Service that the route maps to (if known) */
service: z.string().optional().describe('Target service name, if resolvable'),
/** Guidance for the developer */
hint: z.string().optional().describe('Actionable hint for the developer (e.g., "Install plugin-workflow")'),
+ /** Structured context — genuine context only, never a parked error code */
+ details: z.unknown().optional().describe('Additional error context'),
}),
});
diff --git a/packages/spec/src/api/errors.test.ts b/packages/spec/src/api/errors.test.ts
index ee5ff1cb2a..31934a9160 100644
--- a/packages/spec/src/api/errors.test.ts
+++ b/packages/spec/src/api/errors.test.ts
@@ -7,6 +7,8 @@ import {
EnhancedApiErrorSchema,
ErrorResponseSchema,
ErrorHttpStatusMap,
+ HttpStatusErrorCodeMap,
+ standardErrorCodeForHttpStatus,
} from './errors.zod';
describe('ErrorCategory', () => {
@@ -222,3 +224,49 @@ describe('ErrorHttpStatusMap', () => {
expect(ErrorHttpStatusMap['maintenance']).toBe(503);
});
});
+
+describe('HttpStatusErrorCodeMap / standardErrorCodeForHttpStatus (#3842)', () => {
+ it('names each status the runtime actually returns', () => {
+ expect(standardErrorCodeForHttpStatus(400)).toBe('validation_error');
+ expect(standardErrorCodeForHttpStatus(401)).toBe('unauthenticated');
+ expect(standardErrorCodeForHttpStatus(403)).toBe('permission_denied');
+ expect(standardErrorCodeForHttpStatus(404)).toBe('resource_not_found');
+ expect(standardErrorCodeForHttpStatus(405)).toBe('method_not_allowed');
+ expect(standardErrorCodeForHttpStatus(409)).toBe('resource_conflict');
+ expect(standardErrorCodeForHttpStatus(428)).toBe('precondition_required');
+ expect(standardErrorCodeForHttpStatus(500)).toBe('internal_error');
+ expect(standardErrorCodeForHttpStatus(501)).toBe('not_implemented');
+ expect(standardErrorCodeForHttpStatus(503)).toBe('service_unavailable');
+ });
+
+ it('is total — an unmapped status still yields a code', () => {
+ // `ApiErrorSchema.code` is REQUIRED, so a producer that knows only the
+ // status must always be able to fill it. Falling back per class rather than
+ // to one catch-all keeps a 4xx from being reported as a server fault.
+ expect(standardErrorCodeForHttpStatus(415)).toBe('validation_error');
+ expect(standardErrorCodeForHttpStatus(507)).toBe('internal_error');
+ });
+
+ it('only ever yields catalogued codes', () => {
+ // The whole claim of the map: a derived code is a StandardErrorCode, not an
+ // invented string. Guards the map's values and both fallbacks in one pass.
+ const derived = [
+ ...Object.values(HttpStatusErrorCodeMap),
+ standardErrorCodeForHttpStatus(415),
+ standardErrorCodeForHttpStatus(507),
+ ];
+ for (const code of derived) {
+ expect(StandardErrorCode.safeParse(code).success).toBe(true);
+ }
+ });
+
+ it('mirrors ErrorHttpStatusMap where the two overlap', () => {
+ // The pair is only auditable if it round-trips: every category's status maps
+ // back to a code, and the obvious ones agree on meaning.
+ for (const status of Object.values(ErrorHttpStatusMap)) {
+ expect(StandardErrorCode.safeParse(standardErrorCodeForHttpStatus(status)).success).toBe(true);
+ }
+ expect(standardErrorCodeForHttpStatus(ErrorHttpStatusMap['authorization'])).toBe('permission_denied');
+ expect(standardErrorCodeForHttpStatus(ErrorHttpStatusMap['not_found'])).toBe('resource_not_found');
+ });
+});
diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts
index abce581fe1..16655bd2d7 100644
--- a/packages/spec/src/api/errors.zod.ts
+++ b/packages/spec/src/api/errors.zod.ts
@@ -96,11 +96,15 @@ export const StandardErrorCode = z.enum([
'duplicate_record', // Record already exists
'lock_conflict', // Record is locked by another process
+ // Request Errors (405/428)
+ 'method_not_allowed', // Route exists but the HTTP method is not supported
+ 'precondition_required', // Request is missing a required precondition (e.g. environment scope)
+
// Rate Limiting (429)
'rate_limit_exceeded', // Too many requests
'quota_exceeded', // API quota exceeded
'concurrent_limit_exceeded', // Too many concurrent requests
-
+
// Server Errors (500)
'internal_error', // Generic internal server error
'database_error', // Database operation failed
@@ -140,6 +144,58 @@ export const ErrorHttpStatusMap: Record = {
maintenance: 503,
};
+/**
+ * The mirror image of {@link ErrorHttpStatusMap}: the {@link StandardErrorCode}
+ * that names an HTTP status when the producer has no more specific code of its
+ * own. Declared next to the enum it draws from so the pair stays auditable —
+ * every value here MUST be a member of `StandardErrorCode`, which is what makes
+ * a derived code a catalogued one rather than an invented string.
+ *
+ * Why a *derived* code exists at all (#3842): `ApiErrorSchema.code` is a
+ * REQUIRED semantic string, so a producer that knows only "this failed with
+ * 503" still has to fill it. Before this map the runtime dispatcher filled it
+ * with the status *number* and parked any real code in `details`, which put a
+ * number in the field callers branch on and gave the same condition three
+ * different spellings (`details.code`, `details.type`, `error.type`).
+ *
+ * A specific code always wins — this is the floor, not the ceiling. `403 +
+ * PASSWORD_EXPIRED` stays `PASSWORD_EXPIRED`; only a bare `403` becomes
+ * `permission_denied`.
+ *
+ * Vocabulary note (#3841): the values are `StandardErrorCode`'s lowercase
+ * snake_case because that is the only error vocabulary the spec declares and
+ * `content/docs/api/error-catalog.mdx` documents. Much of the wire is
+ * SCREAMING_SNAKE and #3841 owns reconciling the two; this map is deliberately
+ * the ONE place a derived code is spelled, so that decision is a one-file sweep
+ * rather than a hunt through every producer.
+ */
+export const HttpStatusErrorCodeMap: Record = {
+ 400: 'validation_error',
+ 401: 'unauthenticated',
+ 403: 'permission_denied',
+ 404: 'resource_not_found',
+ 405: 'method_not_allowed',
+ 409: 'resource_conflict',
+ 428: 'precondition_required',
+ 429: 'rate_limit_exceeded',
+ 500: 'internal_error',
+ 501: 'not_implemented',
+ 502: 'external_service_error',
+ 503: 'service_unavailable',
+ 504: 'timeout',
+};
+
+/**
+ * The {@link StandardErrorCode} for an HTTP status, falling back to the
+ * client-error / server-error bucket for a status {@link HttpStatusErrorCodeMap}
+ * does not name (e.g. `415` → `validation_error`, `507` → `internal_error`).
+ * Total by construction: a producer can always fill a required `code`.
+ */
+export function standardErrorCodeForHttpStatus(status: number): StandardErrorCode {
+ return HttpStatusErrorCodeMap[status]
+ ?? (status >= 500 ? 'internal_error' : 'validation_error');
+}
+
/**
* Retry Strategy Enum
* Guidance on whether to retry failed requests
diff --git a/skills/objectstack-api/references/_index.md b/skills/objectstack-api/references/_index.md
index caee18046c..7f236a708f 100644
--- a/skills/objectstack-api/references/_index.md
+++ b/skills/objectstack-api/references/_index.md
@@ -20,7 +20,7 @@ from `node_modules` — there is no local copy in the skill bundle.
## Transitive dependencies
-- `node_modules/@objectstack/spec/src/api/contract.zod.ts` — Standard Create Request
+- `node_modules/@objectstack/spec/src/api/contract.zod.ts` — The numeric HTTP status, when a producer chooses to mirror it into the body.
- `node_modules/@objectstack/spec/src/api/realtime-shared.zod.ts` — Realtime Shared Protocol
- `node_modules/@objectstack/spec/src/data/data-engine.zod.ts` — Data Engine Protocol
- `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification