Errors
One error envelope, eleven codes, and a rule that an unauthorized record must never reveal that it exists.
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.
{ "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
| 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. |
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
401 — we do not know who you are. The credential is missing, malformed, unknown or revoked.
403 — 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.
404 — 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.
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.
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.
NOT_READY the whole readiness body: ready, runtime, revision, schema with applied and expected counts, checks, capabilities.
UNAVAILABLE on a drain: pending (how many outbox rows are still queued) and retryable: true.
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:
| 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. |