From 090065a931e7703a1a87608193e31fe2bf356cf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:14:47 +0000 Subject: [PATCH 1/3] docs(protocol): begin the two-envelope rewrite of the error-handling page Envelope section, auth codes and the validation codes, each measured against its writer. Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .../docs/protocol/kernel/error-handling.mdx | 361 ++++++++++++++---- 1 file changed, 282 insertions(+), 79 deletions(-) diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 0d8a2ab17a..3bbebeb934 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -25,7 +25,12 @@ API 4: HTTP 200 OK with { status: "error", ... } - Debugging is nightmare (where did this error originate?) - Monitoring/alerting is inconsistent -**Solution:** ObjectStack enforces a **single error format** across all communication channels. Every error has a machine-readable code, human-readable message, and context for debugging. +**Solution:** ObjectStack answers every error with a machine-readable `code`, a human-readable message and context for debugging, drawn from one closed code vocabulary ([ADR-0112](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0112-error-code-vocabulary-and-ledger.md)). + +⚠️ What it does **not** do is put them all in one **envelope**. The platform writes +**two** error envelopes, and which one you receive is a property of the door that +answered — see [Error Response Envelopes](#error-response-envelopes) below before you +write an unwrapping helper. ## Business Value Delivered @@ -52,30 +57,148 @@ API 4: HTTP 200 OK with { status: "error", ... } /> -## Standard Error Response +## Error Response Envelopes + +ObjectStack has **two** live error envelopes. There is no third, and there is no +universal one. + +### The rule + +> **The envelope is decided by the door that wrote the body — never by the error code +> and never by the HTTP status.** + +Two corollaries, and both of them bite: + +- **A refusal a writer *builds* always carries that writer's envelope.** `UNIQUE_VIOLATION`, + `DELETE_RESTRICTED` and `INVALID_FIELD` are built by the `/data` door's classification + arms, so they are **always flat**. `RATE_LIMIT_EXCEEDED` is built by the inbound rate + limiter, so it is **always nested**. +- **A refusal that is *thrown* and classified at the boundary carries the envelope of + whichever door caught it.** `PERMISSION_DENIED`, `RESOURCE_NOT_FOUND`, + `SERVICE_UNAVAILABLE` and `INTERNAL_ERROR` are all thrown by shared guards and reach + clients in **either** envelope depending on the route. + +So do not derive the envelope from the code. Derive it from the body you are holding: + +```javascript +// The one discriminator that cannot go stale: the top-level `success` flag. +const isNested = body !== null && typeof body === 'object' && 'success' in body; + +const code = isNested ? body.error?.code : body.code; +const message = isNested ? body.error?.message : body.error; +``` + + +**`error` means two different things.** In the nested envelope `error` is an **object** +holding `code` and `message`. In the flat envelope `error` is the **message string +itself**. Reading `body.error` as the same thing in both is the single most expensive +mistake on this page — `body.error.code` is `undefined` against a flat body, and +`String(body.error)` is `[object Object]` against a nested one. + + +### Nested envelope — `{ success, error: { … } }` -Every error follows this structure: +The envelope [`BaseResponseSchema`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/api/contract.zod.ts) +declares. Written by two writers: + +- `sendOk` / `sendError` in `packages/types/src/response-envelope.ts` — the shared pair + every converted route module writes through. +- `buildApiError` / `apiErrorResponse` in `packages/runtime/src/error-envelope.ts` — the + runtime HTTP dispatcher's door, and the inbound rate limiter. ```json { "success": false, "error": { - "code": "error_code", - "message": "Human-readable description", - "details": { /* Additional context */ }, - "requestId": "req_abc123", - "timestamp": "2024-01-16T14:30:00Z" + "code": "UNAUTHENTICATED", + "message": "Sign in to create share links" } } ``` **Fields:** -- `success`: Always `false` for errors -- `error.code`: Machine-readable error code (use for conditionals) -- `error.message`: Human-readable message (show to users or developers) -- `error.details`: Additional context (field names, constraints, etc.) -- `error.requestId`: Unique request identifier for debugging -- `error.timestamp`: When error occurred (ISO 8601) +- `success` — always `false` for errors. +- `error.code` — the machine-readable code. Closed vocabulary: the parameter is typed to + `ErrorCode`, so an unregistered spelling cannot reach this field. +- `error.message` — the human-readable message. +- `error.httpStatus` — the status, repeated in the body. Written by the **dispatcher** + writer on every body it builds; no `sendError` call site passes one today, so it is + absent from the route-module bodies. +- `error.details` — optional structured context, present only where the emitting route + passes one. Not a general-purpose bag: it appears on a handful of refusals + (rate limiting, settings validation, partial-delete reports) and nowhere else. +- `error.declaredCode` — optional. An author's own `code` when it is **not** a member of + the closed vocabulary, demoted to this sibling rather than overwriting `code`. +- `error.userMessage` — optional. Text a producer marked, at throw time, as addressed to + the end user; render it verbatim when present. + +⛔ **No `requestId` and no `timestamp`.** Neither field is written into any error body by +either writer. The request id travels as a **response header** — see +[Debugging with Request IDs](#debugging-with-request-ids). + +### Flat envelope — the ADR-0112 `/data` door + +Written by `packages/rest/src/error-response.ts` (`structuredCodeAnswer` classifies the +condition, `resolveErrorResponse` / `mapDataError` answer it). The structured context the +nested envelope would nest under `details` rides as **top-level siblings** here. + +```json +{ + "error": "A record with this email already exists", + "code": "UNIQUE_VIOLATION", + "field": "email", + "object": "account" +} +``` + +**Fields:** +- `error` — the human-readable message, **a string**. +- `code` — the machine-readable code. **Optional**: ADR-0112 invents nothing for the half + a producer did not name, so a refusal that declared a status but no code arrives with + `error` alone. Branch on `code` only after checking it is there. +- `object` — the object the refusal is about, when the door knows it. +- `declaredCode` / `userMessage` — the same two optional channels as above, as top-level + siblings. +- **Per-condition siblings**, present only on the arm that builds them: `field` + (`UNIQUE_VIOLATION`, `INVALID_FIELD`), `fields` (`VALIDATION_FAILED`), + `developerMessage` + `dependentObject` + `dependentCount` (`DELETE_RESTRICTED`), + `currentVersion` + `currentRecord` (`CONCURRENT_UPDATE`), `datasource` + `reason` + (`ERR_DATASOURCE_UNAVAILABLE`), `issues` (a spec-validation throw). + +⛔ **No `success`, no `details`, no `requestId`, no `timestamp`.** The flat door writes +none of them. + +### Which route families answer which + +| Route family | Envelope | Writer | +|---|---|---| +| `{base}/data/*` — CRUD, `query`, `batch`, `createMany` / `updateMany` / `deleteMany`, `clone`, import / export, reports, approvals, sharing rules | **flat** | `packages/rest/src/error-response.ts` | +| `{base}/meta/*`, `{base}/ui/*`, discovery and search on the `@objectstack/rest` door, for anything **thrown** out of a handler | **flat** | the same door, via `handleRouteError` | +| `/api/v1/storage/*` | nested | `storage-routes.ts` → `sendError` | +| `/api/settings/*` | nested | `settings-routes.ts` → `sendError` | +| `/api/v1/share-links/*` | nested | `share-link-routes.ts` → `sendError` | +| `/api/v1/packages/*` | nested | `package-routes.ts` → `sendError` | +| `/api/v1/datasources/*` | nested | `admin-routes.ts`, `external-datasource-routes.ts` → `sendError` | +| `/api/v1/i18n/*` | nested | `i18n-service-plugin.ts` → `sendError` | +| every route on a host served by the **runtime HTTP dispatcher** (`/meta`, `/auth`, `/ai`, `/automation`, `/packages`, `/ui`, `/security`, `/notifications`, `/analytics`, `/mcp`, `/keys`, `/actions`, `/share-links`, `/i18n`) | nested | `buildApiError` / `apiErrorResponse` | +| the inbound rate limiter, ahead of every route | nested | `inbound-rate-limit.ts` | + + +**The `/meta` family is split by door, not by path.** A host that serves `/meta` through +the runtime dispatcher answers nested — that is the shape the +[error catalog's `/meta` examples](/docs/api/error-catalog) publish. A host that serves it +through `@objectstack/rest` answers the flat body for the *same* refusal, because the same +thrown error is classified by a different door. Read the body, not the path. + + + +**Two envelopes is the measured state, not the target state.** Some older registrars on +the `@objectstack/rest` door still emit bodies that are neither — an `error` string with no +`code`, or a `code` sibling with no `success` above it. That drift is tracked and +shrink-only: `pnpm check:route-envelope` is the authority on which files carry it and how +much is left. Write clients against the discriminator above, which is correct for the +conforming bodies and fails loudly rather than silently on the rest. + ## HTTP Status Codes @@ -97,26 +220,39 @@ ObjectStack uses standard HTTP status codes: ## Error Codes +Every entry below names the **envelope** its example is written in, and which door emits +it. A code that reaches clients in both envelopes says so and shows both bodies — that is +the rule from [Error Response Envelopes](#error-response-envelopes) applied per code, not +an inconsistency. + ### Authentication & Authorization #### `UNAUTHENTICATED` **HTTP Status:** 401 -**Meaning:** No authentication credentials provided or invalid credentials +**Meaning:** No authentication credentials provided or invalid credentials +**Envelope:** **both** — thrown by shared guards, so the answering door decides -**Example:** +**Nested** (a `sendError` route module — here `POST /api/v1/share-links`): ```json { "success": false, "error": { "code": "UNAUTHENTICATED", - "message": "Authentication required", - "details": { - "hint": "Include 'Authorization: Bearer ' header" - } + "message": "Sign in to create share links" } } ``` +**Flat** (the `@objectstack/rest` door — here the sign-in gate on the generated API docs): +```json +{ + "error": "This documentation requires sign-in", + "code": "UNAUTHENTICATED" +} +``` + +⛔ No `details` bag rides either body: no emitting site passes one for this code. + **How to fix:** - Include valid JWT token in `Authorization` header - Refresh expired tokens @@ -124,7 +260,8 @@ ObjectStack uses standard HTTP status codes: #### `INVALID_TOKEN` **HTTP Status:** 401 -**Meaning:** Token is malformed or invalid +**Meaning:** Token is malformed or invalid +**Envelope:** nested — emitted only by the storage routes, on a rejected upload/download token **Example:** ```json @@ -132,14 +269,14 @@ ObjectStack uses standard HTTP status codes: "success": false, "error": { "code": "INVALID_TOKEN", - "message": "JWT token is invalid", - "details": { - "reason": "signature_verification_failed" - } + "message": "Invalid or expired token" } } ``` +⛔ No `details.reason`: the emitting site deliberately does not disclose *why* the token +failed. + **How to fix:** - Check token hasn't been tampered with - Verify token is meant for this API (check `aud` claim) @@ -147,47 +284,53 @@ ObjectStack uses standard HTTP status codes: #### `EXPIRED_TOKEN` **HTTP Status:** 401 -**Meaning:** JWT token has expired +**Meaning:** Authentication token expired +**Envelope:** none — no producer emits it -**Example:** +**Not emitted today.** `EXPIRED_TOKEN` is a registered member of the standard error-code +catalog (`StandardErrorCode`, `packages/spec/src/api/errors.zod.ts`), and that +registration is its only occurrence outside tests — no ObjectStack producer writes it to a +response. Because nothing emits it, it has no envelope and no example: ⛔ do not write a +client branch against it. + +An expired credential surfaces as `INVALID_TOKEN` above (the storage token gate does not +distinguish expiry from malformation) or as `UNAUTHENTICATED` from the session guards. + +#### `PERMISSION_DENIED` +**HTTP Status:** 403 +**Meaning:** Authenticated but insufficient permissions +**Envelope:** **both** — the identity and last-admin guards *throw* it, so the answering door decides + +**Flat** (a `/data` write refused by the ADR-0092 identity-write guard): ```json { - "success": false, - "error": { - "code": "EXPIRED_TOKEN", - "message": "JWT token expired", - "details": { - "expired_at": "2024-01-16T10:00:00Z", - "current_time": "2024-01-16T14:30:00Z" - } - } + "error": "Identity table 'sys_user' is managed by better-auth (ADR-0092): direct update via the data API is disabled — use the dedicated auth surface instead (invite / create-user / admin endpoints, or the better-auth API).", + "code": "PERMISSION_DENIED", + "object": "sys_user" } ``` -**How to fix:** -- Refresh token using refresh token flow -- Re-authenticate user -- Check token lifetime settings (typically 15-60 minutes) +⚠️ The guard throws `PERMISSION_DENIED: `, and the door **strips** that prefix +before writing the body: the code already rides the `code` axis, so restating it inside +the sentence would ship one fact twice. ⛔ Do not match on a leading `CODE:` — it is +removed. -#### `PERMISSION_DENIED` -**HTTP Status:** 403 -**Meaning:** Authenticated but insufficient permissions - -**Example:** +**Nested** (a `sendError` route module — here the datasource admin routes): ```json { "success": false, "error": { "code": "PERMISSION_DENIED", - "message": "Insufficient permissions to access this resource", - "details": { - "required_permission": "account:write", - "user_permissions": ["account:read"] - } + "message": "Managing datasources requires the `manage_platform_settings` capability." } } ``` +⛔ Neither body enumerates the required or held permissions. Nothing emits a +`required_permission` / `user_permissions` bag — telling an unauthorized caller exactly +which grant would have worked is the disclosure the +[Security Considerations](#security-considerations) below rule out. + **How to fix:** - Request permission from administrator - Check row-level security rules @@ -195,52 +338,112 @@ ObjectStack uses standard HTTP status codes: ### Validation Errors -#### `VALIDATION_ERROR` +#### `VALIDATION_FAILED` **HTTP Status:** 400 -**Meaning:** Input validation failed (schema validation) +**Meaning:** Per-field record validation failed on a write +**Envelope:** flat — built by the `/data` door's validation arm, so it is never nested -**Example:** +This is the code that carries **per-field** detail. The `fields` array is a **top-level +sibling** of `code`, not a `details.fields` bag. + +**Example** — `POST /api/v1/data/account` with a bad email and an out-of-range number: ```json { - "success": false, - "error": { - "code": "VALIDATION_ERROR", - "message": "Validation failed for 2 fields", - "details": { - "fields": [ - { - "field": "email", - "message": "Invalid email format", - "constraint": "format", - "value": "not-an-email" - }, - { - "field": "age", - "message": "Must be at least 18", - "constraint": "min", - "value": 15, - "expected": 18 - } - ] + "error": "Invalid email format; Revenue must be at least 0", + "code": "VALIDATION_FAILED", + "fields": [ + { + "field": "email", + "code": "invalid_email", + "message": "Invalid email format", + "label": "Email", + "value": "not-an-email" + }, + { + "field": "revenue", + "code": "min_value", + "message": "Revenue must be at least 0", + "label": "Revenue", + "constraint": { "min": 0 } } - } + ], + "object": "account" } ``` +**Field entry shape:** `field` (the API name, so a form can focus the input), `code` (a +`FieldErrorCode` — `required`, `invalid_email`, `min_value`, `max_length`, `invalid_option`, +`rule_violation`, … — the closed per-field catalog in +`packages/spec/src/api/errors.zod.ts`), `message` (rendered in the caller's locale), +and optionally `label`, `constraint`, `value`, `options`. + +⚠️ `fields` is always present on this arm, and is `[]` when the thrown error carried none. +The top-level `error` string is the field messages joined with `; ` — show it verbatim in +a toast, and use `fields` to annotate inputs. + **How to fix:** -- Check field constraints in object schema (`GET /api/v1/meta/object/{object}`) +- Check field constraints in the object schema (`GET /api/v1/meta/object/{object}`) - Validate input client-side before submission -- Show field-specific errors in UI +- Show field-specific errors in the UI **Client-side handling:** ```javascript -if (error.code === 'VALIDATION_ERROR') { - error.details.fields.forEach(({ field, message }) => { +if (body.code === 'VALIDATION_FAILED') { + for (const { field, message } of body.fields ?? []) { showFieldError(field, message); - }); + } } ``` +#### `VALIDATION_ERROR` +**HTTP Status:** 400 +**Meaning:** The request is malformed — the generic "you sent something wrong" code +**Envelope:** **both**, and one emitter writes neither (see the warning below) + +⛔ **Not the per-field code.** `VALIDATION_ERROR` never carries a `fields` array and never +carries a `details.fields` bag. For per-field write validation, read `VALIDATION_FAILED` +above. + +Where it comes from: +- **Derived from the status.** `VALIDATION_ERROR` is what `HttpStatusErrorCodeMap` + (`packages/spec/src/api/errors.zod.ts`) maps HTTP **400** to. A door handed a refusal + that names a 400 and no code labels it `VALIDATION_ERROR` from the status alone — so it + is the code you see for the whole family of malformed requests that named nothing more + specific. +- **Query-parameter refusals.** `refuseUnknownQueryParams` and `refuseRepeatedQueryParams` + answer 400 `VALIDATION_ERROR` for an unrecognised or repeated query parameter. + +**Nested** (the dispatcher door, status-derived — `GET /api/v1/packages/:id?version=1&version=2`): +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "The \"version\" query parameter was supplied 2 times. Supply it at most once — this endpoint will not choose between conflicting values.", + "httpStatus": 400 + } +} +``` + +**Flat** (the `@objectstack/rest` form-submit door): +```json +{ + "code": "VALIDATION_ERROR", + "error": "This form declares no fields, so it cannot accept a submission. Wire the fields it collects into the form's sections and publish it again." +} +``` + + +**The two query-parameter helpers write a third shape.** On the `@objectstack/rest` door, +`refuseUnknownQueryParams` and `refuseRepeatedQueryParams` write +`{ "error": { "code": "VALIDATION_ERROR", "message": … } }` — nested per ADR-0112, but with +**no `success` flag above it**, so the discriminator in +[Error Response Envelopes](#error-response-envelopes) reads them as flat and `body.code` +comes back `undefined`. That is tracked, shrink-only drift, not a third contract: +`pnpm check:route-envelope` pins it. Until it converges, a client that must survive it +should fall back to `body.error?.code` when `body.code` is absent. + + #### `MISSING_REQUIRED_FIELD` **HTTP Status:** 400 — with one documented exception, which answers **422** (see below) **Meaning:** Required field is missing From f0998adec0184e8ef6fb2dcdf4761e0fcb04152b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:17:23 +0000 Subject: [PATCH 2/3] docs(protocol): finish the per-code envelope placement on the error-handling page Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .../docs/protocol/kernel/error-handling.mdx | 302 +++++++++++------- 1 file changed, 189 insertions(+), 113 deletions(-) diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index 3bbebeb934..cf551c599f 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -446,23 +446,37 @@ should fall back to `body.error?.code` when `body.code` is absent. #### `MISSING_REQUIRED_FIELD` **HTTP Status:** 400 — with one documented exception, which answers **422** (see below) -**Meaning:** Required field is missing +**Meaning:** Required field is missing +**Envelope:** **both** — the 422 master-reference refusal is thrown at the `/data` door +(flat); the 400 form is written by a `sendError` route module (nested) -**Example:** +⚠️ On a `/data` **write**, an ordinary missing required field does not answer this code at +all — it answers `400 VALIDATION_FAILED` with the field named in `fields`, above. This +code is the one the *master-access gate* and the package-publish route use. + +**Flat** (the 422 exception, on the `/data` door): +```json +{ + "error": "[Security] Missing master reference: insert on 'invoice_line' did not supply 'invoice_id'. A controlled_by_parent detail derives its access from its master, so 'invoice_id' must carry a master record id on every write.", + "code": "MISSING_REQUIRED_FIELD", + "object": "invoice_line" +} +``` + +**Nested** (the 400, on `POST /api/v1/packages/publish`): ```json { "success": false, "error": { "code": "MISSING_REQUIRED_FIELD", - "message": "Missing required field: name", - "details": { - "field": "name", - "constraint": "required" - } + "message": "Missing required fields: manifest, metadata" } } ``` +⛔ Neither body carries a `details` bag. On the flat 422 there is no `fields` array either +— see the table below for why. + **Exception — an absent `controlled_by_parent` master reference answers 422 with no `fields`.** An object whose `sharingModel` is `controlled_by_parent` derives its access from a master record, so the gate that authorizes writes to it must resolve that master *before* the @@ -508,21 +522,22 @@ deriving it from this page. resolves to nothing, not a value of the wrong type. On a list read it also covers an unreserved query parameter, which `GET /data/:object` reads as a field filter. +**Envelope:** flat — built by the `/data` door's `INVALID_FIELD` arm, so it is never +nested. `field` and `object` are **top-level siblings**, not a `details` bag. + **Example:** ```json { - "success": false, - "error": { - "code": "INVALID_FIELD", - "message": "Unknown field 'age' on object 'contact'", - "details": { - "field": "age", - "object": "contact" - } - } + "error": "Unknown field 'age' on object 'contact'", + "code": "INVALID_FIELD", + "field": "age", + "object": "contact" } ``` +⚠️ `field` is present only when the refusal named one; `object` is present when the door +knows which object was addressed. + The [error catalog's `INVALID_FIELD` entry](/docs/api/error-catalog#invalid_field) carries the authoritative cause text — it enumerates every read axis this one code answers on (`select`, `expand`, `searchFields`, `groupBy`, `aggregations[].field`) and the @@ -532,31 +547,50 @@ off-request `backfillSummaryNulls` case. #### `RESOURCE_NOT_FOUND` **HTTP Status:** 404 -**Meaning:** Requested resource doesn't exist +**Meaning:** Requested resource doesn't exist +**Envelope:** nested — this is the code the `sendError` route modules and the +status-derived dispatcher door answer 404 with -**Example:** +**Example** — `GET /api/v1/datasources/warehouse`: ```json { "success": false, "error": { "code": "RESOURCE_NOT_FOUND", - "message": "Account with id 'acc_999' not found", - "details": { - "resource": "account", - "resource_id": "acc_999" - } + "message": "Datasource \"warehouse\" does not exist." } } ``` +⛔ No `details.resource` / `details.resource_id` bag: no emitting site writes one. The +resource is named in `message`. + + +**The `/data` door answers a different code.** A record missing from a CRUD route is +`RECORD_NOT_FOUND` in the flat envelope, with the object as a top-level sibling — see +[HTTP API](/docs/protocol/kernel/http-protocol): + +```json +{ + "error": "Record acc_999 not found in account", + "code": "RECORD_NOT_FOUND", + "object": "account" +} +``` + +A *registered object* that does not exist is `OBJECT_NOT_FOUND`, also flat. Branch on all +three if you handle both families. + + **How to fix:** -- Verify resource ID is correct -- Check user has permission to see resource (row-level security) -- Resource may have been deleted +- Verify the resource id is correct +- Check the user has permission to see the resource (row-level security) +- The resource may have been deleted #### `UNIQUE_VIOLATION` **HTTP Status:** 409 -**Meaning:** The write collides with a unique constraint — a record already holds that value +**Meaning:** The write collides with a unique constraint — a record already holds that value +**Envelope:** flat — built by the `/data` door's duplicate-record arm, so it is never nested **Example:** ```json @@ -582,36 +616,42 @@ never crosses HTTP. The refusal is emitted as the flat body shown above, and #### `DELETE_RESTRICTED` **HTTP Status:** 409 -**Meaning:** Operation violates database constraint +**Meaning:** The record is still referenced by dependent records that cannot be cleared +**Envelope:** flat — built by the `/data` door's `DELETE_RESTRICTED` arm, so it is never +nested -**Example:** +**Example** — `DELETE /api/v1/data/account/acc_123`: ```json { - "success": false, - "error": { - "code": "DELETE_RESTRICTED", - "message": "Cannot delete account with active opportunities", - "details": { - "resource": "account", - "resource_id": "acc_123", - "constraint": "foreign_key", - "related_object": "opportunity", - "related_count": 5 - } - } + "error": "This Account is still referenced by 5 Opportunity record(s) through “Account”. Delete or reassign them first.", + "code": "DELETE_RESTRICTED", + "developerMessage": "Cannot delete account (acc_123): 5 dependent opportunity record(s) reference it via account_id. Delete or reassign them first, or set deleteBehavior:'cascade' on opportunity.account_id.", + "dependentObject": "opportunity", + "dependentCount": 5, + "object": "account" } ``` +**Two sentences, deliberately.** `error` is the **end-user** half — localized, display +labels only, rendered verbatim in a toast. `developerMessage` is the **builder** half — API +names and the `deleteBehavior:'cascade'` remedy — in a field no user-facing surface reads. + +⚠️ `dependentCount` is **absent**, not zero, when the caller's own permissions would not +have let them count the dependents; `dependentObject` is named either way. Treat a missing +`dependentCount` as "withheld", never as "none". + **How to fix:** -- Delete related records first -- Enable cascade delete on object schema -- Archive instead of delete (soft delete) +- Delete or reassign the related records first +- Set `deleteBehavior: 'cascade'` on the referencing field +- Archive instead of deleting (soft delete) ### Rate Limiting #### `RATE_LIMIT_EXCEEDED` **HTTP Status:** 429 -**Meaning:** Too many requests, rate limit exceeded +**Meaning:** Too many requests, rate limit exceeded +**Envelope:** nested — built by the inbound rate limiter, which sits **ahead of every +route**, so it is never flat whichever family you were calling **Example:** ```json @@ -619,15 +659,19 @@ never crosses HTTP. The refusal is emitted as the flat body shown above, and "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", - "message": "Rate limit exceeded", + "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.", + "httpStatus": 429, "details": { "retryAfterSeconds": 45, - "resetAt": "2024-01-16T14:31:00Z" + "resetAt": "2026-08-03T12:00:45.000Z" } } } ``` +This is one of the few refusals that genuinely carries a `details` bag — the limiter +passes one. The code itself is derived from the 429 status. + **HTTP Headers:** ```http HTTP/1.1 429 Too Many Requests @@ -660,7 +704,8 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { #### `QUOTA_EXCEEDED` **HTTP Status:** 429 -**Meaning:** Monthly/daily quota exceeded +**Meaning:** Monthly/daily quota exceeded +**Envelope:** none — no producer emits it **Not emitted today.** `QUOTA_EXCEEDED` is a registered member of the standard error-code catalog (`StandardErrorCode`, `packages/spec/src/api/errors.zod.ts`), @@ -676,45 +721,71 @@ on the wire is `RATE_LIMIT_EXCEEDED` above, whose `Retry-After` header and #### `INTERNAL_ERROR` **HTTP Status:** 500 -**Meaning:** Internal server error +**Meaning:** Internal server error +**Envelope:** **both** — every door has a 500 terminal, and each writes its own envelope -**Example:** +**Flat** (the `/data` door's terminal for an error nothing classified): +```json +{ + "error": "Internal server error", + "code": "INTERNAL_ERROR" +} +``` + +**Nested** (a `sendError` route module — here the settings routes): ```json { "success": false, "error": { "code": "INTERNAL_ERROR", - "message": "An internal error occurred", - "details": { - "requestId": "req_abc123", - "support_url": "https://support.acme.com/request/req_abc123" - } + "message": "Failed to write namespace" } } ``` -**Important:** Never leak stack traces or sensitive internals to clients. +**Important:** the message on a 5xx is **withheld by construction**, not by a heuristic. +The flat door replaces a declared-5xx message with the fixed `Internal server error` +string, so no phrasing a producer picks — deliberate or accidental — can carry driver text +or a stack trace to a client. + +⛔ No `details` bag and no `requestId` in the body. The request id is a **response +header** — see [Debugging with Request IDs](#debugging-with-request-ids). **How to fix:** -- Retry request (may be transient) -- Check server status page -- Contact support with `requestId` +- Retry the request (it may be transient) +- Check the server status page +- Contact support with the `X-Request-Id` value from the response headers #### `SERVICE_UNAVAILABLE` **HTTP Status:** 503 -**Meaning:** Server temporarily unavailable +**Meaning:** A dependency the route needs is not registered, or is unhealthy +**Envelope:** **both** — thrown by shared guards and written by `sendError` route modules -**Example:** +**Nested** (the datasource admin routes, when the service is not composed): ```json { "success": false, "error": { "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" + "message": "The external-datasource service is not available." } } ``` +**Flat** (the `/data` door — here an object whose datasource is refused or unreachable): +```json +{ + "error": "The datasource for this object is not available", + "code": "ERR_DATASOURCE_UNAVAILABLE", + "datasource": "warehouse", + "object": "shipment" +} +``` + +⚠️ The flat family's own 503 carries the **more specific** `ERR_DATASOURCE_UNAVAILABLE` +rather than the generic code, with `datasource` and `reason` as top-level siblings — it is +built by an arm, so it never degrades to `SERVICE_UNAVAILABLE`. Handle both. + **HTTP Headers:** ```http HTTP/1.1 503 Service Unavailable @@ -723,7 +794,10 @@ Retry-After: 300 ## Error Response Examples -### Validation Error (Multiple Fields) +Each example below is labelled with the envelope it is in, because the envelope is a +property of the door — see [Error Response Envelopes](#error-response-envelopes). + +### Validation Error (Multiple Fields) — flat, `/data` door **Request:** ```http @@ -743,40 +817,37 @@ HTTP/1.1 400 Bad Request Content-Type: application/json { - "success": false, - "error": { - "code": "VALIDATION_ERROR", - "message": "Validation failed for 3 fields", - "details": { - "fields": [ - { - "field": "name", - "message": "Name is required", - "constraint": "required", - "value": "" - }, - { - "field": "email", - "message": "Invalid email format", - "constraint": "format", - "value": "not-an-email" - }, - { - "field": "revenue", - "message": "Revenue must be positive", - "constraint": "min", - "value": -1000, - "expected": 0 - } - ] + "error": "Name is required; Invalid email format; Revenue must be at least 0", + "code": "VALIDATION_FAILED", + "fields": [ + { + "field": "name", + "code": "required", + "message": "Name is required", + "label": "Name" }, - "requestId": "req_abc123", - "timestamp": "2024-01-16T14:30:00Z" - } + { + "field": "email", + "code": "invalid_email", + "message": "Invalid email format", + "label": "Email", + "value": "not-an-email" + }, + { + "field": "revenue", + "code": "min_value", + "message": "Revenue must be at least 0", + "label": "Revenue", + "constraint": { "min": 0 } + } + ], + "object": "account" } ``` -### Permission Denied +⚠️ The request id is **not** in the body. Read it from the `X-Request-Id` response header. + +### Write Denied by Record Sharing — flat, `/data` door **Request:** ```http @@ -790,24 +861,26 @@ HTTP/1.1 403 Forbidden Content-Type: application/json { - "success": false, - "error": { - "code": "PERMISSION_DENIED", - "message": "Insufficient permissions to delete accounts", - "details": { - "resource": "account", - "resource_id": "acc_123", - "required_permission": "account:delete", - "user_permissions": ["account:read", "account:write"], - "hint": "Contact your administrator to request delete permission" - }, - "requestId": "req_def456", - "timestamp": "2024-01-16T14:35:00Z" - } + "error": "You do not have access to change or delete this record. Contact the person who owns it, or your administrator, if you need to make changes.", + "code": "FORBIDDEN", + "object": "account" } ``` -### Rate Limit Exceeded +⚠️ **`FORBIDDEN`, not `PERMISSION_DENIED`.** A by-id write the sharing rules refuse carries +`FORBIDDEN`; `PERMISSION_DENIED` is what the capability and identity guards carry. Both are +403s in the same envelope on this door, so branch on either — ⛔ but do not assume one code +covers every 403. + +⚠️ The refusal is thrown as `FORBIDDEN: ` and the door **strips** the +`CODE: ` prefix before writing the body, because the code already rides the `code` axis. +⛔ Do not match on a leading code prefix in `error`. + +⛔ The body deliberately names neither the permission you lack nor the permissions you +hold — see [Security Considerations](#security-considerations). The thrown error carries a +`details` bag internally; the flat door does not relay it. + +### Rate Limit Exceeded — nested, ahead of every route **Request:** ```http @@ -825,17 +898,20 @@ Content-Type: application/json "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", - "message": "Rate limit exceeded: 1000 requests per minute", + "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.", + "httpStatus": 429, "details": { "retryAfterSeconds": 45, - "resetAt": "2024-01-16T14:31:00Z" - }, - "requestId": "req_ghi789", - "timestamp": "2024-01-16T14:30:15Z" + "resetAt": "2026-08-03T12:00:45.000Z" + } } } ``` +⚠️ The same route answered the two envelopes above and below: the `/data` refusals are +flat, and the limiter's 429 in front of the very same path is nested. That is the rule at +work, and it is why a client must read the body's shape rather than the route's prefix. + ## Error Handling Best Practices ### ✅ Use Error Codes, Not Messages From 1cb81e8632a3f07691a2da3da3aae6222ebc135a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 12:19:18 +0000 Subject: [PATCH 3/3] docs(protocol): correct the best-practice snippets, request-id claim and security examples Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk Co-authored-by: Claude --- .../docs/protocol/kernel/error-handling.mdx | 238 ++++++++++++------ 1 file changed, 167 insertions(+), 71 deletions(-) diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx index cf551c599f..d174e08b8b 100644 --- a/content/docs/protocol/kernel/error-handling.mdx +++ b/content/docs/protocol/kernel/error-handling.mdx @@ -5,7 +5,9 @@ description: Global error codes, response formats, and debugging strategies for import { AlertCircle, Bug, Shield, Info, AlertTriangle, XCircle, Radio, Zap } from 'lucide-react'; -The **Error Handling Protocol** defines standardized error codes, response formats, and debugging strategies across all ObjectStack APIs (HTTP, WebSocket). +The **Error Handling Protocol** defines the standardized error codes, the **two** response +envelopes that carry them, and debugging strategies across the ObjectStack APIs (HTTP, +WebSocket). ## Why Standardized Errors Matter @@ -43,7 +45,7 @@ write an unwrapping helper. } title="Faster Debugging" - description="Error codes + request IDs = find root cause in seconds. Save hours of log hunting." + description="Error codes + the X-Request-Id response header = find root cause in seconds. Save hours of log hunting." /> } @@ -216,7 +218,9 @@ ObjectStack uses standard HTTP status codes: | **500** | Internal Server Error | Server-side error | | **503** | Service Unavailable | Server overloaded or maintenance | -**Important:** Even on error, response body always includes JSON error object. +**Important:** an error response always carries a JSON body naming the condition — but in +one of **two** envelopes. Read +[Error Response Envelopes](#error-response-envelopes) before branching on it. ## Error Codes @@ -914,6 +918,31 @@ work, and it is why a client must read the body's shape rather than the route's ## Error Handling Best Practices +### ✅ Normalise the Envelope Once, at the Edge + +Write one reader, use it everywhere, and ⛔ never let an envelope check spread across your +codebase. Everything below assumes this function has already run. + +**Good:** +```javascript +/** Normalise either ObjectStack error envelope into one shape. */ +function readError(body) { + const nested = body !== null && typeof body === 'object' && 'success' in body; + const err = nested ? (body.error ?? {}) : body; + return { + // `code` is absent when a producer declared a status but no code — keep it + // undefined rather than inventing one. + code: nested ? err.code : body?.code, + message: nested ? err.message : (typeof body?.error === 'string' ? body.error : undefined), + // Marked by the producer as addressed to the end user; render verbatim. + userMessage: nested ? err.userMessage : body?.userMessage, + // Per-field detail: `fields` is a top-level sibling on the flat door. + fields: nested ? undefined : body?.fields, + nested, + }; +} +``` + ### ✅ Use Error Codes, Not Messages **Bad:** @@ -925,8 +954,10 @@ if (error.message.includes('not found')) { **Good:** ```javascript -if (error.code === 'RESOURCE_NOT_FOUND') { - // Reliable - code never changes +const { code } = readError(body); +// Both families' "missing" codes — they differ by door, so handle both. +if (code === 'RESOURCE_NOT_FOUND' || code === 'RECORD_NOT_FOUND' || code === 'OBJECT_NOT_FOUND') { + // Reliable - the code vocabulary is closed and versioned } ``` @@ -934,40 +965,53 @@ if (error.code === 'RESOURCE_NOT_FOUND') { **Bad:** ```javascript -alert(error.message); // "VALIDATION_ERROR: Field 'email' constraint 'format' failed" +alert(body.error.message); // undefined on the flat envelope, and raw copy on the nested one ``` **Good:** ```javascript -const userMessages = { - 'VALIDATION_ERROR': 'Please check your input and try again', +const { code, message, userMessage } = readError(body); + +const generic = { + 'VALIDATION_FAILED': 'Please check your input and try again', + 'VALIDATION_ERROR': 'That request was not valid. Please check it and try again.', 'UNAUTHENTICATED': 'Please log in to continue', 'RATE_LIMIT_EXCEEDED': 'Too many requests. Please wait a moment.', }; -showToast(userMessages[error.code] || 'An error occurred'); +// `userMessage` is text the producer MARKED as addressed to the end user, so it +// wins over your generic substitution. `message` is the fallback of last resort. +showToast(userMessage ?? generic[code] ?? message ?? 'An error occurred'); ``` ### ✅ Handle Field-Specific Validation Errors +Per-field detail comes from the flat `/data` door as `VALIDATION_FAILED`, with `fields` as +a **top-level sibling**. ⛔ There is no `details.fields` bag on either envelope. + **Good:** ```javascript -async function handleSubmit(data) { - try { - const response = await api.createAccount(data); - return response.data; - } catch (error) { - if (error.code === 'VALIDATION_ERROR') { - // Show errors next to fields - error.details.fields.forEach(({ field, message }) => { - setFieldError(field, message); - }); - } else { - // Show general error - showToast(error.message, 'error'); +async function handleSubmit(payload) { + const response = await fetch('/api/v1/data/account', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const body = await response.json(); + if (response.ok) return body; + + const { code, message, fields } = readError(body); + if (code === 'VALIDATION_FAILED') { + // Annotate the inputs… + for (const { field, message: fieldMessage } of fields ?? []) { + setFieldError(field, fieldMessage); } - throw error; + // …and show the joined sentence, which is what `error` already carries. + showToast(message, 'error'); + } else { + showToast(message ?? 'An error occurred', 'error'); } + throw new APIError(body); } ``` @@ -984,18 +1028,23 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { const data = await response.json(); if (!response.ok) { - // Check if error is retryable - if (data.error.code === 'RATE_LIMIT_EXCEEDED') { - const retryAfter = data.error.details.retryAfterSeconds || 1; + const { code } = readError(data); + if (code === 'RATE_LIMIT_EXCEEDED') { + // Prefer the HEADER: it is present on every 429, whereas the + // `details` bag is a property of this one writer. + const retryAfter = Number(response.headers.get('Retry-After')) + || data?.error?.details?.retryAfterSeconds + || 1; await sleep(retryAfter * 1000); continue; - } else if (data.error.code === 'INTERNAL_ERROR') { + } else if (code === 'INTERNAL_ERROR' || code === 'SERVICE_UNAVAILABLE' + || code === 'ERR_DATASOURCE_UNAVAILABLE') { // Exponential backoff await sleep(Math.pow(2, attempt) * 1000); continue; } else { // Don't retry validation errors, auth errors, etc. - throw new APIError(data.error); + throw new APIError(data); } } @@ -1011,38 +1060,48 @@ async function fetchWithRetry(url, options = {}, maxRetries = 3) { ### ✅ Log Request IDs for Debugging +⛔ The request id is **not** in the response body, in either envelope. Read it from the +`X-Request-Id` **response header**, and stamp your own timestamp — no body carries one. + **Good:** ```javascript -try { - await api.createAccount(data); -} catch (error) { +const response = await fetch('/api/v1/data/account', init); +if (!response.ok) { + const body = await response.json(); + const { code, message } = readError(body); + const requestId = response.headers.get('X-Request-Id'); + console.error('Account creation failed', { - requestId: error.requestId, - code: error.code, - message: error.message, - timestamp: error.timestamp + requestId, + code, + message, + at: new Date().toISOString(), // your clock — the body has no `timestamp` }); - + // Send to error tracking service - Sentry.captureException(error, { - extra: { requestId: error.requestId } - }); + Sentry.captureException(new APIError(body), { extra: { requestId } }); } ``` ### ✅ Handle Network Errors +⛔ **Do not branch on `data.success`.** It is absent from every flat body, so `!data.success` +is true for a perfectly successful `/data` response and your client throws on its own +happy path. Branch on `response.ok` — the HTTP status — which is correct for both +envelopes. + **Good:** ```javascript try { const response = await fetch('/api/v1/data/task'); const data = await response.json(); - - if (!data.success) { - throw new APIError(data.error); - } - - return data.data; + + // The STATUS decides success, not a body flag. + if (!response.ok) throw new APIError(data); + + // Unwrap only what the nested envelope wraps; the flat door returns the + // payload directly. + return (data !== null && typeof data === 'object' && 'success' in data) ? data.data : data; } catch (error) { if (error instanceof TypeError && error.message === 'Failed to fetch') { // Network error - server unreachable @@ -1060,24 +1119,39 @@ try { ## Debugging with Request IDs -Every API response includes a `requestId` for debugging: +The request id travels as a **response header**, not as a body field. ⛔ Neither envelope +carries `requestId`, and no writer puts one there — a client reading `body.requestId` or +`body.error.requestId` gets `undefined` on every route. + +**Composition:** the id is attached by the observability instrumentation wrapper. A +deployment that does not compose it emits no id at all; one that does echoes a **valid +inbound** `X-Request-Id` and otherwise mints `req_`. The header name is configurable +(`observability.requestIdHeader`), defaulting to `X-Request-Id`. **Request:** ```http POST /api/v1/data/account Content-Type: application/json -X-Request-ID: my-custom-id-123 +X-Request-Id: my-custom-id-123 { "name": "Acme Corp" } ``` **Response:** -```json -{ - "success": true, - "data": { ... }, - "requestId": "my-custom-id-123" -} +```http +HTTP/1.1 201 Created +X-Request-Id: my-custom-id-123 +Content-Type: application/json + +{ "object": "account", "id": "acc_124", "record": { "...": "..." } } +``` + +⚠️ An inbound id is **validated** before it is echoed: a value that is empty, over-long or +outside the accepted character set is discarded and a fresh `req_` is minted instead. +Do not assume the id you sent is the id that was logged — read it back off the response. + +```javascript +const requestId = response.headers.get('X-Request-Id'); ``` **Server logs:** @@ -1104,7 +1178,7 @@ Track error rates by code: // Metrics dashboard { "error_rates": { - "VALIDATION_ERROR": 0.05, // 5% of requests + "VALIDATION_FAILED": 0.05, // 5% of requests "UNAUTHENTICATED": 0.02, // 2% of requests "RATE_LIMIT_EXCEEDED": 0.01, // 1% of requests "INTERNAL_ERROR": 0.0001 // 0.01% of requests (🚨 alert if > 0.01%) @@ -1122,7 +1196,7 @@ Track error rates by code: **Warning alerts:** - `RATE_LIMIT_EXCEEDED` spike (may indicate DDoS or integration bug) - `UNAUTHENTICATED` spike (credential leakage?) -- `VALIDATION_ERROR` spike on new form (bad client-side validation) +- `VALIDATION_FAILED` spike on a new form (bad client-side validation) ### Error Budgets @@ -1142,17 +1216,22 @@ error_budget: ## Security Considerations +These are rules for **writing a producer**. The examples are in the nested envelope; the +same rules hold verbatim on the flat one, where the structured context would ride as +top-level siblings instead. + ### ❌ Never Leak Sensitive Info **Bad:** ```json { + "success": false, "error": { "code": "UNAUTHENTICATED", "message": "Password incorrect for user john@acme.com", "details": { - "attempted_password": "Password123!", // 🚨 NEVER DO THIS - "actual_password_hash": "bcrypt$..." // 🚨 NEVER DO THIS + "attempted_password": "Password123!", + "actual_password_hash": "bcrypt$..." } } } @@ -1161,39 +1240,52 @@ error_budget: **Good:** ```json { + "success": false, "error": { "code": "UNAUTHENTICATED", - "message": "Invalid credentials", - "details": null + "message": "Invalid credentials" } } ``` +⚠️ `details` is **omitted**, not set to `null`. Both writers spread an absent optional +away, so a key with nothing to say does not reach the wire at all. + + +**5xx prose is withheld structurally, not by a keyword filter.** On the flat door a +producer-declared 500 has its message replaced with a fixed `Internal server error` +sentence before the body is written, so there is no phrasing — deliberate or accidental — +that carries driver text, SQL or a stack trace to a client. A 4xx message is addressed *to* +the caller and is kept, truncated rather than replaced. + + ### ❌ Don't Confirm Resource Existence **Bad:** ```json // Attacker probes: DELETE /api/v1/data/account/acc_123 { - "error": { - "code": "PERMISSION_DENIED", - "message": "You don't have permission to delete this account" - } + "error": "You don't have permission to delete this account", + "code": "FORBIDDEN" } // Attacker learns: Account acc_123 exists! 🚨 ``` **Good:** ```json -// Return RESOURCE_NOT_FOUND for both "doesn't exist" and "exists but no permission" +// Answer the same "not found" for "doesn't exist" and "exists but not visible" { - "error": { - "code": "RESOURCE_NOT_FOUND", - "message": "Account not found" - } + "error": "Record acc_123 not found in account", + "code": "RECORD_NOT_FOUND", + "object": "account" } ``` +This is the same reasoning behind the platform's own withholding: `DELETE_RESTRICTED` +names the blocking object unconditionally but **omits** `dependentCount` when the caller's +own permissions would not have let them count those rows, and `PERMISSION_DENIED` never +enumerates the grants you lack. + ### ✅ Rate Limit Error Responses Even error responses can be abused: @@ -1202,16 +1294,20 @@ Even error responses can be abused: // Attacker tries to enumerate user emails for (let i = 0; i < 1000000; i++) { await register({ email: `user${i}@example.com` }); - // Response: "UNIQUE_VIOLATION" or "VALIDATION_ERROR" + // Response: "UNIQUE_VIOLATION" or "VALIDATION_FAILED" } ``` -**Solution:** Rate limit failed registration attempts: +**Solution:** rate limit failed registration attempts. The inbound limiter answers ahead of +every route, in the nested envelope: ```json { + "success": false, "error": { "code": "RATE_LIMIT_EXCEEDED", - "message": "Too many failed registration attempts" + "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.", + "httpStatus": 429, + "details": { "retryAfterSeconds": 45, "resetAt": "2026-08-03T12:00:45.000Z" } } } ```