Docs / Errors

Errors

Every error code, its meaning, and the next move.

Source: docs/initiatives/memory-api/errors/README.md — this page is rendered from it, not copied. Every code the API can return, anchored at #code — and this page is where the docs_url in every error body already points, because the API serves it. Gated by scripts/check_error_docs.py.

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?

typeRetry?What to do
invalid_requestNoThe request is wrong. Retrying sends the same wrong request.
authenticationNoFix the credential.
permissionNoUse a key that carries the scope.
not_foundNoCreate it, or stop using a stale id. Do not poll a 404 into existence — except memory_not_found while a Record is still processing.
conflictOnce, after re-readingRe-read, re-apply on top of what you read, retry. Never by stripping if_version.
rate_limitYesExponential backoff.
internalOnceReads are safe. remember is safe if you resend the same Idempotency-Key — it is the write path's whole retry story.

Every code

CodeHTTPtypeMeansDo this
invalid_api_key401authenticationThe credential is missing or malformedSend Authorization: Bearer sk_<test|live>_<project>_<secret>
key_revoked401authenticationThis key was revoked in the key registryMint a new key; a revoked one never returns
key_registry_unreadable500internalThe key registry exists but could not be readFix or remove BRAIN_KEYS_FILE; every key is refused until then
missing_scope403permissionThe key is valid but narrowedUse a key carrying that scope
mcp_brain_unbound403permissionThe key is not bound to one Brain for hosted MCPMint a replacement with --brain
mcp_identity_unconfigured401authenticationLocal stdio has no explicit project or BrainSet both local MCP environment values
invalid_request_body400invalid_requestA body or typed query field failed validationRead the field name in message, fix that field
unknown_query_parameter400invalid_requestA query parameter the contract does not declareRemove it — or move it into the request body
unknown_kind400invalid_requestcontent.kind is not one of the fourUse message, document, event, or fact
invalid_watermark400invalid_requestsince_watermark could not be read as a boundarySend back a watermark you received, verbatim
conflicting_parameters400invalid_requestTwo parameters that cannot both applyDrop one — message names them
streaming_not_applicable406invalid_requestAccept: text/event-stream on a call that does not streamUse mode: thorough, or drop the Accept header
brain_not_found404not_foundNo Brain with that id in this projectCreate it, or check test-vs-live
record_not_found404not_foundNo Record with that id in this BrainUse the id remember returned, verbatim
record_not_erasable403policyThe Record is reconciled from an external source and would returnDelete it at the source, or forget the whole Brain
memory_not_found404not_foundNo Memory with that id in this BrainRead ids from memory_ids / Context citations, never construct them
memory_pending_rederivation404not_foundThe Memory lost some supporting Records to a deletionWait for re-derivation, then re-read listMemories
object_not_found404not_foundNothing accepted at that type/keyAccept it first; keys are exact
trace_not_found404not_foundNo trace with that id in the bufferTraces are recent-only; re-run the request
corrected_memory_not_found404not_foundcorrects names a Memory this Brain does not holdSend a mem_… id you read from this Brain
brain_exists409conflictThat Brain id is takenGET it instead of creating it
idempotency_conflict409conflictThe same Idempotency-Key was already used for different contentRetry with the SAME body, or use a new key
stale_version409conflictif_version did not matchRe-read, re-apply, retry with current_version
correction_conflict409conflictThe corrected Memory's slot has a newer active valueRead the current Memory, correct that id
not_implemented501internalThe route is real; this engine does not serve it yetNothing to fix caller-side. Do not retry
local_only_record403policyA local_only Record met a hosted deployment — on write or on readUse Brain Local on the device that holds it, or shareable
too_many_requests429rate_limitToo many requests from this key in a minuteBack off for retry_after_seconds
unsupported_media_type415invalid_requestThe body is not JSONSend Content-Type: application/json
memory_without_lineage500internalA stored Memory cannot name its evidenceReport the request_id. Retrying will not help
internal_error500internalAn unhandled bugRetry once, then report the request_id
http_400400invalid_requestThe framework rejected the request before routingSend well-formed JSON
http_401401authenticationAuth failed below the route layerSame fix as invalid_api_key
http_403403permissionRefused below the route layerSame fix as missing_scope
http_404404not_foundThe path does not existFix the URL — not the id
http_405405invalid_requestWrong method on a real pathRead the Allow response header
http_406406invalid_requestYour Accept header excludes JSONSend Accept: application/json
http_409409conflictConflict below the route layerRe-read, then retry once
http_415415invalid_requestWrong Content-Type on a bodySend Content-Type: application/json
http_429429rate_limitToo many requestsBack 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": trueif_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

Meanscontent.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.

FixPOST /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 — 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 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.

FixPOST /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 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.

FixGET /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

Meansvisibility: 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 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, 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, 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

Meansthe 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. 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.

Usuallycurl -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 — 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.