# 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 | Record doesn't exist (or firewall blocked with `errorMode: 'hide'`) |
| `429` | Too Many Requests | Rate limit exceeded |

## Error Response Format

All errors use a flat structure with contextual fields. Every structured error response 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
{
  "error": "Insufficient permissions",
  "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 |
|-------|------|-------------|
| `error` | string | Human-readable error message |
| `layer` | string | Security layer that rejected the request |
| `code` | string | Machine-readable error code |
| `details` | object | Layer-specific context (optional) |
| `hint` | string | Actionable guidance for resolution (optional) |
| `request` | object | 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]"` |

## Errors by Security Layer

### Authentication (401)

Missing or invalid authentication tokens.

```json
{
  "error": "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 |

### Firewall (403)

Records outside the user's firewall scope return **403 Forbidden** by default with a structured error:

```json
{
  "error": "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 `errorMode: 'hide'` in your firewall config to return opaque **404 Not Found** responses instead. This prevents attackers from distinguishing between "record exists but you can't access it" and "record doesn't exist".

### Access (403)

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

```json
{
  "error": "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
{
  "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"
}
```

**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
{
  "error": "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:
- **201** — All records succeeded
- **207** — Partial success (some records failed)
- **400** — Atomic mode and at least one record failed (all rolled back)

### Partial Success (207)

```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, "atomic": false }
}
```

### Atomic Failure (400)

```json
{
  "error": "Batch operation failed in atomic mode",
  "layer": "validation",
  "code": "BATCH_ATOMIC_FAILED",
  "details": {
    "failedAt": 2,
    "reason": { "error": "Not found", "code": "NOT_FOUND" }
  },
  "hint": "Transaction rolled back. Fix the error and retry the entire batch."
}
```

**Batch error codes:**

| Code | Description |
|------|-------------|
| `BATCH_SIZE_EXCEEDED` | Too many records in a single request |
| `BATCH_ATOMIC_FAILED` | Atomic batch failed, all changes rolled back |
| `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 body forwards as much of the underlying
error as is safe to expose: error class name, message, cause-chain
message, and the top three stack frames with absolute paths sanitized
to basename + line number.

```json
{
  "error": "Cannot read properties of undefined (reading 'getXmlFragment')",
  "layer": "action",
  "code": "ACTION_EXECUTION_FAILED",
  "details": {
    "name": "TypeError",
    "frames": [
      "at updateContent (notebooks.ts:142:31)",
      "at execute (actions.ts:88:7)"
    ]
  },
  "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`. The body carries the same
underlying-error context as `ACTION_EXECUTION_FAILED`: `name`, `cause`,
sanitized `frames`, and the request block.

```json
{
  "error": "Connection terminated unexpectedly",
  "layer": "database",
  "code": "INTERNAL_ERROR",
  "details": {
    "name": "Error",
    "cause": "ECONNRESET",
    "frames": ["at query (db.ts:54:12)"]
  },
  "hint": "Uncaught error during request handling. Look up the requestId in server logs for the full stack trace, or set EXPOSE_ERROR_STACK=1 to include it inline.",
  "requestId": "0c4f2c9e-...",
  "request": {
    "method": "GET",
    "url": "/api/v1/jobs",
    "requestId": "0c4f2c9e-..."
  }
}
```

### Exposing the full stack

By default the response body only carries the top three sanitized
frames — full stack traces stay server-side so the worker bundle's
on-disk layout doesn't leak to API clients. To include the full stack
in the response (useful in dev / staging), set `EXPOSE_ERROR_STACK=1`
on the worker (`wrangler.toml` `[vars]`, or `wrangler secret put`):

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

When set, both the global 500 and `ACTION_EXECUTION_FAILED` bodies
gain a `details.stack` field with the unsanitized stack trace.
