Error Envelope & Hint Codes
Every argument-validation rejection engram produces, plus the one rejection that is not
about an input at all (a response too large to return)
— on both the MCP tool-call lane and the Connect RPC lane — carries the same structured
envelope: the field(s) that failed (or the fixed pseudo-field response), a machine-stable
hint code naming why, and a human-readable detail. This page is the complete,
checked-off reference for that vocabulary.
The envelope grammar
Section titled “The envelope grammar”field=<name> hint=<code>: <human text>field and hint are the machine-readable parts — parse and branch on those. The text
after the colon is for a human, not a contract; it has already changed wording once in
this release and will again.
Single-field example — an oversized summary on store_memory:
field=summary hint=too_long: summary must be at most 512 bytes (got 700)The same shape covers every capped field, carrying only sizes — never the submitted value.
An oversized content on store_memory/schedule_memory/update_memory:
field=content hint=too_long: content too large: 70000 bytes (max 65536)Too many tags on the same write paths:
field=tags hint=too_many: too many tags: 129 (max 128)No new hint code was added for either of these — both reuse too_long/too_many.
Relational example — fields that cannot be combined, on list_memory:
field=cursor_mode,offset,page_token hint=mutually_exclusive: cursor_mode, offset, and page_token are mutually exclusivefield is a comma-joined list rather than a single string specifically so a relational
rejection like this one has a home in the same envelope — every field the constraint
relates is named, never just one arbitrarily picked.
MCP carries this string as the entire wire payload. The protocol has no structured-error slot for a tool-call rejection: the MCP SDK discards any structured result on a non-nil error and returns only the error string as text. A client on the MCP lane parses this prefix out of that string; there is no side channel.
Connect carries the same string, plus a typed error code (see
Connect code mapping below) — a Connect client can
branch on the code alone and treat the string as detail, or parse the same field=/hint=
prefix for parity with the MCP lane.
Multi-target rejections
Section titled “Multi-target rejections”supersede_memory accepts a set of one or more
targets to merge into a single correcting record. An invalid target set rejects the
whole call — a merge never partially applies to a valid subset of the set you sent.
A rejection carries exactly one failure class per response, in a fixed order evaluated top to bottom:
- Set shape — the
supersedesarray is empty, or one of its entries is blank. - Addressability and access — a target you do not own, a target that does not exist, and a target whose short id matches more than one record are all the SAME rejection. Do not read a not-found response as proof an id is unused, and do not read it as proof a short id is unique — either could be why you got it. If a short id will not resolve, name that target by its full UUID instead.
- Rule target — one or more targets is a
store_rulerecord, which cannot be superseded. - Already superseded — one or more targets already carries a
superseded_bylink; supersede the current head of that chain instead.
Every offending target of the failing class is named in the same response, comma-separated, in the order you supplied them — never just the first one. Each target is echoed exactly as you wrote it, so a target you sent as a short id comes back as that short id, never as a resolved UUID.
Worked examples, taken verbatim from what the server renders — an automated test
(TestSupersedeDocsMatchShippedContract) binds this text to the production rendering
helper, so an out-of-date example fails the build rather than silently drifting:
Addressability and access, two offending targets:
not found: a1b2c3d4e5, f6g7h8j9k0Already superseded, two offending targets:
target is already superseded: a1b2c3d4e5, m1n2p3q4r5These four rejections are sentinel-shaped, not field-and-hint shaped — they name
offending targets, not a field, and the twelve-code hint vocabulary above is unchanged;
no new hint code was added for this verb. Set-shape rejections (empty array, blank
entry) DO use the field-and-hint grammar above, naming the supersedes argument
itself rather than any target value.
Batch outcomes (archive_memory / restore_memory)
Section titled “Batch outcomes (archive_memory / restore_memory)”archive_memory and
restore_memory take a batch of ids and
return one outcome per id — a per-id not_found (not yours, or does not
exist) is an outcome in the result, not an error, and the call still
succeeds. The whole call rejects, with the ordinary field-and-hint grammar
above naming ids, only on a malformed batch:
hint=required—idsis empty, or one of its entries is blank.hint=out_of_range—idshas more than 1000 entries.hint=too_long— one entry exceeds 256 bytes.
The twelve hint codes
Section titled “The twelve hint codes”Transcribed directly from internal/server/argerror.go’s HintCode constants and checked
off one by one against that file — this table cannot list a code the server does not emit.
| Hint code | Meaning | What to do |
|---|---|---|
required |
The field was absent entirely. | Supply it — it was missing, not malformed. |
conditional_required |
The field is required only given another field’s value or the call’s shape (e.g. a caller-authored summary being addressed on update_memory). |
Supply the field, given the condition named in the detail text. |
too_long |
The field exceeds a maximum length/byte bound. | Shorten the field’s value — do not resend the whole record; only this field failed. |
too_many |
A collection field (e.g. citations, tags) exceeds a maximum count. |
Trim the collection to the stated bound. |
enum |
The value is not one of the accepted set. | Resend with one of the accepted values named in the detail text. |
format |
The value fails a structural check (e.g. an RFC3339 timestamp). | Correct the value’s shape; the constraint is named in the detail text. |
prefix |
The value must start with a required prefix (e.g. a discovery scope must start with discovery:). |
Prepend the required prefix. |
ordering |
A before/after or numeric ordering constraint is violated — usually between two fields, but sometimes between one field and a fixed reference such as the current time. | Adjust so the stated ordering holds. Read field=: it lists every field involved, which may be one or two. |
mutually_exclusive |
Two or more fields cannot be combined at once. | Drop all but one — every field the constraint relates is listed under field=. |
not_applicable |
The field does not apply given another field’s value on this call. | Omit the field entirely rather than sending an empty or default value. |
out_of_range |
A numeric field exceeds its documented maximum. | Resend at or below the maximum named in the detail text — the value is rejected, never clamped. |
response_too_large |
The result the request would produce exceeds what one response can carry — not a rejected input; field= is always the fixed pseudo-field response. |
Retry with a smaller limit or k, or omit full; retrying the identical request fails the same way. See Response too large. |
out_of_range names a NUMERIC argument above its documented ceiling — distinct from
too_long (a length/byte bound on a string or blob) and too_many (a collection count
bound). limit/k above the documented maximum (1000) is the first caller of this code.
out_of_range reads like it should map to CodeOutOfRange below, but it is classified
Malformed by decision — a rejected numeric argument is still a malformed request, and
Malformed and Out of range already collapse to the same CLI exit, so nothing observable
changes for a CLI-driven caller:
| Hint code | Connect code | CLI exit |
|---|---|---|
out_of_range |
invalid_argument (CodeInvalidArgument) |
2 |
too_long and response_too_large are easy to conflate but name opposite directions:
too_long means an INPUT field you sent exceeded a bound — shorten that field and resend.
response_too_large means the RESPONSE your request would produce exceeds a bound — the
request itself was fine; ask for less of it (a smaller limit/k, or without full).
required and conditional_required are two different codes for a reason: required
means the field is unconditionally missing; conditional_required means it is missing
given something else about the call, so retrying with the same fields you already sent
minus the addition will fail again for the same reason.
mutually_exclusive always names two or more fields, never one — do not retry by
guessing which single field is “the” problem field; the constraint is between all of
them. list_memory’s paging trio (cursor_mode, offset, page_token) is the one
three-field case today.
ordering names one or two fields, so read field= rather than assuming a pair. Two
fields means they are misordered relative to each other (not_before must precede
not_after). One field means it is misordered relative to a fixed reference rather than to
another argument you sent — not_after must be in the future, for example, which no change
to a second field can fix.
Operator-tier hint codes (engram migrate revert)
Section titled “Operator-tier hint codes (engram migrate revert)”These two codes use the same field=<name> hint=<code>: <text> grammar as the twelve-code
table above, but they are produced by internal/store/revert.go’s RevertRefusalError —
not by internal/server/argerror.go — so they are not HintCode constants and the twelve-code
table above remains exactly what it claims to be: a transcription of argerror.go. They
surface only from an engram migrate revert refusal (see below for the two places that can
happen), never from any memory-tool call.
| Hint code | Meaning | What to do |
|---|---|---|
irreversible |
The requested revert range includes at least one step declared irreversible — the whole operation is refused before any write. | Recover via a collection snapshot taken before the forward migration ran; there is no partial-revert path around an irreversible step. |
unsupported |
At least one record in the above-target range is stamped at a version with no reachable reverse chain to the target. | Recover via a collection snapshot, or narrow the target to a version every record can actually reach. |
A single refusal never carries both hint codes. If a range is both irreversible and
carries an unsupported version, RevertRefusalError still emits exactly one
field=/hint= envelope (the one-envelope-per-rejection contract above) — it leads with
field=steps hint=irreversible (irreversible outranks unsupported: it cannot be resolved
by migrating forward again, unlike an unsupported-version gap) and folds the unsupported
detail into that same envelope’s text as an additional clause.
Both codes can also surface from a narrower, single-record refusal discovered inside
migrate revert --apply’s write-convergence loop, after its own whole-range preflight has
already passed — not only from that preflight itself. A concurrent engram migrate --apply
can land a new above-target record in the window between the preflight and the write loop’s
first read (or between any two write-loop passes, since the loop re-derives its backlog on
every pass), and that record can turn out to be irreversible or unsupported even though the
preflight never saw it. This surfaces with the identical field=/hint= grammar and the
identical recovery guidance — the underlying condition (a genuinely irreversible step, or a
record with no reachable chain) carries the same remedy whether discovered before the first
write or between two writes — but reports Candidates: 1 for the one record that triggered
it, not the whole range’s count. errors.As still recovers the same *RevertRefusedError
either way, so a caller does not need to distinguish the two cases.
One consequence is worth acting on: unlike a preflight refusal, a mid-loop refusal can
follow writes that already landed. migrate revert --apply reverts in batches, so a run
can revert whole batches of earlier records and only then hit the racing record that trips
the refusal. The refusal report therefore carries the real progress counters — reverted,
failed, passes, and backlog — alongside applied: false, and the text summary says so
explicitly. Treat a refusal with a non-zero reverted as a partially reverted
collection: run engram migrate status to see the resulting version distribution before
deciding whether to re-run the revert, migrate forward again, or restore the snapshot. A
preflight refusal reports all-zero counters and needs no reconciliation.
The class-to-Connect-code mapping
Section titled “The class-to-Connect-code mapping”Every argument-validation failure belongs to one of three classes, and the class — never message text — selects the Connect error code:
| Class | Connect code | Meaning |
|---|---|---|
| Malformed | CodeInvalidArgument |
Wrong shape or value: absent, unparseable, not in an enum, wrong prefix. |
| Out of range | CodeOutOfRange |
Right shape, wrong magnitude: a length or numeric bound violated. |
| Precondition | CodeFailedPrecondition |
A relationship or state constraint between two individually-valid fields, not a single value. |
All three map to the CLI’s exitUsage (exit code 2). cmd/engram/client_common.go’s
exitCodeForConnectErr already groups CodeInvalidArgument, CodeOutOfRange, and
CodeFailedPrecondition under the same exit code — see the
CLI guide’s exit-code table — so a Connect client driving the
engram CLI needs no change. A Connect client branching on the error code directly (not
through the CLI) does need to widen from CodeInvalidArgument alone to all three.
Response too large: resource_exhausted and exit 10
Section titled “Response too large: resource_exhausted and exit 10”Every other rejection on this page is about an input: something you sent was wrong. This
one is not. The request was fine — the RESULT it would produce exceeds what one response
can carry. It uses the same field=<name> hint=<code>: <detail> grammar as every other
rejection above, but with the fixed pseudo-field response: the response overflowed, not
an argument you supplied, so there is no caller-supplied field to name.
It is not one of the three argument classes in the mapping above — a Qdrant read that
overflows the client’s receive limit is a transport-layer event, not a malformed, out of
range, or preconditioned argument — so it does not map to exit 2.
| Hint code | Connect code | CLI exit |
|---|---|---|
response_too_large |
resource_exhausted (CodeResourceExhausted, HTTP 429) |
10 |
On the MCP lane the same envelope is the tool result’s text content, with IsError true.
field=response hint=response_too_large: the result is too large to return in one response; retry with a smaller limit or k, or omit fullThe remedy is a smaller limit or k, or omitting full — retrying the identical
request fails the same way, since the ceiling that tripped it does not change between
requests.
The one exit code with no hint-code or Connect-code counterpart
Section titled “The one exit code with no hint-code or Connect-code counterpart”Every exit code the CLI’s taxonomy publishes elsewhere is reachable through this page’s hint-code or Connect-code vocabulary above — except one:
| Exit code | Meaning |
|---|---|
7 |
Findings reported under an explicit opt-in flag (e.g. spine-review verify --fail-on) — the command itself succeeded; see the CLI guide’s exit-code table. |
No argument-validation hint and no Connect error code ever produces exit 7, because it
never represents an invalid request — the request succeeded and reported real findings a
CI step asked to be told about. It is documented here specifically because a reader
auditing this page for “every way engram can fail” would otherwise miss the one exit path
that isn’t a failure at all.
What is NOT in an error
Section titled “What is NOT in an error”No value echo, with one deliberate, bounded exception. An engram rejection names the field that failed and states the constraint it violated — it never echoes the value you sent. A derived, bounded number (a byte count, an element count) may appear, but never the caller-supplied string or structure itself. This means an engram rejection is safe to log verbatim: it cannot carry a secret or an oversized blob back out through your own logs.
The one exception is supersede_memory’s multi-target rejections
(sentinel-shaped, classes 2-4 above): each offending target is echoed back exactly as you
wrote it — a UUID or short id you already possess, not a secret or free-form content, and
individually length-bounded (see the MCP Tools reference for the
per-entry cap) so the echo itself can never carry an oversized or arbitrary blob. Every
other field in this grammar, including supersede_memory’s own set-shape (class 1)
rejection, still names the field alone and never echoes its value.
The response_too_large envelope carries no number at all. No byte ceiling, no observed size,
and no upstream transport text — those are logged server-side only; the wire text is the
fixed, generic detail shown in Response too large.
The MCP 401 auth body is a separate, unchanged contract. A bearer-token rejection (missing or invalid credential) is produced by the MCP SDK’s own auth middleware, before any engram argument validation runs, and does not use this grammar. It is byte-identical to its pre-existing shape and is not covered here.
The go-sdk’s own schema-level rejections are also outside this grammar. Required-ness
for engram’s arguments now lives entirely in engram’s own validation (see
MCP Tools reference), but the MCP SDK can still reject a call for a
reason upstream of engram entirely — a malformed JSON-RPC envelope, an unknown tool name.
Those rejections are not field-and-hint shaped. A client that fails to parse the
field=…hint=…: prefix out of an error string should fall back to treating the string as
an opaque message, not raise a parse error of its own.
See also
Section titled “See also”- MCP Tools reference — the tool argument tables, including the
memory-
summarylength bound this envelope enforces. - CLI guide — the exit-code table this page’s Connect mapping is proven to sit inside.