# Brain Memory API — every error code

**Date:** 2026-07-27 · Source of truth for `https://docs.brain.new/errors/{code}`.

Every failure the API can produce returns the same body, and that body carries a `docs_url` built
from its `code`. **This page is what that URL resolves to** — one section per code, anchored at
`#<code>`. A code that is raised and not documented here fails
`.venv/bin/python scripts/check_error_docs.py`, so the link cannot rot back into a 404.

An error page that only restates the failure is worthless. Each section below names **the fix**.

---

## One shape, always

```json
{
  "type": "conflict",
  "code": "stale_version",
  "message": "current version is 1",
  "request_id": "req_01KYJDX64GD6N69K4A5QN649FC",
  "docs_url": "https://docs.brain.new/errors/stale_version",
  "current_version": 1
}
```

- **`type`** — the class of failure. Branch on this. Eight are declared in the contract
  (`invalid_request · authentication · permission · not_found · conflict · rate_limit · policy ·
  internal`), and all eight are reachable.
- **`code`** — the exact reason. Log this. It is stable; a code is never repurposed.
- **`message`** — for a human, and it names the offending field or id. Never parse it.
- **`request_id`** — quote it in a support request. It identifies this one call.
- Some codes add **one extra field** (`stale_version` adds `current_version`). Extra fields are
  additive; ignore ones you do not know.

There is no second error shape. FastAPI's default `{"detail": ...}` is mapped away in
`memory_api/app.py`, because the most common failure an integration hits must not be the one
response a generated SDK cannot parse.

---

## Should I retry?

| `type` | Retry? | What to do |
|---|---|---|
| `invalid_request` | **No** | The request is wrong. Retrying sends the same wrong request. |
| `authentication` | **No** | Fix the credential. |
| `permission` | **No** | Use a key that carries the scope. |
| `not_found` | **No** | Create it, or stop using a stale id. Do not poll a 404 into existence — except `memory_not_found` while a Record is still `processing`. |
| `conflict` | **Once**, after re-reading | Re-read, re-apply on top of what you read, retry. Never by stripping `if_version`. |
| `rate_limit` | **Yes** | Exponential backoff. |
| `internal` | **Once** | Reads are safe. `remember` is safe **if you resend the same `Idempotency-Key`** — it is the write path's whole retry story. |

---

## Every code

| Code | HTTP | `type` | Means | Do this |
|---|---|---|---|---|
| [`invalid_api_key`](#invalid_api_key) | 401 | `authentication` | The credential is missing or malformed | Send `Authorization: Bearer sk_<test\|live>_<project>_<secret>` |
| [`key_revoked`](#key_revoked) | 401 | `authentication` | This key was revoked in the key registry | Mint a new key; a revoked one never returns |
| [`key_registry_unreadable`](#key_registry_unreadable) | 500 | `internal` | The key registry exists but could not be read | Fix or remove `BRAIN_KEYS_FILE`; every key is refused until then |
| [`missing_scope`](#missing_scope) | 403 | `permission` | The key is valid but narrowed | Use a key carrying that scope |
| [`mcp_brain_unbound`](#mcp_brain_unbound) | 403 | `permission` | The key is not bound to one Brain for hosted MCP | Mint a replacement with `--brain` |
| [`mcp_identity_unconfigured`](#mcp_identity_unconfigured) | 401 | `authentication` | Local stdio has no explicit project or Brain | Set both local MCP environment values |
| [`invalid_request_body`](#invalid_request_body) | 400 | `invalid_request` | A body or typed query field failed validation | Read the field name in `message`, fix that field |
| [`unknown_query_parameter`](#unknown_query_parameter) | 400 | `invalid_request` | A query parameter the contract does not declare | Remove it — or move it into the request body |
| [`unknown_kind`](#unknown_kind) | 400 | `invalid_request` | `content.kind` is not one of the four | Use `message`, `document`, `event`, or `fact` |
| [`invalid_watermark`](#invalid_watermark) | 400 | `invalid_request` | `since_watermark` could not be read as a boundary | Send back a `watermark` you received, verbatim |
| [`conflicting_parameters`](#conflicting_parameters) | 400 | `invalid_request` | Two parameters that cannot both apply | Drop one — `message` names them |
| [`streaming_not_applicable`](#streaming_not_applicable) | 406 | `invalid_request` | `Accept: text/event-stream` on a call that does not stream | Use `mode: thorough`, or drop the `Accept` header |
| [`brain_not_found`](#brain_not_found) | 404 | `not_found` | No Brain with that id in this project | Create it, or check test-vs-live |
| [`record_not_found`](#record_not_found) | 404 | `not_found` | No Record with that id in this Brain | Use the id `remember` returned, verbatim |
| [`record_not_erasable`](#record_not_erasable) | 403 | `policy` | The Record is reconciled from an external source and would return | Delete it at the source, or forget the whole Brain |
| [`memory_not_found`](#memory_not_found) | 404 | `not_found` | No Memory with that id in this Brain | Read ids from `memory_ids` / Context citations, never construct them |
| [`memory_pending_rederivation`](#memory_pending_rederivation) | 404 | `not_found` | The Memory lost some supporting Records to a deletion | Wait for re-derivation, then re-read `listMemories` |
| [`object_not_found`](#object_not_found) | 404 | `not_found` | Nothing accepted at that `type`/`key` | Accept it first; keys are exact |
| [`trace_not_found`](#trace_not_found) | 404 | `not_found` | No trace with that id in the buffer | Traces are recent-only; re-run the request |
| [`corrected_memory_not_found`](#corrected_memory_not_found) | 404 | `not_found` | `corrects` names a Memory this Brain does not hold | Send a `mem_…` id you read from this Brain |
| [`brain_exists`](#brain_exists) | 409 | `conflict` | That Brain id is taken | `GET` it instead of creating it |
| [`idempotency_conflict`](#idempotency_conflict) | 409 | `conflict` | The same `Idempotency-Key` was already used for different content | Retry with the SAME body, or use a new key |
| [`stale_version`](#stale_version) | 409 | `conflict` | `if_version` did not match | Re-read, re-apply, retry with `current_version` |
| [`correction_conflict`](#correction_conflict) | 409 | `conflict` | The corrected Memory's slot has a newer active value | Read the current Memory, correct that id |
| [`not_implemented`](#not_implemented) | 501 | `internal` | The route is real; this engine does not serve it yet | Nothing to fix caller-side. Do not retry |
| [`local_only_record`](#local_only_record) | 403 | `policy` | A `local_only` Record met a hosted deployment — on write or on read | Use Brain Local on the device that holds it, or `shareable` |
| [`too_many_requests`](#too_many_requests) | 429 | `rate_limit` | Too many requests from this key in a minute | Back off for `retry_after_seconds` |
| [`unsupported_media_type`](#unsupported_media_type) | 415 | `invalid_request` | The body is not JSON | Send `Content-Type: application/json` |
| [`memory_without_lineage`](#memory_without_lineage) | 500 | `internal` | A stored Memory cannot name its evidence | Report the `request_id`. Retrying will not help |
| [`internal_error`](#internal_error) | 500 | `internal` | An unhandled bug | Retry once, then report the `request_id` |
| [`http_400`](#http_400) | 400 | `invalid_request` | The framework rejected the request before routing | Send well-formed JSON |
| [`http_401`](#http_401) | 401 | `authentication` | Auth failed below the route layer | Same fix as `invalid_api_key` |
| [`http_403`](#http_403) | 403 | `permission` | Refused below the route layer | Same fix as `missing_scope` |
| [`http_404`](#http_404) | 404 | `not_found` | The **path** does not exist | Fix the URL — not the id |
| [`http_405`](#http_405) | 405 | `invalid_request` | Wrong method on a real path | Read the `Allow` response header |
| [`http_406`](#http_406) | 406 | `invalid_request` | Your `Accept` header excludes JSON | Send `Accept: application/json` |
| [`http_409`](#http_409) | 409 | `conflict` | Conflict below the route layer | Re-read, then retry once |
| [`http_415`](#http_415) | 415 | `invalid_request` | Wrong `Content-Type` on a body | Send `Content-Type: application/json` |
| [`http_429`](#http_429) | 429 | `rate_limit` | Too many requests | Back off and retry |

The `http_*` family is raised by the framework **before any route runs** — an unknown path, a method
that does not exist, a body it could not parse. They carry a generic `message` because at that point
nothing has been interpreted yet. Everything else comes from a route or the store, and says
precisely what went wrong.

---

## `key_revoked`

`401` · `authentication` · raised by every route

**Means** — this key is well-formed, but its hash is listed in the revocation registry.

**Usually** — the key leaked and was revoked, or it belonged to a rotation that has completed.

**Fix** — mint a new secret and use it (`scripts/brain_keys.py mint --project <project> --env test`,
which prints the key once). **Revocation is per-key, not per-format**: before the registry existed,
a leaked key could only be handled by changing the key format for everyone. Now that one key's
entry is stamped, that one key stops working — on the next request, with no restart — and nobody
else is affected.

**Revocation has no inverse.** A key is revoked because it is believed to be in someone else's
hands, so restoring it would restore their access too. The way back to a working integration is
always a new key.

**The registry never holds a key** — only the SHA-256 of one, plus the `sk_<env>_<project>` prefix
and the last four characters. It is therefore safe to commit, log, and pass around; it cannot be
used to authenticate as anybody, and a key that is lost can be replaced but never recovered.

## `key_registry_unreadable`

`500` · `internal` · raised by every route

**Means** — a key registry was configured (`BRAIN_KEYS_FILE`) but could not be read or parsed, so
**every key is refused**. That file holds both the issued keys (hash, prefix, last four, project,
status) and any hashes revoked by hand; either section being unreadable produces this.

**Usually** — a bad path, a permissions problem, or malformed JSON after a hand edit.

**Fix** — repair the file, or unset `BRAIN_KEYS_FILE` to run with no registry.
`scripts/brain_keys.py list` reads the same file and reports the same problem out of band, and the
minting tool refuses to *write* a registry it could not read — a fresh ledger written over a
corrupt one would silently un-revoke every key it held.

**Why this fails CLOSED when an ABSENT registry fails open** — the two are different states, and
conflating them is the bug. No registry configured means *"nobody has revoked anything"*, which is
a legitimate way to run. A registry that is present but unreadable means *somebody wrote down
which keys must stop working and we cannot tell which* — and continuing to accept keys in that
state is exactly the failure the registry was added to prevent.

## `invalid_api_key`

`401` · `authentication` · raised on every request, before routing

**Means** — the `Authorization` header was absent, was not a `Bearer` credential, or the key did not
parse as `sk_<test|live>_<project>_<secret>` (four `_`-separated segments, none empty).

**Usually** — the header was sent as the bare key without `Bearer `; an unset environment variable
went out as the literal `$BRAIN_API_KEY`; or the key was copied with a trailing newline.

**Fix** — send `Authorization: Bearer sk_test_acme_<secret>`. Note what is *not* here: there is no
project, tenant, or user field to send. **The credential is the only thing that selects a Brain**, so
if you were looking for where to put the project id, it is already inside the key. `sk_test_…` and
`sk_live_…` address different namespaces — a Brain created with a test key is invisible to a live
key, and that is deliberate, not a bug.

---

## `missing_scope`

`403` · `permission` · raised when a route requires a scope the credential lacks

**Means** — the key is genuine and resolved, but does not carry the scope this route needs. The
vocabulary is `records:write · records:read · memories:read · objects:write · objects:read ·
context:read`, and `message` names the missing one.

**Usually** — a deliberately narrowed key (say, read-only) used on a write path. Keys are currently
issued with all six scopes, so seeing this means someone narrowed it on purpose.

**Fix** — use a key that carries the scope. A scope cannot be requested per call and cannot be
escalated by any header, body field, or parameter — that is the point of it. Retrying is pointless.

---

## `mcp_brain_unbound`

`403` · `permission` · hosted MCP only

**Means** — the key authenticates, but its issuance record does not bind it to one Brain. Hosted
tools accept no caller-controlled Brain ID, so falling back would risk the wrong customer.

**Fix** — mint a replacement with `scripts/brain_keys.py mint --brain <brain-id>` and the minimum
read scopes. An older unbound key may still use HTTP paths, where the Brain is explicit in the URL.

---

## `mcp_identity_unconfigured`

`401` · `authentication` · local stdio only

**Means** — the local bridge was started without both `BRAIN_MCP_PROJECT` and `BRAIN_MCP_BRAIN`.

**Fix** — set both values for local development, or connect to hosted `/mcp` with a Brain-bound
bearer credential. There is no implicit customer identity.

---

## `invalid_request_body`

`400` · `invalid_request` · raised by request validation on every route with a body or typed query

**Means** — Pydantic rejected the request. `message` is `"<field>: <reason>"`, and `<field>` is the
exact path into your JSON.

**Usually** — one of four things:

- a required field is absent — `id: Field required`
- `"if_version": true` — `if_version: Input should be a valid integer`. **`bool` subclasses `int` in
  Python**, so `true` once passed as version 1; strict typing now refuses it
- a timestamp that is not RFC 3339 — `as_of: Input should be a valid datetime`. `occurred_at` and
  `as_of` are date-times, and a string that merely looks like one is rejected rather than ignored
- no body at all, or a body that is not a JSON object — `body: Input should be a valid dictionary`

**Fix** — read the field name in `message` and fix that field only. If the field name surprises you,
compare the request against `openapi.yaml`, which is the contract — not the framework's generated
schema, and not any SDK's docstring.

---

## `unknown_query_parameter`

`400` · `invalid_request` · raised by `listMemories`

**Means** — you sent a query parameter the contract does not declare, and `message` names it:
`unknown query parameter(s): limitt`.

**Usually** — a typo (`limitt=1000`), or a read knob put in the wrong place. `listMemories` declares
exactly `subject · owner · as_of · cursor · limit`.

**Fix** — remove or correct the parameter. If it was a knob — `purpose · budget · include · schema ·
filter · mode` — it belongs in the **body of `POST /v1/brains/{id}/context`**, not in a query string.

We refuse rather than ignore on purpose: a silently dropped `limitt=1000` reads to you as *accepted*,
and you would go on believing a bound was applied that never was.

---

## `unknown_kind`

`400` · `invalid_request` · raised by `rememberRecord` on the spine engine

**Means** — `content.kind` is not one of `message`, `document`, `event`, `fact`, `correction`.

**Usually** — a kind invented for one application's domain.

**Fix** — pick one of the five, or send a **plain string**, which is stored as a `document` (you said
nothing about structure, so none is invented). Do not add a kind for your vertical: the five are the
entire vocabulary, and one contract serving every application with no per-customer branch is the
property that makes that possible.

---

## `invalid_watermark`

`400` · `invalid_request` · raised by `listMemories` when `since_watermark` cannot be read

**Means** — the engine serving this Brain could not read the string you sent as `since_watermark`
as a boundary. The contract can only promise the `wm_` prefix; what follows is the issuing engine's
business, so "well-formed" is not the same as "readable here".

**Usually** — a watermark that was constructed rather than received (`wm_0`, a timestamp, an
ordinal); one truncated or re-formatted on the way through a client; or a watermark from a
**different Brain**. Watermarks are opaque and are only comparable within the Brain that issued
them — a Brain in your test project and one in live do not share a clock.

**Fix** — send back, byte for byte, a `watermark` you received: from a `MemoryPage`, from the
`Brain-Watermark` header on a write, or from a Context. Store it as an opaque string; do not parse
it, sort it against another Brain's, or round-trip it through a number.

Why this is an error and not an ignored parameter: a boundary we could not read, quietly treated as
"from the beginning", returns your entire corpus while looking exactly like a delta — and a sync
client would apply it as one. Same reasoning as `unknown_query_parameter`.

---

## `conflicting_parameters`

`400` · `invalid_request` · raised by `listMemories`

**Means** — you sent two parameters that cannot both apply, and `message` names them. Today that is
`since_watermark` together with `cursor`.

**Usually** — a paging helper that always attaches its `cursor` being pointed at a delta read.

**Fix** — send one. In a delta read the **watermark IS the cursor**: page with `since_watermark`
alone, following each page's returned `watermark` while `has_more` is true (a delta page returns
`next_cursor: null` for exactly this reason). Use `cursor` for a full read, which is ordered by id
instead of by when things changed. Honouring both would leave the page order ambiguous, and a
resumable page needs one order.

---

## `streaming_not_applicable`

`406` · `invalid_request` · raised by `createContext`

**Means** — you sent `Accept: text/event-stream` on a request that does not stream. Only
`mode: thorough` streams.

**Usually** — a client that sets the SSE `Accept` header globally, or a default `mode` (which is
`fast`) left in place while streaming was switched on.

**Fix** — either send `"mode": "thorough"` in the body, or drop the `Accept` header and read the
single JSON response.

`fast` does not stream on purpose. It composes nothing, so there is no generative call to wait on
and a stream would deliver one frame after the same wait — all of the event-loop
complexity for none of the latency it exists to hide. We refuse rather than quietly returning JSON
to a client that asked for a stream, because content negotiation you cannot detect is the same
defect as a silently ignored parameter.

---

## `brain_not_found`

`404` · `not_found` · raised by every route that names a Brain

**Means** — no Brain with that id exists **in the project this key addresses**.

**Usually** — the Brain was never created; the id is misspelled; or it was created with a `sk_test_`
key and read with a `sk_live_` one. Test and live are different projects internally, not a flag on
one, so they cannot see each other's Brains.

**Fix** — `POST /v1/brains` with the id you want (ids are yours to choose), then retry. If you are
sure it exists, check the environment segment of the key you used.

---

## `record_not_found`

`404` · `not_found` · raised by `getRecord` and `forgetRecord`

**Means** — the Brain exists; this Record id does not belong to it.

**Usually** — an id from a different Brain or environment; a mangled `rec_` prefix; or the Record was
forgotten, which is permanent.

**Fix** — use the `id` returned by `remember` verbatim, and store it against the Brain it came from.
If you are retrying a `remember` that timed out, do **not** guess the id — resend the write with the
same `Idempotency-Key` and you will get the original Record back, never a second one.

---

## `memory_not_found`

`404` · `not_found` · raised by `getMemory`

**Means** — the Brain exists; this Memory id does not belong to it.

**Usually** — the id was constructed rather than read, or the Memory does not exist **yet**. Memories
are *derived* from Records, never written directly, so a Record that reports `status: "processing"`
has no Memories at all yet. It is also possible the Record it cited was forgotten: a derived row that
loses all of its supporting Records is deleted with them.

**Fix** — take Memory ids from `record.memory_ids` or from a Context's `citations`. If the Record is
still `processing`, poll `GET /records/{id}` until it is `ready` — this is the one 404 where waiting
is the correct response, and the `status` field is how the contract tells you so instead of lying.

---

## `record_not_erasable`

`403` · `policy` · raised by `forgetRecord`

**Means** — this Record came from an externally reconciled source such as Messages, Notes,
Contacts, or Calendar. Deleting only Brain's copy would not be durable: the next source sweep would
read the original again and restore it after a receipt said it was gone.

**Fix** — delete the item at its source and let the next sweep reconcile that deletion. To remove
the entire customer namespace immediately, call `forgetBrain`; its terminal cascade removes every
source and derivative under that Brain. API-origin Records are self-attesting—the API is their
origin—so `forgetRecord` can erase those directly.

---

## `memory_pending_rederivation`

`404` · `not_found` · raised by `getMemory`

**Means** — this Memory exists and **cannot be served**. It was derived from several Records, you
deleted one of them, and it is excluded from every read path until it is re-derived from the ones
that survived.

**Usually** — you called `DELETE /records/{id}` and are now following a `memory_ids` pointer or a
citation you captured before the deletion. The receipt from that delete already counted this:
`rederiving.memories` is exactly the number of Memories put into this state.

**Why not just serve it** — its statement was written from evidence that included the Record you
deleted, so serving it would serve the deleted content back to you inside a summary, under a
citation list that no longer says where it came from. Half the evidence is not most of the answer;
it is an assertion nobody can check. The other half of the same rule is that a Memory which lost
**all** of its supporting Records is deleted outright, and returns
[`memory_not_found`](#memory_not_found) — that one is not a policy choice, because a Memory with no
citations is not a shape the contract can represent.

**Fix** — nothing on your side. Re-read `GET /brains/{id}/memories` after re-derivation; what comes
back is a Memory derived only from Records that still exist, and it may say something different
from the one you were holding, which is the point. If you deleted the Record by mistake, there is
no undo — `remember` it again and the Memory is derived fresh.

**Not a synonym for `memory_not_found`.** They are separated so this state cannot be a silent
filter: a Memory the system still holds but refuses to serve must say so, or the only way to
discover it is to notice that answers quietly got worse.

---

## `trace_not_found`

`404` · `not_found` · raised by `getTrace`

**Means** — no recall trace with that id is still held for this Brain.

**Usually** — the trace has aged out. Traces live in a bounded in-process ring buffer, not in the
database, so they are **recent-only by design**: a busy Brain evicts old ones, and a restart clears
all of them. A trace id from yesterday is expected to be gone.

**Fix** — re-run the Context request and read the trace it returns, or list `GET
/v1/brains/{id}/traces` to see what is currently held. If you need a trace to outlive the buffer,
capture it at the time of the request; the API does not promise durability for it.

**Why it is not durable** — a trace records the shape of a retrieval, not its content, and keeping
every one forever would be a second store of things the contract already forbids duplicating. The
buffer exists to answer "what did that request actually do", which is a question you ask within
minutes, not months.

## `corrected_memory_not_found`

`404` · `not_found` · raised by `rememberRecord` on a `correction` Record

**Means** — the `corrects` field names a Memory this Brain does not hold, so there is nothing to
supersede. Checked before anything is written: a failed correction leaves no Record behind.

**Usually** — a constructed or mangled id; a `mem_…` id from a different Brain or environment; or a
Memory whose supporting Records were forgotten, which deletes it — there is nothing left to correct
because the claim is already gone.

**Fix** — send a Memory id you *read from this Brain*: from `record.memory_ids`, a Context's
`memories`, or `GET /v1/brains/{id}/memories`. Do not retry with the same id — unlike
[`memory_not_found`](#memory_not_found) while a Record is `processing`, a correction targets a
Memory that must already exist, so polling cannot make this succeed.

---

## `object_not_found`

`404` · `not_found` · raised by `getObject`

**Means** — nothing has been accepted at that `type`/`key` in this Brain.

**Usually** — it was never accepted, or the `type`/`key` differ by case or spacing. Both are exact
strings; `refund_window` and `Refund_Window` are two different Objects.

**Fix** — `POST /v1/brains/{id}/objects` to accept the value first. An Object is something **your
application** took authority for; the system never creates one on your behalf, so a missing Object is
always a write you have not made yet.

**Engine note** — the spine-backed engine implements Objects against a real, versioned table. Until
the operator has applied `brain/ingest/schema.sql` (one idempotent, additive DDL the ingest pipeline
self-applies), that table does not exist and *every* Object call returns
[`not_implemented`](#not_implemented) naming that fix — **not** this code. That distinction is
deliberate: `object_not_found` would have sent you looking for an acceptance you never made.

---

## `brain_exists`

`409` · `conflict` · raised by `createBrain`

**Means** — a Brain with that id already exists in this project.

**Usually** — a retried create, or two processes bootstrapping the same Brain concurrently.

**Fix** — `GET /v1/brains/{id}` and use it. For "create if absent", `GET` first and treat a
`brain_not_found` as the signal to create; when two callers race, the loser gets this code and should
`GET`, not retry the create. `createBrain` is deliberately **not** idempotent — a Brain is a memory
boundary, and silently returning someone else's Brain because the id matched is exactly the failure
that must never happen.

---

## `stale_version`

`409` · `conflict` · raised by `acceptObject` · **carries `current_version`**

**Means** — you sent `if_version: N` and the Object is not at version N. The body tells you what it
actually is.

**Usually** — someone else accepted a new version between your read and your write.

**Fix** — re-read the Object, re-apply your change on top of **that** value, and retry with
`if_version` set to the `current_version` you were just handed. Retry once; if it conflicts again,
something else is writing continuously and you should serialize the writers.

**Do not** drop `if_version` to force the write through. That converts a reported conflict into a
silent lost update, which is the exact failure `if_version` exists to make visible. Objects are
immutable and versioned: a successful accept appends a version, it never mutates the old one.

---

## `correction_conflict`

`409` · `conflict` · raised by `rememberRecord` on a `correction` Record, spine engine

**Means** — the Memory named by `corrects` exists but was **already superseded**, and its slot now
has a newer active value. Superseding it again would put two live claims on one slot, which the
database refuses (one active row per slot is enforced by a unique index, not by trust).

**Usually** — a stale id: you read the Memory, something revised it (a newer Record, an earlier
correction), and your correction arrived after. The same race as `stale_version`, on Memory.

**Fix** — re-read the current value (`GET /v1/brains/{id}/memories` filtered to the subject, or the
Context you are working from) and send the correction against the **current** Memory's id. The
failed attempt rolled back whole — no Record, no supersede, nothing to clean up.

---

## `idempotency_conflict`

`409` · `conflict` · raised by `rememberRecord`, both engines

**Means** — this `Idempotency-Key` was already used, for a Record whose content was **different**.
A key identifies a *request*, not a slot: reusing it is how you say "this is the same write, I am
retrying." Two different bodies under one key are two writes wearing one name, and there is no
answer to that which is not a lie. Overwriting would mutate a Record the contract calls immutable;
returning the earlier one would hand you a Record you never wrote and call it yours.

**Usually** — a client that derives its key from something too coarse (a user id, a day, a job
name) rather than from the write itself, so two genuinely different writes collide. Occasionally a
retry that edited the body before resending.

**Fix** — retry with the **byte-identical** body to get the original Record back (that is a replay
and returns `202` with the same `rec_…`), or send a **new key** for the new content. If what you
actually want is to change what this Brain believes, that is a `correction` Record: it supersedes
the old claim and keeps the history, which is what `Idempotency-Key` can never do.

**Note** — this used to be silent. The spine ran `ON CONFLICT … DO UPDATE`, so a replay carrying
different content rewrote the stored Record and answered `202`. History changed and nobody was
told (CLOSURE_PLAN #12).

---

## `local_only_record`

`403` · `policy` · raised by `rememberRecord` (on write) and `getRecord` (on read), hosted deployments

**Means** — `visibility: local_only` says this Record never leaves the device. A hosted deployment
cannot keep that promise, so it will not participate in one. **Two moments, one rule:**

  · **on write** — the Record is refused rather than stored. A promise never made beats a promise
    made and broken, and storing it in a datacentre while answering `202` would be the second.
  · **on read** — a Record that reached the host another way (the sync pipeline is custody
    *transport*, not serving) is refused rather than returned.

`403`, not `404`: the Record exists and you may well own it. What you cannot have is it leaving the
device. Saying "not found" would be a lie that also teaches you to retry.

**Also true of Context, and this is the part that was broken.** `POST /context` filters `local_only`
evidence out of the served window on a hosted deployment — until 2026-07-28 it did not, so the same
content the direct read refused came back as body text inside a `200`. There is no error for that
case by design: the evidence is simply absent, exactly as if the Brain did not hold it, because a
`403` naming what was withheld would itself leak that something is there.

**Fix** — read it from Brain Local on the device that holds it, or write it as `redacted_summary`
or `shareable` if it may live on a host.

**Scope, stated honestly** — this closes the *response* leak. A hosted deployment still ranks rows
it will not serve, because the lane predicates live in `brain/retrieve/` and pushing visibility into
that SQL is a retrieval change that has to clear the eval gate.

## `too_many_requests`

`429` · `rate_limit` · raised by every route

**Means** — this API key made more requests in the last minute than the limit allows.

**Usually** — a retry loop with no backoff, or a batch job that should be pacing itself.

**Fix** — wait `retry_after_seconds`, which is on the error body, then retry. The window is fixed,
not sliding, so the counter resets rather than decays.

**Honest bound** — the limiter is **per process**. Two instances mean two counters, so the
effective limit is per-instance. Real rate limiting belongs at the edge and is not shipped yet; this
exists so the declared response is reachable and a single instance has a floor.

## `unsupported_media_type`

`415` · `invalid_request` · raised by every route that takes a body

**Means** — the request body is not JSON.

**Usually** — a missing or wrong `Content-Type`. An HTTP client that defaults to
`application/x-www-form-urlencoded` produces this.

**Fix** — send `Content-Type: application/json` (or any `+json` type).

**Why it is checked first** — it used to surface as `invalid_request_body`, which sends you hunting
through the payload for a field problem that is really a header problem.

## `not_implemented`

`501` · `internal` · raised by the spine engine on paths that are not built yet

**Means** — the route is real and the contract is honest about it; **this** engine does not serve it
yet. `message` names what unblocks it: creating and forgetting Brains and the deletion cascade (T9)
— or a database the operator has not applied `brain/ingest/schema.sql` to yet (one idempotent DDL),
which is two separate gates with the same fix: on `rememberRecord`, an `events` type domain not yet
widened for `api.*` Records; on `acceptObject`/`getObject`, a missing `objects` table. In both of
those the path itself is BUILT, nothing was half-written (the failed statement rolls back whole),
and the same call succeeds the moment the DDL has run.

**Usually** — you are calling a write path against the spine-backed deployment. The in-memory
conformance engine implements all of them, which is why the same call can succeed there.

**Fix** — none caller-side, and **do not retry** — it will fail identically until the tier ships or
the operator applies the DDL. This is deliberate: a half-built write path that appeared to work
would be worse than one that says it is not there, because you would build on a guarantee that does
not exist.

**Known wart, stated rather than hidden:** the `type` is `invalid_request`, which reads as though the
caller erred; it is a server-side gap and would be better typed. The status is contract-legal (every
operation declares `default: Error`), and changing the type is a contract change that has to go
through the conformance gate.

---

## `memory_without_lineage`

`500` · `internal` · raised while serializing a Memory

**Means** — a stored Memory has no supporting Records, so it cannot cite its evidence. We refuse to
serialize it at all rather than return an assertion you have no way to verify.

**Usually** — a derivation defect upstream: a row written without its `event_ids`, or a partial
cascade that removed Records and left the derived row behind.

**Fix** — nothing caller-side, and retrying is pointless: it is deterministic, not transient. Report
the `request_id`. The server-side fix is to re-derive or quarantine the offending row — never to relax
the check, because **cited-or-abstain is the guarantee**, and a citation-free Memory is the shape of
every confidently-wrong answer this API exists to not produce.

---

## `internal_error`

`500` · `internal` · the catch-all for an unhandled exception

**Means** — a bug. The handler exists so that even a bug returns the one typed error shape: no stack
trace, no framework HTML, nothing untyped ever reaches you.

**Usually** — genuinely unexpected. A dependency being unavailable is the most common cause.

**Fix** — retry **once**. Reads are safe. `remember` is safe to retry if you resend the same
`Idempotency-Key`: the key becomes the record's natural key under a `UNIQUE` constraint, so a replay
with identical content returns the **original** Record and cannot mint a second. If it recurs, report
the `request_id` — it identifies the exact call in the server logs.

---

## `http_400`

`400` · `invalid_request` · framework-level, before routing

**Means** — the request was rejected before any route saw it — malformed HTTP or a body the framework
could not parse at all.

**Fix** — send well-formed JSON. Note that most malformed bodies surface as
[`invalid_request_body`](#invalid_request_body) instead, which names the field; if you got this
generic one, the problem is with the request as a whole, not one field in it.

---

## `http_401`

`401` · `authentication` · framework-level

**Means** — authentication failed below the route layer.

**Fix** — same as [`invalid_api_key`](#invalid_api_key), which is what an authentication failure
actually returns today. This code is mapped so that if any future middleware raises a bare 401, it
still arrives as the one typed shape rather than `{"detail": ...}`.

---

## `http_403`

`403` · `permission` · framework-level

**Means** — the request was refused below the route layer.

**Fix** — same as [`missing_scope`](#missing_scope), which is what a permission failure returns
today. Mapped for the same reason as `http_401`: no failure path may escape the Error shape.

---

## `http_404`

`404` · `not_found` · framework-level

**Means** — **the path does not exist.** This is not "your id was not found" — no route matched the
URL at all, and `message` is the generic `Not Found`.

**Usually** — a missing `/v1` prefix, a mistyped segment (`/v1/brain/…` for `/v1/brains/…`), or a
resource path that is not in the contract.

**Fix** — compare the URL against `openapi.yaml`. Every path is
`/v1/brains`, `/v1/brains/{id}`, or `/v1/brains/{id}/{records|memories|objects|context}`. If your id
is the problem instead, you would have received `brain_not_found` or `record_not_found` — a specific
code means routing worked.

---

## `http_405`

`405` · `invalid_request` · framework-level · **carries the `Allow` response header**

**Means** — the path is real, the method is not: `PUT` on a Record, `POST` on a Memory.

**Fix** — read the `Allow` header on the response; it lists exactly what that path accepts. That
header is passed through deliberately, because a refusal a client cannot learn from is just a number.

Worth knowing which method to expect: nothing in this API is updated in place. You `POST` to remember
a Record and to accept an Object (which appends a version), you `GET` to read, and you `DELETE` to
forget. There is no `PUT` or `PATCH` anywhere in the contract.

---

## `http_406`

`406` · `invalid_request` · framework-level

**Means** — your `Accept` header excludes `application/json`, and JSON is all this API speaks.

**Fix** — send `Accept: application/json`, or omit the header entirely. (Today the server does not
enforce `Accept`, so this is mapped ahead of a stricter negotiation rather than commonly seen.)

---

## `http_409`

`409` · `conflict` · framework-level

**Means** — a conflict raised below the route layer.

**Fix** — re-read the resource and retry once, exactly as for
[`stale_version`](#stale_version). Route-level conflicts arrive as `brain_exists` or `stale_version`,
which name the resource and, where one exists, hand you the current version.

---

## `http_415`

`415` · `invalid_request` · framework-level

**Means** — a body was sent with a `Content-Type` the API does not accept.

**Usually** — `curl -d '{...}'` with no `-H 'Content-Type: application/json'`, which defaults to
`application/x-www-form-urlencoded`.

**Fix** — send `Content-Type: application/json`. (Today a wrong content type is more likely to
surface as [`invalid_request_body`](#invalid_request_body) — the parser fails before negotiation
does — so treat both as the same fix.)

---

## `http_429`

`429` · `rate_limit` · framework-level

**Means** — too many requests.

**Fix** — back off exponentially and retry; this is the one code where retrying is the correct
response rather than a hopeful one. Make writes safe to repeat by sending an `Idempotency-Key` on
every `remember`, so a retried write after a rate limit returns the original Record instead of a
duplicate.

**Not enforced yet** in this deployment: no limiter is wired, so you will not see this today. It is
documented and mapped ahead of that, because clients need retry behaviour built *before* the limit
exists, not after.

---

## Adding a code

1. Raise `StoreError(type_, code, message, status)` — the taxonomy lives in one place.
2. Add a table row **and** a section here. Both are asserted.
3. `.venv/bin/python scripts/check_error_docs.py` — it fails on an undocumented code, a documented
   code nothing raises, and a status or type in this file that disagrees with the raise site.
