# Errors

The generated API returns structured error responses with consistent fields across all security layers.

## Status Codes

| Code | Meaning | When |
|------|---------|------|
| `200` | OK | Successful GET, PATCH, DELETE |
| `201` | Created | Successful POST |
| `207` | Multi-Status | Batch operation with mixed results |
| `400` | Bad Request | Guard violation, validation error, invalid input |
| `401` | Unauthorized | Missing or expired authentication |
| `403` | Forbidden | Access denied (wrong role, condition failed) or firewall blocked it |
| `404` | Not Found | Firewall blocked the record on a resource that set `firewallErrorMode: 'hide'` (or the route doesn't exist) |
| `405` | Method Not Allowed | The path is a real route, but not for this verb — the response carries an `Allow` header listing the methods it does accept |
| `409` | Conflict | Unique constraint hit, or a duplicate [`Idempotency-Key`](/platform/api-contract#idempotency-key) still in flight |
| `412` | Precondition Failed | `If-Match` revision mismatch — [changesets](/define/changesets) and plain single-record `PATCH`/`PUT`/`DELETE` |
| `422` | Unprocessable | `Idempotency-Key` reused with a different request or principal |
| `428` | Precondition Required | `If-Match` sent where it can't anchor: no revision column, a batch endpoint (single-resource precondition), or the POST changeset variant |
| `429` | Too Many Requests | Rate limit exceeded |
| `500` | Internal Error | Uncaught throw — generic body, full error server-side |

## Error Response Format — RFC 9457 Problem Details

Every top-level error response is an
[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem Details document,
served as `application/problem+json` (see
[API Contract](/platform/api-contract)).
Quickback's contextual fields (`code`, `layer`, `details`, `hint`, `request`)
survive as 9457 **extension members**, so clients that switch on `code` keep
working. Every structured error also carries a `request` block with the HTTP
method, URL, request ID, and a redacted+truncated copy of the request body —
enough to triage or hand the response to an LLM agent without grepping logs:

```json
{
  "type": "https://quickback.dev/problems/access-role-required",
  "title": "Access role required",
  "status": 403,
  "detail": "Insufficient permissions",
  "instance": "/api/v1/candidates/cnd_01H.../advance",
  "layer": "access",
  "code": "ACCESS_ROLE_REQUIRED",
  "details": {
    "required": ["hiring-manager"],
    "current": ["interviewer"]
  },
  "hint": "Contact an administrator to grant necessary permissions",
  "request": {
    "method": "POST",
    "url": "/api/v1/candidates/cnd_01H.../advance",
    "body": "{\"stage\":\"offer\"}",
    "requestId": "0c4f2c9e-..."
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `type` | string | Problem type URI — `https://quickback.dev/problems/<code-in-kebab-case>` |
| `title` | string | Human-stable summary derived from the code |
| `status` | number | The HTTP status, repeated in the body |
| `detail` | string | Human-readable error message (optional) |
| `instance` | string | Request path (optional) |
| `layer` | string | Extension — security layer that rejected the request |
| `code` | string | Extension — machine-readable error code (the full enum ships in `openapi.json` as `Problem.code`) |
| `details` | object | Extension — layer-specific context (optional) |
| `pointer` | string | Extension — first offending field for validation failures, e.g. `/stage` (optional) |
| `hint` | string | Extension — actionable guidance for resolution (optional) |
| `request` | object | Extension — method, URL, redacted body, and request ID. Auto-attached by the runtime so the error body is self-contained — bodies are capped at 4 KB and well-known sensitive keys (`password`, `token`, `secret`, `apiKey`, `otp`, `code`, `authorization`, …) are replaced with `"[REDACTED]"` |

The per-layer examples below are abbreviated to `detail` plus each layer's
extension members — every real response also carries `type`, `title`,
`status`, and (where derivable) `instance`.

## Errors by Security Layer

### Authentication (401)

Missing or invalid authentication tokens.

```json
{
  "detail": "Authentication required",
  "layer": "authentication",
  "code": "AUTH_MISSING",
  "hint": "Include Authorization header with Bearer token"
}
```

**Error codes:**

| Code | Description |
|------|-------------|
| `AUTH_MISSING` | No Authorization header provided |
| `AUTH_INVALID_TOKEN` | Token is malformed or invalid |
| `AUTH_EXPIRED` | Token has expired |
| `AUTH_RATE_LIMITED` | Too many auth attempts |
| `AUTH_SESSION_REQUIRED` | The endpoint requires session (cookie) authentication — returned by `POST /api/v1/token` when called with a JWT, API key, or OAuth bearer, so a stolen or revoked token can't mint its own replacement |
| `AUTH_REVOCATION_UNAVAILABLE` | **503, retryable.** Only with [`revocationCheck: 'kv'`](/configure/auth#revocationcheck-none--kv--token-revocation-strategy): the bearer's signature verified, but its revocation state could not be read (KV binding missing or unreachable), so it was not trusted. Retry, or authenticate with a session cookie |

### Firewall (403)

Records outside the user's firewall scope return **403 Forbidden** by default —
`firewallErrorMode` defaults to `'reveal'`:

```json
{
  "detail": "Record not found or not accessible",
  "layer": "firewall",
  "code": "FIREWALL_NOT_FOUND",
  "hint": "Check the record ID and your organization membership"
}
```

Firewall filtering is transparent — the query is scoped by `WHERE organizationId = ?` so inaccessible records simply don't appear in results.

**Error codes:**

| Code | Description |
|------|-------------|
| `FIREWALL_NOT_FOUND` | Record not found behind firewall (wrong org, soft-deleted, or doesn't exist) |
| `FIREWALL_ORG_ISOLATION` | Record belongs to a different organization |
| `FIREWALL_USER_ISOLATION` | Record belongs to another user |
| `FIREWALL_SOFT_DELETED` | Record has been soft deleted |

For security-hardened deployments, set `firewallErrorMode: 'hide'` at the **top
level of the resource definition** (not inside `firewall:` — it was lifted off
when `firewall` became a predicate array) to return opaque **404 Not Found**
responses with `NOT_FOUND` instead. This prevents attackers from distinguishing
between "record exists but you can't access it" and "record doesn't exist".

```typescript title="quickback/features/candidates/candidates.ts"
import { feature, q } from "@quickback/compiler";

export default feature("candidates", {
  columns: {
    id:             q.id(),
    name:           q.text({ maxLength: 200 }).required(),
    organizationId: q.text({ maxLength: 64 }).required(),
    ...q.audit(),
    ...q.softDelete(),
  },
  firewallErrorMode: 'hide',   // top level — default: 'reveal' (403)
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
});
```

### Access (403)

Access violations return **403 Forbidden** when the user's role doesn't match the required roles for the operation.

```json
{
  "detail": "Insufficient permissions",
  "layer": "access",
  "code": "ACCESS_ROLE_REQUIRED",
  "details": {
    "required": ["hiring-manager"],
    "current": ["interviewer"]
  },
  "hint": "Contact an administrator to grant necessary permissions"
}
```

**Error codes:**

| Code | Description |
|------|-------------|
| `ACCESS_ROLE_REQUIRED` | User doesn't have the required role |
| `ACCESS_CONDITION_FAILED` | Record-level access condition not met |
| `ACCESS_OWNERSHIP_REQUIRED` | User must own the record |
| `ACCESS_NO_ORG` | No active organization set |

### Guards (400)

Guard violations return **400 Bad Request** when the request body contains fields that aren't allowed.

```json
{
  "detail": "Field cannot be set during creation",
  "layer": "guards",
  "code": "GUARD_FIELD_NOT_CREATEABLE",
  "details": {
    "fields": ["stage"]
  },
  "pointer": "/stage",
  "hint": "These fields are set automatically or must be omitted"
}
```

**Error codes:**

| Code | Description |
|------|-------------|
| `GUARD_FIELD_NOT_CREATEABLE` | Field not in `createable` list |
| `GUARD_FIELD_NOT_UPDATABLE` | Field not in `updatable` list |
| `GUARD_FIELD_PROTECTED` | Field is action-only (protected) |
| `GUARD_FIELD_IMMUTABLE` | Field cannot be modified after creation |
| `GUARD_SYSTEM_MANAGED` | System field (createdAt, modifiedAt, etc.) |

### Masking

Masking doesn't produce errors — it silently transforms field values in the response.

### Database

Surfaced when a SQL write fails *post-validation* — most often a `NOT NULL` or `UNIQUE` constraint hit at the storage layer (e.g. an FK target was deleted concurrently, or a unique column collided with another tenant's row). All four codes share the same `layer: "database"` so consumers can switch on the layer alone and treat the rest as one bucket.

```json
{
  "detail": "Duplicate value",
  "layer": "database",
  "code": "DB_UNIQUE_VIOLATION",
  "details": { "column": "email" },
  "hint": "A record with this \"email\" already exists"
}
```

**Error codes:**

| Code | Status | Description |
|------|--------|-------------|
| `DB_NOT_NULL_VIOLATION` | 400 | Required column was null at write time |
| `DB_UNIQUE_VIOLATION` | 409 | Unique constraint hit |
| `DB_INSERT_FAILED` | 500 | INSERT failed for an unrecognized reason |
| `DB_UPDATE_FAILED` | 500 | UPDATE failed for an unrecognized reason |

The `details.column` field is only present when the driver's error message identified the offending column (SQLite/D1 do; some PG variants don't).

## Batch Errors

Batch operations can return:
- **200**/**201** — All records succeeded (`201` for create/upsert, `200` for update/delete)
- **207** — Partial success (some records failed)
- **400** — `failFast: true` was set and at least one record failed

### Partial Success (207)

A 207 body is a **success envelope** (plain `application/json`, not
`application/problem+json`) — the per-record `error` objects embedded in it
keep the internal flat shape (`error` message field, `layer`, `code`,
`details`, `hint`):

```json
{
  "success": [{ "id": "app_1", "candidateId": "cand_101", "stage": "applied" }],
  "errors": [
    {
      "index": 1,
      "record": { "candidateId": "cand_102", "jobId": "job_201", "stage": "interview" },
      "error": {
        "error": "Field cannot be set during creation",
        "layer": "guards",
        "code": "GUARD_FIELD_NOT_CREATEABLE",
        "details": { "fields": ["stage"] },
        "hint": "These fields are set automatically or must be omitted"
      }
    }
  ],
  "meta": { "total": 2, "succeeded": 1, "failed": 1, "failFast": false, "transactional": true }
}
```

`meta` always carries exactly `total`, `succeeded`, `failed`, `failFast`, and
`transactional` — there is no `atomic` key. `meta.transactional` tells you
whether you got real rollback or only early-stop semantics; see
[Batch Operations](/api/batch-operations#transactional-semantics).

### Fail-Fast Failure (400)

```json
{
  "detail": "Batch stopped at first error (fail-fast mode)",
  "layer": "validation",
  "code": "BATCH_FAIL_FAST_STOPPED",
  "details": {
    "failedAt": 2,
    "reason": "Not found",
    "errorDetails": { "id": "app_3" }
  },
  "hint": "Records before index 2 were applied and are NOT rolled back. Inspect application state before retrying — re-sending the same batch may double-apply records that already succeeded."
}
```

**Batch error codes:**

| Code | Description |
|------|-------------|
| `BATCH_SIZE_EXCEEDED` | Too many records in a single request |
| `BATCH_FAIL_FAST_STOPPED` | `failFast: true` batch stopped at the first error |
| `BATCH_MISSING_IDS` | Batch update/delete missing required IDs |

## Action Failures (500)

When an action's `execute()` body throws something that isn't a known
transition / state / FK / constraint failure (e.g. a `TypeError`, a
fetch timeout, a third-party API error), the route returns
`ACTION_EXECUTION_FAILED`. The production body is generic: it carries
only the error class name in `details.name` (so callers can branch on
e.g. `TypeError` vs a fetch timeout) — never the underlying message,
cause, or stack frames. The raw error is always logged server-side.

```json
{
  "detail": "Action execution failed",
  "layer": "action",
  "code": "ACTION_EXECUTION_FAILED",
  "details": {
    "name": "TypeError"
  },
  "hint": "The action handler threw an unhandled error. Check server logs for the underlying cause.",
  "request": {
    "method": "POST",
    "url": "/api/v1/notebooks/nb_123/updateContent",
    "body": "{\"content\":\"...\"}",
    "requestId": "0c4f2c9e-..."
  }
}
```

## Internal Errors (500)

Uncaught throws that escape every route handler land in the global
`onError` and return `INTERNAL_ERROR`. Same contract: the production
body is generic, and the full error (message, cause chain, stack) is
logged via `console.error` keyed by the `requestId` in the body — so
the response → log correlation is one grep.

```json
{
  "detail": "Internal error",
  "layer": "database",
  "code": "INTERNAL_ERROR",
  "details": { "operation": "request handling" },
  "hint": "Operation \"request handling\" failed unexpectedly. Check server logs for details.",
  "request": {
    "method": "GET",
    "url": "/api/v1/jobs",
    "requestId": "0c4f2c9e-..."
  }
}
```

### Exposing error details in dev / staging

By default nothing derived from the thrown error reaches the response
body — messages, cause chains, and stack frames can carry SQL, file
paths, or secrets, so they stay server-side. To get verbose bodies
while developing or staging, set `EXPOSE_ERROR_STACK=1` on the worker
(`wrangler.toml` `[vars]`, or `wrangler secret put`):

```toml
[vars]
EXPOSE_ERROR_STACK = "1"
```

When set, the global 500 and `ACTION_EXECUTION_FAILED` bodies forward
the underlying error message plus `details.cause` (cause-chain
message), `details.frames` (top three stack frames with absolute paths
sanitized to basename + line number), and `details.stack` (the full
unsanitized stack trace). Leave it unset in production.
