# Errors

> One error envelope, eleven codes, and a rule that an unauthorized record must never reveal that it exists.

Section: Reference · Source: https://titen.dev/docs/errors
Derived from: https://github.com/RamaAditya49/titen/blob/main/docs/reference/api.md

---
## The envelope

Every failure has the same shape: an `error` object with a stable machine-readable
`code` and a human-readable `message`, plus `meta` carrying at least the request id.

```json
{ "error": { "code": "UNAUTHENTICATED", "message": "Credential is missing or invalid." },
  "meta": { "request_id": "req_f7144027d9d94061a2783ebd32df01e7" } }
```

Branch on `code`, never on `message`. Codes are part of the contract; messages are for
the log, and several interpolate the offending value:
`Missing required scope "claims:write".`, `Resource is leased by another principal
until 2026-07-30T11:02:14.000Z.`

## Request ids

`meta.request_id` is `req_` + 32 lowercase hex, and the same value is returned in an
`x-request-id` header on every response, success or failure. It is generated before
routing, so even a `404` on an unknown path has one.

## Every code

<div class="table-wrap">

| Code | Status | Raised when | What a caller does |
| --- | --- | --- | --- |
| `VALIDATION_ERROR` | 400 | A field is missing, the wrong type, out of range, over a length limit, or the body is empty or not JSON. Also covers illegal state transitions: superseding a claim that is neither `active` nor `disputed`, revoking a `superseded` one, resolving a handoff addressed to someone else. | Fix the request. Never retry unchanged. The message names the field or the current state. |
| `UNRESOLVED_REFERENCE` | 400 | An import references a project or observation that does not exist yet, or a write hits a foreign-key failure. | Import in dependency order — projects, then observations, then claims. The message names the missing record type and up to three example ids. |
| `UNAUTHENTICATED` | 401 | No `Authorization` header, a non-`Bearer` scheme, a token that is not prefixed `titen_sk_`, an unknown key hash, or a revoked key. | Check the credential. A `401` after it previously worked usually means the key was revoked. |
| `FORBIDDEN` | 403 | The credential is valid but lacks the route's scope, tries to assert trust above its ceiling, tries to mint a key with authority it does not hold, or a federation peer is inactive, wrongly directed, or wrongly signed. | Widen the scope or lower the trust deliberately. Not retryable. |
| `NOT_FOUND` | 404 | The path matches no route (`Unknown endpoint.`), or the resource does not exist, belongs to another organization, is `private` and not yours, or is an expired checkpoint. | Treat as absent. Do not infer existence from a `404`. |
| `METHOD_NOT_ALLOWED` | 405 | The path exists but not with that method. The message lists the allowed ones. | Use a listed method. |
| `CONFLICT` | 409 | A unique constraint was violated, an `Idempotency-Key` was reused with a different body, a live lease is held by another principal, a workspace name is taken, a release is published or revoked from the wrong state, or an imported id already exists outside your organization. | Read the message — most conflicts are a real state disagreement, not a transient race. Nothing partial was written. |
| `PAYLOAD_TOO_LARGE` | 413 | The declared `content-length` or the received body exceeds 1,048,576. | Split the request. Export and import are paginated for this reason. |
| `INTERNAL` | 500 | An unexpected failure the service refuses to describe. | Retry once, then report the `request_id`. |
| `NOT_READY` | 503 | `GET /readyz` found the storage check failing or the applied migration count not matching the expected one. | Do not send traffic. The readiness body is in `meta`, so the reason is in the response. |
| `UNAVAILABLE` | 503 | Storage is unreachable, or the embedding model or vector index is unreachable during `POST /v1/index/drain`. | Retryable. On a drain the queue is left untouched, so a later call picks up the same work. |

</div>

## Captured live

Three real responses from a running service. The `405` arrives without a credential:
routing and method matching happen before authentication.

```
$ curl -i "$TITEN_URL/v1/keys"
HTTP/1.1 401 Unauthorized
{"error":{"code":"UNAUTHENTICATED","message":"Credential is missing or invalid."},
 "meta":{"request_id":"req_f7144027d9d94061a2783ebd32df01e7"}}
```

```
$ curl "$TITEN_URL/nope"
{"error":{"code":"NOT_FOUND","message":"Unknown endpoint."},
 "meta":{"request_id":"req_3895034103df464c90749a01d54bfb09"}}
```

```
$ curl -X GET "$TITEN_URL/v1/observations"
{"error":{"code":"METHOD_NOT_ALLOWED","message":"Allowed methods: POST."},
 "meta":{"request_id":"req_1440258be9684a36822415b8a5827987"}}
```

## 401, 403 or 404

<div class="rules">

<p><strong>401</strong> — we do not know who you are. The credential is missing, malformed, unknown or revoked.</p>
<p><strong>403</strong> — we know who you are and this capability is not yours. Scope, trust ceiling, or a peer/webhook in a state that forbids the operation.</p>
<p><strong>404</strong> — we will not say. The record may not exist, may belong to another organization, or may exist and be invisible to you. All three answer identically.</p>

</div>

<div class="callout callout--alarm">
<strong>WHY A FOREIGN ID IS NEVER 403</strong>

A `403` on a record id confirms that the id is real. Distinguishing "no such claim"
from "that claim is not yours" hands an attacker an oracle for enumerating another
tenant's identifiers. Every cross-scope denial funnels through one indistinguishable
`404`, and the Atlas view compiler applies the same rule to graph topology, so a claim
or observation you may not see contributes no node, edge or label to the projection.
</div>

`GET /v1/claims/:id/evidence` applies the same rule: evidence you may not read is
reported as a `hidden_source_count`, never as content.

## Codes that carry extra meta

Two errors put non-sensitive detail in `meta` alongside the request id.

<div class="kv">
<strong>DETAIL IN META</strong>

<p><code>NOT_READY</code> the whole readiness body: <code>ready</code>, <code>runtime</code>, <code>revision</code>, <code>schema</code> with applied and expected counts, <code>checks</code>, <code>capabilities</code>.</p>
<p><code>UNAVAILABLE</code> on a drain: <code>pending</code> (how many outbox rows are still queued) and <code>retryable: true</code>.</p>

</div>

## When the failure is not one Titen raised

Anything that is not a recognised error becomes `500 INTERNAL` with the fixed message
"Request could not be completed." Only the error's *name* is logged, next to the
request id, never its message: a raw database message can carry SQL fragments or
observation content.

Three storage failures are recognised on the way out and translated:

<div class="table-wrap">

| Underlying failure | Becomes | Why not something else |
| --- | --- | --- |
| `FOREIGN KEY constraint failed` | `400 UNRESOLVED_REFERENCE` | A missing dependency, not a conflict. Saying "conflicted with an existing record" about a record that does not exist sends the operator looking for the wrong thing. |
| `UNIQUE` / other constraint failure | `409 CONFLICT` | The write lost a race with an existing row. Every write is one atomic batch, so nothing partial survives. |
| no such table, unable to open database, readonly database | `503 UNAVAILABLE` | The deployment is broken, not the request. Retrying the same request later can succeed. |

</div>

## Next

<div class="cards">

<a href="/docs/api"><strong>HTTP API</strong><span>Which routes raise which of these, with scopes and limits.</span></a>
<a href="/docs/keys-scopes"><strong>Keys &amp; scopes</strong><span>The 34 scopes and the trust ceiling behind most 403s.</span></a>
<a href="/docs/data-model"><strong>Data model</strong><span>The state machines behind the 409s and transition rejections.</span></a>

</div>