From 170bf229c892bf4cfa21c426ede8430e56ede72a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:12:32 +0000 Subject: [PATCH 1/3] fix(runtime,spec)!: error.code is the semantic string, the status moves to httpStatus (#3842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj --- .../dispatcher-error-code-is-semantic.md | 88 +++++++ content/docs/api/client-sdk.mdx | 9 +- content/docs/api/error-catalog.mdx | 30 ++- content/docs/api/index.mdx | 11 +- content/docs/api/wire-format.mdx | 38 +++ content/docs/releases/v17.mdx | 39 +++ ...026-07-dispatcher-client-route-coverage.md | 22 +- packages/client/src/client.test.ts | 50 +++- packages/client/src/index.ts | 26 +- ...ispatcher-plugin.ready.integration.test.ts | 5 +- packages/runtime/src/dispatcher-plugin.ts | 35 ++- .../dispatcher-validation-error.real.test.ts | 7 +- .../src/dispatcher-validation-error.test.ts | 50 ++-- .../src/domain-handler-registry.test.ts | 7 +- .../runtime/src/domain-handler-registry.ts | 18 +- .../actions-fault-vs-rejection.test.ts | 3 +- packages/runtime/src/domains/ai.ts | 18 +- .../src/domains/error-passthrough.test.ts | 9 +- packages/runtime/src/domains/mcp.ts | 16 +- .../src/error-envelope.conformance.test.ts | 229 ++++++++++++++++++ packages/runtime/src/error-envelope.ts | 134 ++++++++++ .../src/http-dispatcher.error-leak.test.ts | 11 +- .../runtime/src/http-dispatcher.root.test.ts | 3 +- packages/runtime/src/http-dispatcher.test.ts | 48 ++-- packages/runtime/src/http-dispatcher.ts | 71 ++++-- packages/spec/authorable-surface.json | 1 + packages/spec/src/api/contract.zod.ts | 14 ++ packages/spec/src/api/dispatcher.test.ts | 51 +++- packages/spec/src/api/dispatcher.zod.ts | 59 +++-- packages/spec/src/api/errors.test.ts | 48 ++++ packages/spec/src/api/errors.zod.ts | 58 ++++- 31 files changed, 1041 insertions(+), 167 deletions(-) create mode 100644 .changeset/dispatcher-error-code-is-semantic.md create mode 100644 packages/runtime/src/error-envelope.conformance.test.ts create mode 100644 packages/runtime/src/error-envelope.ts 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/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/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 From 3018f17063770dffe7bee682d03cacc8f81d55a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:19:53 +0000 Subject: [PATCH 2/3] docs(references): regenerate the API reference for the #3842 spec changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/references/` is generated from `packages/spec` by `scripts/build-docs.ts` and is gated by `check:docs`. The spec edits in the parent commit — `ApiError.httpStatus`, two new `StandardErrorCode` members, and `DispatcherErrorCode`/`DispatcherErrorResponseSchema` — left 12 reference pages stale. Regenerated with `gen:schema && gen:docs`; no hand edits. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj --- content/docs/references/api/analytics.mdx | 6 +-- content/docs/references/api/auth.mdx | 4 +- .../docs/references/api/automation-api.mdx | 18 ++++----- content/docs/references/api/batch.mdx | 6 +-- content/docs/references/api/contract.mdx | 33 ++++++++++++---- content/docs/references/api/dispatcher.mdx | 12 +++--- content/docs/references/api/errors.mdx | 10 +++-- content/docs/references/api/export.mdx | 12 +++--- content/docs/references/api/metadata.mdx | 38 +++++++++---------- content/docs/references/api/package-api.mdx | 16 ++++---- content/docs/references/api/protocol.mdx | 12 +++--- content/docs/references/api/storage.mdx | 16 ++++---- 12 files changed, 101 insertions(+), 82 deletions(-) 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; … }` | ✅ | | From 0659a409d88022a0a2dbb6a00470bb53fbfc75ad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:22:55 +0000 Subject: [PATCH 3/3] chore(spec): regenerate the skill reference index and API-surface snapshot (#3842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more artifacts generated from `packages/spec` and gated in CI, missed alongside `content/docs/references/`: - `skills/objectstack-api/references/_index.md` (`check:skill-refs`) - `packages/spec/api-surface.json` (`check:api-surface`) — records the two intentional additions, `HttpStatusErrorCodeMap` and `standardErrorCodeForHttpStatus`. 0 breaking, 2 added. Ran every generate/check pair in the package this time rather than one CI round at a time: docs, skill-refs, skill-docs, api-surface, spec-changes, upgrade-guide, react-blocks, authorable-surface, liveness, empty-state, react-conformance, skill-examples — all twelve pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj --- packages/spec/api-surface.json | 4 +++- skills/objectstack-api/references/_index.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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