diff --git a/content/docs/protocol/kernel/error-handling.mdx b/content/docs/protocol/kernel/error-handling.mdx
index 0d8a2ab17a2..d174e08b8b9 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
@@ -25,7 +27,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
@@ -38,7 +45,7 @@ API 4: HTTP 200 OK with { status: "error", ... }
}
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."
/>
}
@@ -52,30 +59,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: { … } }`
+
+The envelope [`BaseResponseSchema`](https://github.com/objectstack-ai/objectstack/blob/main/packages/spec/src/api/contract.zod.ts)
+declares. Written by two writers:
-Every error follows this structure:
+- `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
@@ -93,30 +218,45 @@ 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
+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 +264,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 +273,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 +288,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,71 +342,145 @@ 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
+**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
@@ -305,21 +526,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
@@ -329,31 +551,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
@@ -379,36 +620,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
@@ -416,15 +663,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
@@ -457,7 +708,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`),
@@ -473,45 +725,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
@@ -520,7 +798,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
@@ -540,40 +821,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
@@ -587,24 +865,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
@@ -622,19 +902,47 @@ 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
+### ✅ 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:**
@@ -646,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
}
```
@@ -655,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);
}
```
@@ -705,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);
}
}
@@ -732,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
@@ -781,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:**
@@ -825,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%)
@@ -843,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
@@ -863,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$..."
}
}
}
@@ -882,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:
@@ -923,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" }
}
}
```