> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and conventions

> The error response contract, meaningful 409s, 401 vs 403, job-status polling shapes, idempotency, and pagination (or its deliberate absence).

This page documents the conventions that hold across every route on the product API (`services/api/product_app.py`), so you don't have to re-derive them per endpoint. Individual request/response schemas live on the auto-generated endpoint pages under **Endpoints** in the sidebar.

## The error shape

Every error response on this API is FastAPI's default:

```json theme={null}
{"detail": "<string or structured detail>"}
```

`detail` is usually a plain string suitable for showing directly to a user (`"Invalid workspace, email, or password."`, `"CAP not found."`), but a handful of routes return a structured object instead — for example the legacy connector-activation path returns `{"detail": {"status": "not_enabled", "message": "..."}}` (see [not-yet-enabled connectors](#not-yet-enabled-connectors-a-real-409-example) below). There is no separate `error_code` field, no envelope wrapper, and no `errors` array on the product API — just `detail`, whose shape varies only in that one documented case.

## `422` — validation errors

A request body that fails Pydantic validation (wrong type, missing required field, value outside an `enum`/`Literal`) never reaches your route handler. FastAPI returns `422` with the standard `HTTPValidationError` shape, visible in every generated endpoint page:

```json theme={null}
{
  "detail": [
    {
      "loc": ["body", "slug"],
      "msg": "String should have at least 3 characters",
      "type": "string_too_short",
      "input": "ab"
    }
  ]
}
```

`loc` is a path into the request (`["body", "<field>"]` for a JSON field, `["path", "<param>"]` for a path parameter, `["query", "<param>"]` for a query parameter). A handful of routes also raise `422` deliberately from handler code for domain-level shape problems that Pydantic can't express as a type — e.g. `POST /admin/tenants` re-validates the slug with `validate_tenant_slug()` and raises `422` with a plain-string `detail` (not the list shape) if it fails a rule Pydantic's `Field(min_length=3, max_length=63)` doesn't cover (the slug's character-set regex — lowercase alphanumeric and hyphens only, no leading/trailing hyphen). Don't assume every `422` has the list-shaped `detail` — check whether it came from Pydantic (list) or a handler's own `HTTPException(422, ...)` (string) before parsing it programmatically.

## `409` — meaningful conflicts, not generic failures

A `409` on this API always means "the request is well-formed and you're allowed to make it, but the resource's current state makes it invalid right now" — never a catch-all for unexpected errors. Three recurring shapes:

### Duplicate identity

```bash theme={null}
POST /admin/tenants
{"slug": "acme", ...}
# -> 409 {"detail": "Tenant slug 'acme' already exists."}
```

Checked explicitly in `onboarding_api.py`'s `create_tenant()` before the insert, against the `control.tenants.slug` uniqueness the database also enforces — the API-level check exists to return this message instead of surfacing a raw database constraint violation as a `500`.

### Lifecycle-state conflicts

```bash theme={null}
POST /admin/tenants/{tenant_id}/provision
# -> 409 {"detail": "Tenant is 'live'; provisioning only applies to draft/onboarding tenants."}
```

`provision()` only accepts tenants whose `lifecycle_state` is `draft`, `provisioning`, or `onboarding`. The same pattern recurs elsewhere on the onboarding lifecycle:

* `POST /admin/tenants/{tenant_id}/go-live` — `409` listing exactly which prerequisites are missing (`"Cannot go live yet: historical data ingested, a model checkpoint activated, at least one user invited."`), checked against real state (a `Dataset` row exists, a `ModelCheckpoint` is active, a pending/accepted `TenantInvitation` exists) rather than a stored flag.
* `POST /admin/tenants/{tenant_id}/train` — `409 {"detail": "No ingested dataset to train on -- complete the ingest step first."}` if no `Dataset` exists yet.
* `POST /admin/tenants/{tenant_id}/impersonate` — `409` if the tenant isn't `live`, or has no active user to impersonate.
* `POST /sources/{source_id}/upload` — `409 {"detail": "Source is kind 'postgres', not file_upload."}` if you point the file-upload endpoint at a source registered as a different connector kind.

### Optimistic-concurrency conflicts

CAP and review mutations require the caller to prove they saw the current state before writing:

```bash theme={null}
PATCH /remediation/caps/{cap_id}
{"expected_version": 3, "status": "approved"}
# -> 409 {"detail": "This CAP changed since you loaded it — refresh and retry."}
```

`update_cap()` in `remediation_api.py` conditions its `UPDATE` on both `Cap.id` **and** `Cap.version == expected_version`; if another writer already bumped the version, the conditional update matches zero rows and the route returns `409` rather than silently overwriting the other writer's change. `PATCH /remediation/reviews/{review_id}` uses the same idea without an explicit version number — the `UPDATE` is conditioned on `Review.decision IS NULL`, so a second decision on an already-decided review is a `409 {"detail": "This review has already been decided."}`, by design: "two reviewers cannot silently replace each other's audit decision" (from the route's own docstring).

### Not-yet-enabled connectors (a real `409` example)

The legacy `/clients/*` onboarding API (`services/api/clients_api.py`, part of the research surface — see [Research API](/api-reference/research-api)) registers four connector kinds but only actually implements one: `file_upload`. Attempting to activate `postgres`, `s3_dump`, or `kafka_pubsub` raises `ConnectorNotEnabledError` (`packages/causegraph/src/causegraph/onboarding/connectors.py`), caught and surfaced as:

```json theme={null}
409 {"detail": {"status": "not_enabled", "message": "Connector 'postgres' is registered but not yet enabled in this runtime."}}
```

This is the canonical shape for a documented stub: the connector kind is real, visible, and schema-validated, but deliberately not wired to live infrastructure yet. Don't build a UI or integration against a connector kind assuming it works end-to-end just because it appears in a registry response — check `enabled` (or expect this `409`) first.

## `404` — resource doesn't exist, route doesn't exist, or you're not allowed to know which

Two distinct causes produce an identical-looking `404`, and the API deliberately does not distinguish them in the response:

1. **Genuine resource-not-found**: `GET /admin/tenants/{tenant_id}` for a UUID with no matching row → `404 {"detail": "Tenant not found."}`. Every `_tenant_record_or_404()`-style lookup across `onboarding_api.py`, `remediation_api.py`, and `sources_api.py` follows this pattern with a resource-specific message (`"CAP not found."`, `"Review not found."`, `"Unknown source."`, `"Unknown upload: ...".`).
2. **Route not on the allowlist at all**: any path/method not in `product_app.py`'s `*_OPERATIONS` frozensets (see [API reference overview](/api-reference/overview#the-allowlist-philosophy)) never gets a FastAPI route registered, so it falls through to Starlette's default `404 {"detail": "Not Found"}`.

There's no way to tell the two apart from the response alone (both are HTTP 404 with a `detail` string), which is intentional: a route the product intentionally doesn't expose should be indistinguishable from a resource that doesn't exist, not leak "this endpoint exists but isn't for you."

## `401` vs `403`

These are not interchangeable, and the API is consistent about which one it uses:

* **`401`** — authentication itself failed or is absent: no session cookie, an expired/revoked session, or (at login) a wrong email/password/workspace combination. Always thrown by `get_current_employee()` or `get_current_tenant_user()`, or by the login handlers themselves.
* **`403`** — you *are* authenticated, but this specific action isn't allowed: a CSRF token missing or mismatched (`require_csrf`), an employee's `control_role` not in the route's allowed set (`require_employee_role`), or a tenant user's permission set missing the route's required permission (`require_permission`). The message names what was required: `"Requires one of ('onboarding_admin',), has 'support_readonly'."` / `"Missing required permission 'caps.write'."`

If you're debugging an unexpected error, check which one you got before assuming a bug — a `403` on a route you can normally hit usually means a role/permission gap or a missing/stale CSRF header, not a broken session.

## Job-status polling

Long-running operations are modeled as durable rows in `control.platform_jobs` rather than synchronous responses: `provision_tenant`, `train_tenant_model`, `materialize_insights`, and `send_email` — the last two are triggered internally and aren't polled directly. The two the API exposes for direct polling share one status vocabulary:

```mermaid theme={null}
stateDiagram-v2
    [*] --> not_started: no job row exists yet
    not_started --> queued: POST .../provision or .../train
    queued --> running: worker claims the row (FOR UPDATE SKIP LOCKED)
    running --> succeeded
    running --> failed: attempts exhausted
    running --> queued: transient failure, retried
    failed --> queued: retry_terminal request re-queues it
    succeeded --> [*]
    failed --> [*]
    cancelled --> [*]
```

`not_started` is synthetic — it's what the status route returns when no `PlatformJob` row exists yet for that tenant+job-type pair, not a real stored value. Every other value (`queued`, `running`, `succeeded`, `failed`, `cancelled`) is the literal contents of `PlatformJob.status`.

```bash theme={null}
POST /admin/tenants/{tenant_id}/provision       # 202 {"status": "queued", "job_id": "..."}
GET  /admin/tenants/{tenant_id}/provision/status
# -> {"status": "running", "progress": 40, "steps_completed": [...], "error_detail": null}

POST /admin/tenants/{tenant_id}/train           # 202 {"status": "queued", "job_id": "..."}
GET  /admin/tenants/{tenant_id}/train/status
# -> {"status": "succeeded", "progress": 100, "error_detail": null}
```

There is no webhook or push notification for job completion — the console polls the `*/status` route. `error_detail` is `null` until a job fails, at which point it holds a human-readable failure reason.

## Idempotency

The API's idempotency guarantee is built on one mechanism used consistently everywhere durable work is enqueued: `enqueue_platform_job()` (`services/pipeline/jobs.py`) inserts with `ON CONFLICT (idempotency_key) DO NOTHING` and, on conflict, looks up and returns the **existing** job's id instead of erroring or duplicating work. Callers construct keys that encode exactly what makes a request a duplicate:

* `POST /admin/tenants/{tenant_id}/provision` → key `provision:{tenant_id}` — calling it twice while a provision job is already queued/running returns the same `job_id`, not a second job.
* `POST /admin/tenants/{tenant_id}/train` → key `train:{tenant_id}:{dataset_id}:{config_hash}` — retraining the *same* dataset with the *same* training config resolves to the same job; changing either produces a new one. Passing `retry_terminal=True` (used here) additionally re-queues an existing job that had reached `failed`/`cancelled`, rather than treating it as permanently resolved.
* `POST /admin/tenants/{tenant_id}/ingest/commit` → the underlying `SourceUpload` row's `status` is checked first: committing an already-`committed` upload returns the previously-created dataset's summary verbatim instead of re-processing it; an upload that previously `failed` validation returns `409` rather than silently retrying.

Outside the job queue, `POST /remediation/caps` has its own idempotent-adopt behavior: creating a CAP against a target that already has a non-terminal CAP with the same problem statement (or title, if no problem statement was given) returns the **existing** CAP verbatim — no duplicate row, no audit log entry, since nothing changed. This exists specifically because a UI "Adopt as plan" action gives no inline feedback on success, so a double-click used to create duplicate CAPs before this check existed.

There is no generic `Idempotency-Key` request header on this API — idempotency is handled per-route, using domain-meaningful keys derived from the request's own content, not a client-supplied token.

## Pagination

Most list endpoints on this API return their full result set in one response, unpaginated: `GET /admin/tenants`, `GET /sources`, `GET /members`, `GET /remediation/caps`, `GET /remediation/reviews`, `GET /admin/tenants/{tenant_id}/checkpoints`, `GET /admin/tenants/{tenant_id}/invitations`. This is a deliberate simplicity trade-off appropriate to per-tenant result sizes (a tenant's member list, source list, or CAP backlog), not an oversight — there is no `page`/`cursor`/`next` convention anywhere on this API to be consistent with.

The one exception is `GET /remediation/audit-log`, which accepts a `limit` query parameter (default `50`, clamped server-side to the range `1`–`200`) but still has no cursor or offset — it's a "most recent N" cap, not true pagination. If you need the full history beyond 200 rows, use `GET /remediation/export.xlsx` instead, which is unbounded.

`GET /remediation/caps` and `GET /remediation/reviews` both accept optional `target_type`/`target_id` query parameters to filter to one entity's records rather than paginating the whole tenant's list.

## Related

* [API reference overview](/api-reference/overview)
* [Authentication](/api-reference/authentication)
* [Research API](/api-reference/research-api)
* [Workers and jobs](/architecture/workers-and-jobs)
