Skip to content

Commit 170bf22

Browse files
committed
fix(runtime,spec)!: error.code is the semantic string, the status moves to httpStatus (#3842)
`HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it 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 branches on. The real code then had to go elsewhere, and did, three elsewheres: `details.code`, `details.type` and `error.type`. Four sites, three parking spots, because the declared one was full. `code` is now the semantic string, `httpStatus` carries the number, and `details` is genuine context only. Every code already on the wire moves verbatim — PERMISSION_DENIED, ROUTE_NOT_FOUND, PASSWORD_EXPIRED, PROJECT_MEMBERSHIP_REQUIRED, VALIDATION_FAILED, unauthenticated. This moves a field; #3841 still owns renaming any of them. A branch with no code of its own gets one derived from the status via one declared map in the spec (`HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus`), so the value is a catalogued `StandardErrorCode` rather than an invented string, and #3841's sweep is one file. Spec: - `ApiErrorSchema` gains optional `httpStatus` (precedent: `EnhancedApiErrorSchema.httpStatus`). Additive. - `StandardErrorCode` gains `method_not_allowed` / `precondition_required` — the two statuses the runtime returns that the enum could not name. Additive. - BREAKING `DispatcherErrorCode`: `'404'|'405'|'501'|'503'` becomes the four semantic spellings the removed `error.type` declared, moved verbatim. - BREAKING `DispatcherErrorResponseSchema`: `code` is a string, `type` is gone, `httpStatus`/`details` are declared. This schema declared the opposite of `ApiErrorSchema` for the same field, which is what legitimised the deviation. Also aligned, being the same wire surface: `dispatcher-plugin`'s `errorResponseBase` (which used to discard a thrown error's `.code` outright, having nowhere to put it) and its inline 404, plus the MCP 405. All bodies now come from one builder, guarded both ways by `error-envelope.conformance.test.ts` — every branch driven and parsed against the schema imported from the spec, plus a source scan so a new branch cannot reintroduce a numeric `code`. Deletes the #3687 pin, which asked to be deleted rather than updated once the dispatcher was fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj
1 parent e2c64f1 commit 170bf22

31 files changed

Lines changed: 1041 additions & 167 deletions
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
"@objectstack/client": patch
5+
---
6+
7+
fix(runtime,spec)!: the dispatcher's `error.code` is the semantic string it always declared; the HTTP status moves to `httpStatus` (#3842)
8+
9+
`HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it
10+
straight into the field `ApiErrorSchema` reserves for a semantic string, so
11+
`error.code` came back as `400`/`403`/`503` — a number, duplicating the response
12+
status and occupying the one slot a caller is meant to branch on. The real code
13+
then had to go somewhere else, and did, three somewhere-elses: `details.code`
14+
(auth gate, permission denial, anonymous deny), `details.type`
15+
(project-membership gate), and `error.type` (`routeNotFound`). Four sites, three
16+
parking spots, because the declared one was full.
17+
18+
**FROM → TO on the wire.** A dispatcher error body
19+
20+
```json
21+
{ "success": false,
22+
"error": { "message": "", "code": 403, "details": { "code": "PERMISSION_DENIED" } } }
23+
```
24+
25+
is now
26+
27+
```json
28+
{ "success": false,
29+
"error": { "code": "PERMISSION_DENIED", "message": "", "httpStatus": 403 } }
30+
```
31+
32+
| Reading | Was | Now |
33+
|---|---|---|
34+
| semantic code | `error.details.code` / `error.details.type` / `error.type` | `error.code` |
35+
| HTTP status | `error.code` | `error.httpStatus` (or the response status) |
36+
| context | `error.details` (with the code mixed in) | `error.details` (context only, absent when empty) |
37+
38+
**One-line fix for a direct reader:** replace `body.error.details?.code ??
39+
body.error.type` with `body.error.code`, and `body.error.code` with
40+
`body.error.httpStatus`. **SDK callers need no change**`ObjectStackClient`
41+
already normalised this (`err.code` semantic, `err.httpStatus` numeric) and still
42+
reads the old shape, so a client newer than its server is unaffected.
43+
44+
Every code already on the wire moves **verbatim**`PERMISSION_DENIED`,
45+
`ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`,
46+
`VALIDATION_FAILED`, `unauthenticated`. This change moves a field; it does not
47+
rename anything. Reconciling the repo's two code vocabularies is #3841, and this
48+
leaves it exactly one map and one enum to sweep instead of four parking spots.
49+
50+
A branch with no code of its own is served one derived from the status, via the
51+
single declared map `HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus`
52+
in `@objectstack/spec/api` (`403``permission_denied`, `503`
53+
`service_unavailable`, …). Derivation is necessary because `ApiErrorSchema.code`
54+
is required; drawing it from `StandardErrorCode` keeps a derived code a
55+
catalogued one rather than an invented string.
56+
57+
**Spec changes:**
58+
59+
- `ApiErrorSchema` gains optional `httpStatus: number` — the precedent is
60+
`EnhancedApiErrorSchema.httpStatus`. Additive.
61+
- `StandardErrorCode` gains `method_not_allowed` and `precondition_required`,
62+
the two statuses the runtime returns that the enum could not name. Additive.
63+
- **Breaking — `DispatcherErrorCode`** was `'404' | '405' | '501' | '503'` (string
64+
spellings of HTTP statuses, for matching against the numeric `error.code`). It
65+
is now `'ROUTE_NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'NOT_IMPLEMENTED' |
66+
'SERVICE_UNAVAILABLE'` — the same four members the removed `error.type` enum
67+
declared, moved verbatim. FROM `DispatcherErrorCode.parse('404')` TO
68+
`DispatcherErrorCode.parse('ROUTE_NOT_FOUND')`; to match a status, read
69+
`error.httpStatus`. TypeScript flags every call site.
70+
- **Breaking — `DispatcherErrorResponseSchema`**: `error.code` is `z.string()`
71+
(was `z.number().int()`), `error.type` is **removed** (folded into `code`), and
72+
`error.httpStatus` / `error.details` are declared. This schema is what
73+
legitimised the deviation — it declared the opposite of `ApiErrorSchema` for
74+
the same field. FROM `{ code: 404, type: 'ROUTE_NOT_FOUND' }` TO
75+
`{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }`.
76+
77+
**Also aligned, because they are the same wire surface:** `dispatcher-plugin`'s
78+
`errorResponseBase` (the THROWN-error exit) and its inline 404, and the MCP 405.
79+
`errorResponseBase` previously discarded a thrown error's `.code` outright — it
80+
had nowhere to put it — so the two exits of one surface disagreed about what a
81+
caller would see; they now agree. Every body on this surface is built by one
82+
helper (`packages/runtime/src/error-envelope.ts`), guarded in both directions by
83+
`error-envelope.conformance.test.ts`: each branch driven and parsed against the
84+
schema imported from `packages/spec`, plus a source scan so a new branch cannot
85+
quietly reintroduce a numeric `code` or a `type`-as-code sibling.
86+
87+
This deletes the #3687 pin in `http-dispatcher.test.ts`, which asked to be
88+
deleted rather than updated once the dispatcher was fixed.

content/docs/api/client-sdk.mdx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -522,9 +522,12 @@ try {
522522
`error.code` is always the **semantic** code as a string — the numeric HTTP
523523
status lives on `error.httpStatus` and nowhere else. This holds regardless of
524524
which server surface answered: the REST server replies with a flat
525-
`{ error, code, fields }` body while the runtime dispatcher replies with a
526-
wrapped `{ success, error: { message, code, details } }` body whose `error.code`
527-
is the HTTP status, and the client normalizes both before throwing.
525+
`{ error, code, fields }` body and the runtime dispatcher replies with a wrapped
526+
`{ success, error: { code, message, httpStatus, details } }` body, and the
527+
client normalizes both before throwing. (Dispatcher bodies from servers older
528+
than #3842 put the status in `error.code` and the semantic code in
529+
`error.details.code`; the client still reads those, so an SDK newer than the
530+
server it talks to behaves the same.)
528531

529532
### Per-field validation errors
530533

content/docs/api/error-catalog.mdx

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Complete reference for all ObjectStack error codes with causes, fix
55

66
# Error Code Catalog
77

8-
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.
8+
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.
99

1010
<Callout type="info">
1111
**Source:** `packages/spec/src/api/errors.zod.ts`
@@ -14,6 +14,8 @@ ObjectStack uses a structured error system with **9 error categories** and **51
1414

1515
<Callout type="warn">
1616
**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.
17+
18+
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.
1719
</Callout>
1820

1921
---
@@ -263,6 +265,25 @@ ObjectStack uses a structured error system with **9 error categories** and **51
263265

264266
---
265267

268+
## Request Errors (405/428)
269+
270+
Added in #3842 so `standardErrorCodeForHttpStatus` can name every status the
271+
runtime actually returns. Without them a `405` would fall into the generic 4xx
272+
bucket and be reported as a validation failure.
273+
274+
### `method_not_allowed`
275+
**Cause:** The route exists but does not serve this HTTP method.
276+
**Fix:** Use the method named in the response's `Allow` header.
277+
**Retry:** `no_retry`
278+
279+
### `precondition_required`
280+
**Cause:** The request is missing a precondition the route requires — most often
281+
an environment scope (no `X-Environment-Id` header and no hostname mapping).
282+
**Fix:** Send the missing header, or address the environment-scoped URL form.
283+
**Retry:** `no_retry`
284+
285+
---
286+
266287
## Rate Limit Errors (429)
267288

268289
### `rate_limit_exceeded`
@@ -447,10 +468,9 @@ Two naming details matter here:
447468
`if (apiError.fields)` tests "this failure is field-anchored".
448469
- `apiError.code` is always the semantic code as a **string**. The numeric HTTP
449470
status is on `apiError.httpStatus` and nowhere else. This holds for both wire
450-
formats: the flat REST envelope carries the code at the top level, the runtime
451-
dispatcher's wrapped envelope carries it in `error.details.code` (its
452-
`error.code` is the HTTP status), and the client normalizes both before
453-
throwing.
471+
formats: the flat REST envelope carries the code at the top level and the
472+
runtime dispatcher's wrapped envelope carries it in `error.code`, with the
473+
status alongside it in `error.httpStatus`.
454474
</Callout>
455475

456476
### TypeScript Example

content/docs/api/index.mdx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,19 +165,24 @@ Validation failures additionally include a `fields` array (one entry per invalid
165165
| `RECORD_NOT_FOUND` | 404 | Resource does not exist |
166166
| `CONCURRENT_UPDATE` | 409 | Record was modified by another user |
167167

168-
**Runtime dispatcher** (`@objectstack/runtime`) wraps errors in the `{ success: false, ... }` envelope, where `code` is the numeric HTTP status:
168+
**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:
169169

170170
```json
171171
{
172172
"success": false,
173173
"error": {
174+
"code": "RECORD_NOT_FOUND",
174175
"message": "Record not found: account/123",
175-
"code": 404,
176-
"details": {}
176+
"httpStatus": 404
177177
}
178178
}
179179
```
180180

181+
Branch on `error.code`, never on `error.httpStatus` — the status answers "what
182+
class of failure" and the code answers "which failure". A branch that has no
183+
code of its own is served a `StandardErrorCode` derived from the status
184+
(`403``permission_denied`, `503``service_unavailable`, …).
185+
181186
<Callout type="info">
182187
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`.
183188
</Callout>

content/docs/api/wire-format.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,44 @@ Read `body.error.code`, not `body.code`, on these routes. `ObjectStackClient`
427427
normalizes both — `error.code` and `error.message` are populated whichever
428428
envelope the server used — so SDK callers do not need to branch.
429429

430+
### Dispatcher routes use it too
431+
432+
Everything the runtime dispatcher serves — `/api/v1/meta/*`, `/actions/*`,
433+
`/packages/*`, `/automation/*`, `/analytics/*`, `/ready`, … — answers in the same
434+
declared envelope, plus `httpStatus`:
435+
436+
```json
437+
{
438+
"success": false,
439+
"error": {
440+
"code": "PROJECT_MEMBERSHIP_REQUIRED",
441+
"message": "Forbidden: user usr_01H… is not a member of project env_prod",
442+
"httpStatus": 403,
443+
"details": { "environmentId": "env_prod", "userId": "usr_01H…" }
444+
}
445+
}
446+
```
447+
448+
| Field | Meaning |
449+
|:---|:---|
450+
| `code` | The semantic code — the field to branch on. Never a number. |
451+
| `message` | Human-readable text, safe to show. 5xx messages are sanitised. |
452+
| `httpStatus` | The response status, mirrored. Redundant with the response line by design. |
453+
| `details` | Structured context only (`fields[]`, `issues[]`, ids). Never a parked code. |
454+
455+
A route-resolution failure spells `code` from `DispatcherErrorCode`
456+
(`ROUTE_NOT_FOUND`, …) and adds `route` / `hint` / `service` as siblings. A
457+
branch with no code of its own gets a `StandardErrorCode` derived from the
458+
status.
459+
460+
<Callout type="info">
461+
Before [#3842](https://github.com/objectstack-ai/objectstack/issues/3842) this
462+
envelope put the HTTP status in `error.code` and the real code in
463+
`error.details.code`, `error.details.type` or `error.type`. `ObjectStackClient`
464+
reads all of them, so an SDK newer than the server it talks to is unaffected;
465+
code reading these bodies directly should move to `error.code`.
466+
</Callout>
467+
430468
---
431469

432470
## 8. Batch Operations

content/docs/releases/v17.mdx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,45 @@ now tenant-scoped, matching the sequence.
457457
were declared, put on the query string by the SDK, and read by neither serving
458458
surface; passing `keys` shrank nothing and reported nothing.
459459

460+
### The dispatcher's `error.code` is the semantic code, not the HTTP status (#3842)
461+
462+
Everything the runtime dispatcher serves — `/meta/*`, `/actions/*`,
463+
`/packages/*`, `/automation/*`, `/analytics/*`, `/ready`, the route-not-found
464+
404 — used to answer with the HTTP status in `error.code`, the field
465+
`ApiErrorSchema` declares as a semantic string. The real code had to live
466+
somewhere else and did, in three different somewhere-elses: `error.details.code`,
467+
`error.details.type`, and a sibling `error.type`.
468+
469+
```jsonc
470+
// before // after
471+
{ "error": { { "error": {
472+
"message": "", "code": "PERMISSION_DENIED",
473+
"code": 403, "message": "",
474+
"details": { "code": "PERMISSION_DENIED" } "httpStatus": 403
475+
} } } }
476+
```
477+
478+
- **`error.code`** is the semantic string. **`error.httpStatus`** is the number.
479+
**`error.details`** is context only. Replace a
480+
`body.error.details?.code ?? body.error.type` read with `body.error.code`, and
481+
a `body.error.code` read (for the status) with `body.error.httpStatus`.
482+
- **SDK callers need no change.** `ObjectStackClient` already normalised this —
483+
`err.code` semantic, `err.httpStatus` numeric — and still reads the old shape,
484+
so a client newer than its server behaves identically.
485+
- **No code was renamed.** `PERMISSION_DENIED`, `ROUTE_NOT_FOUND`,
486+
`PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, `VALIDATION_FAILED` and
487+
`unauthenticated` all reach the wire spelled exactly as before; only their
488+
field changed. Reconciling the platform's two code vocabularies is #3841.
489+
A branch with no code of its own now derives a `StandardErrorCode` from the
490+
status (`403``permission_denied`), spelled in one map in the spec.
491+
- **Spec:** `ApiErrorSchema` gains optional `httpStatus`; `StandardErrorCode`
492+
gains `method_not_allowed` and `precondition_required` (both additive).
493+
`DispatcherErrorCode` changes members from `'404' | '405' | '501' | '503'` to
494+
the four semantic spellings the removed `error.type` declared, and
495+
`DispatcherErrorResponseSchema.error.code` becomes a string — it had declared
496+
the opposite of `ApiErrorSchema` for the same field, which is what let the
497+
deviation stand.
498+
460499
### Dead spec clusters removed
461500

462501
Each of these parsed and did nothing. None has a runtime consumer; delete the

docs/audits/2026-07-dispatcher-client-route-coverage.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -290,11 +290,23 @@ Four error dialects were live in-repo at the time:
290290
| `settings-routes.ts`, `share-link-routes.ts` | `{ error: { code, message } }` — no `success` |
291291
| service-i18n, service-storage | `{ error: <string> }`, sometimes `+ code` at the top level |
292292

293-
#3675 moved the last row to the contract. The rest are unchanged, and the
294-
dispatcher's deviation is now pinned to exactly one field (`error.code` carries
295-
the HTTP status where a semantic string is declared) by a test rather than by
296-
prose — it parks the real code in `details` to work around its own occupied
297-
field, which is the tell.
293+
#3675 moved the last row to the contract, and #3842 moved the dispatcher row:
294+
`error.code` is the declared semantic string, the HTTP status has its own
295+
`httpStatus`, and the three parking spots the occupied field forced
296+
(`details.code`, `details.type`, `error.type`) are gone. Its guard is
297+
`error-envelope.conformance.test.ts` in `packages/runtime`, built the same two
298+
ways as the storage/i18n suites — drive every branch, then scan the source so a
299+
new branch cannot reintroduce the drift. `rest-server.ts` and the two sibling
300+
services keep their dialects; the remaining envelope gaps are tracked in #3843.
301+
302+
Note what the pin bought. #3687 wrote the deviation down as an executable
303+
assertion (`issues.map(path)` `toEqual(['code'])`) instead of a comment, with
304+
the instruction to DELETE rather than update it once the dispatcher was fixed.
305+
That is exactly how it played out: the assertion failed the moment the fix
306+
landed, named the one field to look at, and its replacement asserts the positive
307+
(`safeParse` succeeds). A pin is worth writing when a known deviation cannot be
308+
fixed yet — it dates the debt and fails loudly when either the debt grows or
309+
someone pays it off.
298310

299311
The generalisable lesson matches §11's: a guard only covers the question it
300312
asks. "The route exists" and "the route answers in the declared shape" are two

0 commit comments

Comments
 (0)