This document defines the normative error-class contract. The vault and magic-context mirror it by reference; subconscious preserves it on results.
Classification is PRODUCED by the error source, never INFERRED by the consumer. A consumer branches on the produced class tag and structured sibling fields only — never on status codes, message substrings, or producer-detail codes.
Classification is emitted as a structured field, preserved across every layer boundary, and consumed by branching on the class tag. Message text and status codes are not substitutes for a producer's classification.
Wire strings, snake_case, pinned by a conformance test in every producer:
| Wire string | Consumer decision |
|---|---|
transient |
Retry may succeed. With retry_after_secs present: respect it as a backoff floor (a longer producer value wins; a shorter one may clamp down). Without: consumer's own schedule/routing. |
permanent |
Retrying THIS target with THIS input is futile until out-of-band action. Advance to an alternative immediately; if all alternatives are permanent-dead, abandon and surface — never backoff-retry. |
auth_required |
A credential/authorization must be re-established by a human or admin. Pause and surface an actionable auth prompt. Provider-scoped: the consumer marks same-provider alternatives dead from its own routing knowledge. |
context_overflow |
Input exceeds THIS producer+model's bound; retrying the same input against the SAME model is futile. Whether a larger-bound alternative helps is the CONSUMER's decision (windows are heterogeneous — a 128k overflow may be precisely fixable by a 1M fallback). Producer-neutral: model context windows, bounded batch reads, any bounded-input producer. |
The canonical copy-pastable set every producer constant and golden test asserts against:
ERROR_CLASS_WIRE_SET = ["transient", "permanent", "auth_required", "context_overflow"]
(+ optional sibling retry_after_secs, seconds)
Not all quota errors are the same class, and they all arrive as HTTP 429:
- Billing/credits exhaustion (e.g. OpenAI
insufficient_quota):permanent— a human must pay; no amount of waiting fixes it. - Window quotas (e.g. Claude 5-hour windows, daily caps):
transient+retry_after_secswhen the provider supplies it — they self-recover in minutes-to-hours. - Safe default: when the producer cannot distinguish which kind a quota-429
is, map it to
transient. The costs are asymmetric: mis-classifying as transient burns a few retries; mis-classifying as permanent silently kills a healthy model and fires a false chain-dead alarm in the consumer's health channel.
The permanent arm's definition pins as "cannot recover without human or config
action", which makes the quota split derivable per provider code.
Ordering rule: code/body discrimination runs before generic status defaults. A quota-429 is indistinguishable only after provider-code discrimination.
These mappings refine the producer's classification only; they do not add wire vocabulary or change the consumer contract:
| Provider response | Class |
|---|---|
| HTTP 401 | auth_required |
| HTTP 403 (authenticated but forbidden) | permanent |
Anthropic authentication_error |
auth_required |
Anthropic permission_error |
permanent |
A producer may use a more specific structured provider code before applying a generic status
default. In particular, a credential-authentication code remains auth_required, while an
authorization/entitlement denial remains permanent. Only the former reaches vault auth-failure
reporting; a 403 must not invalidate a valid credential.
retry_after_secs(seconds — the only unit) is an optional structured sibling, not arm data. Rate-limitedness is a transport phenomenon, not a consumer decision: a 429 withRetry-Afteristransient+ the field; a 429insufficient_quota(billing exhaustion) ispermanent.- Normalization is the PRODUCER's job: whatever shape the provider sends
(
Retry-Afterseconds or HTTP-date,retry-after-ms, rate-limit-reset headers, reset hints in body text), the producer folds it intoretry_after_secsso no consumer ever parses headers or text. Consumers treat the value as a backoff floor and may cap it — capping policy stays consumer-local. - Producer detail (broca
provider_code/status, vaultReadErrorname) travels alongside for diagnostics, logging, and corroboration ONLY. A consumer branching generically on detail is the bug this contract kills.
"Does a consumer branch on it GENERICALLY?"
ProviderUnavailable(broca): NO — its generic decision istransient's; the runner's respawn-and-reissue mechanics are producer business. It maps totransientat shared boundaries (ErrorClass::shared_class) and survives in detail.ContextOverflow: YES — the compaction/reduce trigger is a generic consumer decision distinct from bothpermanentandtransient. A consumer whose inputs are budget-sized below every target's bound may treat an overflow as its own estimator bug and short-circuit — that mapping is consumer-specific, not contract semantics.
The set is pinned for PRODUCERS. Consumers should decode class as an OPEN string
rather than a closed enum, so an unrecognised value becomes a PASSTHROUGH rather
than a decode failure.
This is not a softening of the contract, and it costs nothing while the set holds. It decides what happens on the day it does not: with a closed enum, adding a fifth class turns every existing consumer's decode into an error on a field they were reading fine — the producer ships a strictly more precise error and the consumer reports a parse failure, which is the least useful outcome available. With an open string, an unknown class arrives intact and can be logged, surfaced, or defaulted by the consumer's own policy.
A consumer that pins the enum makes vocabulary extension a BREAKING change for
everyone at once, which in practice means it never happens and producers smuggle
new meanings into message text instead. That is the failure this rule exists to
prevent.
Producers assert the closed set. Widening is a contract amendment; open consumer reads protect against vocabulary changes without licensing arbitrary new classes.
The contract is the WIRE STRING SET, not a Rust type. Each producer keeps its own enum; a pinned string-set serde test per producer asserts conformance. Each producer mirrors this wire contract independently.
broca specifics:
-
The canonical enum is
ErrorClassincrates/broca-protocol/src/error.rs. -
The
Autharm's wire spelling isauth_required, withauthaccepted on read indefinitely (serde alias) so WAL records written before the rename replay clean. -
ErrorClass::shared_class()is the boundary mapping for cross-producer consumers. -
Served management refusals carry their class in their error code, never in the message. The codes whose class is fixed:
Code Class Detail variant_unsupportedpermanent{model, variant, defined}wal_newer_writerpermanent{kind: "newer_writer", missing: [<feature names>]}One served admission refusal carries its class in its detail, because its class depends on which refusal routing sent:
Code Class Detail account_selection_refuseddetail.class:transientorpermanent{routing_code, class, retry_after_s?, retry_after_secs?, decision_id?}The fetch-plan admission refusal
scoped_tools_require_planis permanent and carriesdetail: {class: "permanent"}. A new send declaring non-empty legacytoolson a stamped route or an already-scoped session needs a fetch plan (carried by the send or already frozen in the session's manifest): scoped tool providers require a per-call preset, which only the manifest supplies. Nothing is fetched or written for this refusal. Existing scope and plan refusals retain precedence, and an already-admitted keyed resend keeps its existing answer. The served bytes are pinned bycrates/broca-wire/tests/golden/scoped_tools_require_plan.json.account_selection_refusedmeans the routing module would not choose a provider account for the send; nothing was written.routing_codeis routing's own refusal code, copied verbatim with itsretry_after_sanddecision_id. It istransientforaccount_hold,account_pool_exhausted,account_pool_reserve_limited,account_quota_unknownandaccount_selection_unavailable, withretry_after_secsset from routing's wait when it gave one;permanentforaccount_not_authorized,admission_conflictandinvalid_request. Callers branch onrouting_codeandclass, never on the message. The bodies are pinned bycrates/broca-wire/tests/golden/account_selection_refused.*.json.wal_newer_writermeans the session's WAL holds a complete frame that requires WAL features this build does not know: a newer broca wrote it. The bytes are intact and nothing was written, so retrying cannot help; the remedy is to place a build that knows every name inmissing. It is not in any retryable set. Every other failure to open a session's WAL, corruption included, staysopen_failed. Thewal_newer_writerbody is pinned bycrates/broca-wire/tests/golden/wal_newer_writer.json.open_failedcarries optionaldetail.classin the four-class shared vocabulary. Admission and run-resolution causes useRunError::admission_error_class, never message matching. Typed temporary WAL I/O (Interrupted,WouldBlock,TimedOut) is transient: the same read can succeed on retry. Unsupported handler operations are permanent. Opaque storage/write/replay/lifecycle errors remain unclassified, including corruption; neither a generic WAL error nor replay failure establishes retryability. A newer writer retains its dedicated code and unchanged body. When class is absent, no detail is added and legacy bytes are unchanged. Consumers must not invent a default class; they may keep their legacy fallback for unclassified errors. All other error bodies are unchanged.
broca-module::ServeError::Open has 14 production construction sites in
crates/broca-module/src/serve.rs, pinned by
open_failed_classes::every_open_failed_site_chooses_a_class:
| Site | Class | Reason |
|---|---|---|
final_message_of, missing RunStarted |
absent | Missing history supplies no retryable I/O cause. |
final_message_of, replay failure |
absent | Replay errors are not evidence that retry can succeed. |
final_message_of, missing assistant |
absent | Transcript shape alone does not establish a remedy. |
From<OpenError>, Open |
absent | Lease, write, replay and lifecycle causes were erased. |
From<OpenError>, Classified |
carried | Typed temporary WAL read I/O can succeed on retry. |
default head |
permanent | The handler does not implement the operation. |
default warm |
permanent | The handler does not implement the operation. |
default run_result |
permanent | The handler does not implement the operation. |
default baseline |
permanent | The handler does not implement the operation. |
send, Error |
absent | Actor storage, replay and lifecycle failures are opaque strings. |
send, ClassifiedError |
admission_error_class() |
The original admission/run-resolution RunError knows the remedy. |
retract, Error |
absent | Combined WAL read/write errors have erased causes. |
subscribe, Error |
absent | Attachment errors combine replay and channel failures. |
warm, Error |
absent | Combined replay, storage and actor failures have erased causes. |
The read classifier only marks Interrupted, WouldBlock and TimedOut transient;
permission errors and other I/O remain unknown. Corrupt WAL bodies remain byte-identical.
WalOpenError::Failed erases its cause in WalWriteError, so writes and recovery/proof
failures stay unknown rather than guessing from their message. NewerWriter is handled
separately before either conversion and never gains detail.class here.