Error taxonomy
Status: design draft for review · decided, not built
This page declares the typed, fail-closed error taxonomy of the HTTP API (D13).
Every refusal the service makes is one of the codes below — a closed vocabulary: a new refusal
reason is a design change, never an ad-hoc string. The machine-readable form is the
ErrorCode enum in openapi.yaml; the two lists are identical by construction, and
a code appearing in one but not the other is a documentation bug.
Three properties hold for every error:
- Typed.
code is machine-stable; clients branch on it, never on message.
- It names its rule. The
rule field states the design rule that refused the request, in
words — an agent that is told why can correct course; one that is told "403" retries forever.
- Fail closed. Where the service cannot establish that an act is permitted, the act does not
happen and the refusal says which rule could not be satisfied. No refusal is ever downgraded to
a silent no-op, and no read ever reveals what the key may not see — absence and refusal-to-admit
are the two honest answers.
The envelope
Every non-2xx response under /v1:
{
"error": {
"code": "access.verb_ceiling",
"message": "a writer key cannot transition a workarea",
"rule": "R1: the surface an agent can call never promotes, signs, demotes or deletes.",
"details": {"verb": "POST /v1/workareas/{id}/transitions", "role": "writer"},
"retryable": false
},
"request_id": "req_01J…"
}
| Field |
Meaning |
code |
One of the codes below, family.name. Stable. |
message |
Human-readable instance description. Unstable; never parsed. |
rule |
The design rule the refusal names, quoted or paraphrased. Stable per code. |
details |
Code-specific structured fields (listed per code where they matter). |
retryable |
true only where retrying the identical request can succeed later. Retryable responses carry a Retry-After header (seconds). |
request_id |
Mirrors the X-Request-Id response header, for correlating with the read log and support. |
auth — who is calling
| Code |
HTTP |
Fires when |
The rule it names |
auth.missing |
401 |
No Authorization: Bearer header. |
Every /v1 request authenticates; only /healthz and /readyz do not. |
auth.invalid |
401 |
The key is unknown, malformed, expired or revoked (details.reason says which). |
Tenant and identity derive from the key, server-side, or not at all — an unverifiable caller gets nothing (D6). |
access — what this key may do
| Code |
HTTP |
Fires when |
The rule it names |
access.verb_ceiling |
403 |
A key's role may never call this verb: any agent key (reader/writer/dreamer) calling an admin verb — re-parent, keys, grants, bans, simulations, reassign, demote, erase, tip-override, export, settings (a reader or writer key proposing a status transition is also this code; a dreamer proposing or ratifying one is not — transitions are dreamer-or-admin verbs, D8); an admin key posting a verdict (admin ≠ reviewer); an operator key touching content. |
R1: the surface an agent can call never promotes, signs, demotes or deletes — the verb ceiling is per role (D9), and no claimed purpose lifts it. |
access.grant_refused |
403 |
The key can read the resource but its effective access — grants minus denies, at the current permission epoch — does not confer the verb's role on this workarea. |
Effective access is the union of grants, each attenuated by its denies, deny-wins for global denials; what a grant does not confer, the key does not have (D7). |
access.not_found |
404 |
The resource does not exist, or lies outside the key's effective read scope, or is a simulation row and the key is outside that simulation's audience. Indistinguishable by design. |
Reads fail closed as absence: the service never confirms the existence of what a key may not see (D7, D10, R3). |
access.author_only |
403 |
A writer key proposes a supersession of a claim it did not author. |
A writer proposes supersessions of its own claims only; editing another author's claim is the dreamer's edit-as-proposal path (D9). |
access.issuer_ceiling |
403 |
Key issuance (or a grant on it) requests a role above the issuer's; an operator key issues anything beyond a tenant's first admin key. |
A key is issued with a role no higher than the issuer's (D9); authority never amplifies through issuance. |
access.role_exclusive |
403 |
Key issuance or a grant amendment would combine the admin role with a dreamer-class grant on one key. |
Admin is never a reviewer (D9): a key never combines admin with dreamer verbs. A person holding both duties holds two keys, so the journal shows which capacity acted (roles-and-keys). |
access.banned |
403 |
Admission-time check: the submitting principal or source is under an active ban. |
Bans are journalled admin acts checked at every admission from the moment they are imposed (D9); lifting one is an equally journalled act. |
request — the request itself
| Code |
HTTP |
Fires when |
The rule it names |
request.validation |
400 |
Malformed JSON, missing or ill-typed fields, unknown enum values, a stale or foreign pagination cursor. |
The contract is openapi.yaml; what it does not admit is refused, not coerced. |
request.idempotency_key_required |
400 |
A state-creating POST arrives without Idempotency-Key. |
Every side-effecting request is replayable exactly once by construction (D13); the service refuses to guess whether a retry is a duplicate. |
request.payload_too_large |
413 |
Body or part exceeds the admitted size. |
Size bounds are admission policy, enforced before bytes are stored. |
idempotency — the replay contract
Semantics in full on the HTTP API page; the record is
(key_id, workarea, idem_key) → request digest + state + stored response, claimed before any side
effect (D13).
| Code |
HTTP |
Fires when |
The rule it names |
idempotency.in_progress |
409 |
A replay arrives while the record is in_progress — the first attempt has not committed or failed yet. Retryable, with Retry-After. |
The record is claimed in its own transaction before side effects, so two racing attempts can never both execute (D13). |
idempotency.digest_mismatch |
409 |
Same (key, workarea, idem_key), different request digest. |
An idempotency key names exactly one request; replay returns the stored response, a different request under the same key is refused — never silently honored as new work (D13). |
A successful replay is not an error: it answers the stored response with header
Idempotency-Replay: true.
submission — admission refusals
| Code |
HTTP |
Fires when |
The rule it names |
submission.scan_veto |
422 |
The deterministic scan, running in the same call as capture, vetoes the submission (details.finding names the rule class that fired). The veto is journalled as a rejection. |
The scan is rules, not a model: same input, same verdict, reproducible from the journalled rule-set version — and it can veto but never promote (D11; admission). |
submission.attachment_missing |
422 |
The submission binds an attachment digest with no completed upload session owned by this key. |
The claim and its files are one signed unit; the envelope binds only bytes the service already holds and has digest-verified (D3). |
submission.simulation_id_required |
422 |
derives_from touches a simulation row but the submission does not carry that row's simulation_id. |
Taint propagates and nesting is forbidden: a derivation from a simulation row carries that simulation's id, server-enforced, so one end-act demotes the closure (D10). |
submission.lineage_required |
422 |
A verb: "learn" submission arrives with an empty or omitted derives_from. |
learn asserts a derived claim, so its lineage is required, never optional; a derived conclusion whose author cannot or will not name lineage is submitted as remember with kind: derived_fact (http-api). |
submission.tip_conflict |
409 |
A supersession's target is not the current served tip — at submission, or re-checked at promotion when a competing supersession won in between. |
Supersession promotion CAS-checks its target is the current tip; the tip flip is one journalled fact, never a lost update (postgres-schema). |
submission.claim_key_conflict |
409 |
A new claim's claim_key collides with a non-erased claim in the same workarea; or a reassignment would collide at its destination without recording a re-key. |
claim_key is unique per (tenant, workarea) over non-erased rows; a colliding move is refused unless the journalled move event records the re-key (D7). |
submission.universe_refused |
409 |
A claim write addresses the tenant's universe root. |
Universe is permanently committed governance structure and refuses direct claim writes — claims live in crew workareas (D7). |
workarea — structure and status refusals
| Code |
HTTP |
Fires when |
The rule it names |
workarea.frozen |
409 |
A validated workarea is asked to accept what freeze forbids: a new write, a promotion into it, a reassignment into it, a new child under it, or a re-parent into it. |
validated is frozen: the only mutations it admits are demote, erasure, and crew-metadata update, each journalled (D8). |
workarea.transition_refused |
409 |
A backward transition is requested (none exist); a rung is skipped (validated proposed from development); validated is proposed while a child is not validated; any transition is proposed on universe. |
The status ladder moves forward only — development → committed → validated — and an area becomes validated only when all its children are (D8). |
The two-party ceremony around transitions — proposer plus a second, non-proposing ratifier — is
refused through review.ratifier_is_proposer below, because it is the same independence rule.
review — independence and the serve binding
| Code |
HTTP |
Fires when |
The rule it names |
review.author_in_lineage |
403 |
review/next would serve, or a verdict/ratification/reopen would count, for a key or principal that appears anywhere in the row's derivation lineage closure — pending and ratified edges, computed at that moment. The original author reopening its own rejection is this code. |
R2: the reviewer is never the author — enforced at promotion time against the author and the lineage closure, not just at key issuance. "I edited an ancestor" disqualifies exactly as "I wrote it" does (roles-and-keys). |
review.ratifier_is_proposer |
403 |
The key ratifying a workarea edge, claim edge, or status transition is the key or principal that proposed it. |
R2 extends to edges and transitions: proposal and ratification are two identities or they are nothing (D7, D8). |
review.serve_invalid |
409 |
A verdict names a serve that is expired, already consumed by its first verdict, bound to a different reviewer key, or invalidated because the served content changed (details.reason ∈ expired | consumed | reviewer_mismatch | content_changed | unknown). |
A verdict counts only for exactly what was served: the serve is bound server-side to (reviewer key, artifact version, attachment digest set), and the journalled verdict records the served digests (D14; security). |
simulation — the fence
Reads of simulation rows by non-audience keys are access.not_found — the fence hides, it does
not advertise. The codes below fire where the caller has already named the simulation.
| Code |
HTTP |
Fires when |
The rule it names |
simulation.audience_refused |
403 |
A key outside the simulation's journalled audience writes with that simulation_id, or posts a verdict on one of its rows. |
Simulation rows are written, read and reviewed only by keys in the audience; a simulation with no dreamer in its audience can promote nothing — fail closed (D10). |
simulation.ended |
409 |
A write or verdict names a simulation that has ended. |
Ending a simulation is a journalled act that demotes its rows; nothing accretes to a closed channel (D10). |
simulation.taint_refused |
409 |
A promotion or reassignment would place a simulation row, or a claim whose lineage closure touches one (sim-derived), into a validated workarea. |
Taint propagates and simulation rows never enter validated workareas (D10). |
simulation.tip_refused |
409 |
A promotion, supersession ratification or tip-override would make a provenance: simulation row or sim-derived claim the default served tip of a claim lineage. |
Taint never captures the default tip: tainted versions shadow the tip only for the simulation's audience as an overlay tip; the default tip of a real claim is bit-for-bit indifferent to every simulation (D10). |
upload — attachment sessions
| Code |
HTTP |
Fires when |
The rule it names |
upload.session_invalid |
409 |
A part or complete addresses a session that is expired, already complete, or aborted (details.reason says which). |
A retry resumes or restarts the session — the behavior is the session state machine's, not improvised per client (D3). |
upload.digest_mismatch |
422 |
At complete, the assembled bytes do not hash to the session's expected_digest. |
An attachment is named by the SHA-256 of its plaintext and bound into the signed envelope by that digest; bytes that are not what was promised are never stored as if they were (D3). |
lifecycle — erased content
| Code |
HTTP |
Fires when |
The rule it names |
lifecycle.erased |
410 |
A fetch addresses an artifact whose DEK was destroyed by a journalled erase. |
R4: no silent deletion — erasure is a journalled crypto-shred scoped to body and attachment bytes; the journal trail and attribution remain and the response says erased, never not found (D4). |
410 deliberately differs from 404: erasure is an attested act with an inventory, visible to
anyone who could read the artifact before. What a key could never see stays access.not_found.
limits
| Code |
HTTP |
Fires when |
The rule it names |
limit.rate |
429 |
The key or tenant exceeds its journalled rate-limit grant attribute. Retryable, with Retry-After. |
Rate limits are journalled grant attributes (D6). v1 enforcement is per-replica token buckets at limit/N — a documented approximation, FR-8; a burst may transiently pass, a refusal is always honest. |
limit.quota |
429 |
A journalled quota attribute (e.g. a storage or submission budget) is exhausted. Retryable only where the budget replenishes; details.quota names the attribute. |
Quotas are journalled grant attributes, so every enforcement decision is reproducible from the journal (D6). |
export, settings
| Code |
HTTP |
Fires when |
The rule it names |
export.not_ready |
409 |
The bundle is fetched before the export job reaches done. |
An evidence export is an async, journalled job; the bundle exists only when its chain verification has finished (audit-and-lineage). |
settings.retired_key |
422 |
A settings document names a retired settings key (details.key, details.replacement). |
Retired keys are refused by name, with the setting that replaced them — silently ignoring unknown keys is how configuration rots (D14). |
settings.invalid |
422 |
settings/apply finds any validation error. |
Apply is validate-then-journal-then-apply, refused whole on any error; there is no force flag (D14). |
system
| Code |
HTTP |
Fires when |
The rule it names |
system.internal |
500 |
A defect. request_id is the handle for the operator. |
— |
system.unavailable |
503 |
The replica cannot serve safely: Postgres unreachable, or writes are refused because the startup fork check has not verified the stored chain head against the last signed head (details.reason). Retryable. |
A replica that cannot prove its journal head accepts no writes (D1); /readyz reports the same condition to the orchestrator (deployment). |
Review unavailability — a workarea whose policy cannot currently be satisfied by any eligible
reviewer — is deliberately not an API error: submissions still land held, wait, and expire
per the policy's held_item_expiry (D11 fails closed by holding, not by refusing). The condition
is surfaced by oml status / oml doctor, not by this taxonomy.
Status-code index
| HTTP |
Codes |
| 400 |
request.validation, request.idempotency_key_required |
| 401 |
auth.missing, auth.invalid |
| 403 |
access.verb_ceiling, access.grant_refused, access.author_only, access.issuer_ceiling, access.role_exclusive, access.banned, review.author_in_lineage, review.ratifier_is_proposer, simulation.audience_refused |
| 404 |
access.not_found |
| 409 |
idempotency.in_progress, idempotency.digest_mismatch, submission.tip_conflict, submission.claim_key_conflict, submission.universe_refused, workarea.frozen, workarea.transition_refused, review.serve_invalid, simulation.ended, simulation.taint_refused, simulation.tip_refused, upload.session_invalid, export.not_ready |
| 410 |
lifecycle.erased |
| 413 |
request.payload_too_large |
| 422 |
submission.scan_veto, submission.attachment_missing, submission.simulation_id_required, submission.lineage_required, upload.digest_mismatch, settings.retired_key, settings.invalid |
| 429 |
limit.rate, limit.quota |
| 500 |
system.internal |
| 503 |
system.unavailable |