Two consumers are currently branching on this and it has never been published — the classification lived only in source comments and in whatever a consumer inferred from behaviour. Writing it down as contract.
Everything below is read from source, not from the design doc.
The wire class set is closed and golden-pinned
pub const ERROR_CLASS_WIRE_SET: [&str; 4] =
["transient", "permanent", "auth_required", "context_overflow"];
Branch on class. Never on code. The class set is pinned by a golden test; the code set is producer detail and will grow. A consumer switching on codes will break on the next added variant; a consumer switching on classes will not.
The mapping, read_surface.rs:378
code class meaning for a consumer
────────────────────────────────────────────────────────────────────────────────
not_found permanent handle revoked or unknown -- deliberately
indistinguishable (anti-enumeration)
corrupt permanent record quarantined
refresh_unsupported permanent static credential, no refresh path exists
kind_not_signable permanent sign request against a non-signing credential
────────────────────────────────────────────────────────────────────────────────
needs_reauth auth_required refresh token is dead; a HUMAN must log in
────────────────────────────────────────────────────────────────────────────────
refresh_failed transient refresh attempt failed, provider may recover
vault_locked transient master key unresolvable now (keychain/lease)
────────────────────────────────────────────────────────────────────────────────
too_many_items context_overflow over the get_many cap
sign_payload_too_large context_overflow over the signing-payload cap
ttl_unsatisfiable context_overflow a fresh token still misses your min_ttl_ms
context_overflow means REDUCE-AND-RETRY, never wait-and-retry
All three are bounds on one request, not statements about the credential. Each cap is a compile-time constant, so an identical request retried after any backoff fails identically forever.
This is not hypothetical: a careful consumer filed context_overflow into a retry-with-backoff arm, describing the codes as "transient load shapes". The name invites exactly that misreading. Reduce the request — fewer items, smaller payload, a lower min_ttl_ms — or stop.
not_found + permanent is authoritative "stop using this account"
A vault outage can never produce it. That is pinned by a test named an_unmapped_store_error_is_never_permanent, which exists because that exact mutation once left the whole workspace green. So a consumer can safely treat it as terminal rather than guessing whether the vault is merely unwell.
The internal classification, and the rule that governs adding to it
RefreshError (refresh_adapters/mod.rs:71) is what an adapter returns; InvalidGrant is the one that latches needs_reauth.
Its disposition is "unserviceable until a human acts" — NOT the OAuth error whose name it borrows. That distinction has already cost an incident:
- Obvious case: provider rejects the refresh with
400 invalid_grant. The refresh token is dead.
- Non-obvious case: a GitHub App with no installation. The key is fine, the JWT authenticates, and it still cannot serve until an operator installs the App. That was classified
Decode (wire class transient) until 2026-08-27, so consumers doing the correct thing for a transient error retried forever — one App JWT mint per attempt, against a vendor rate limit shared by every holder of that App.
The test when adding a variant: does a retry have any chance of succeeding without someone taking an action outside this process? If not, it is InvalidGrant whatever the provider called it.
Do not persist or forward the error strings
Every RefreshError variant carries raw provider response text. An OAuth error body can echo submitted parameters, so those strings are not safe for a plaintext column or a log. Use variant_name() and provider_status(), which carry the diagnostic value without the payload.
The auth_events vocabulary
Five kinds are emitted. These are the discriminators for "what actually happened", and they are what an operator or an analyser should read — not the record state, which collapses several distinct causes into one value.
consumer_report_stale a consumer reported 401/403 on a REFRESHABLE credential.
Record stays ACTIVE with stale_pending set; the next get
refreshes. This is the recoverable path.
consumer_report_latch same report against a NON-refreshable credential. There is
no refresh path, so it latches needs_reauth immediately.
refresh_failed an adapter ran and the provider rejected it. PROVES a real
upstream call happened.
stale_nonrefreshable_latch the short-circuit: a stale mark on a non-refreshable record
latches with NO upstream call, in milliseconds.
reconcile_needs_reauth startup reconciliation found a dangling refresh intent and
could not prove the token still valid.
refresh_failed vs stale_nonrefreshable_latch is the discriminator that makes timing experiments valid
Both produce a latched credential. Only the first involved a network call.
This matters for anyone measuring when a verdict lands: a non-refreshable record short-circuits to an immediate invalidate with no upstream call, so a forced get and a verdict arriving in the same millisecond reads as spectacular confirmation and proves nothing. Capture the event kind per run rather than assuming it — refresh_failed means the refresh path executed, stale_nonrefreshable_latch means it did not and the run is void.
Refreshability cannot be read before the fact: the credentials table has no kind column and refreshability lives inside the encrypted envelope. The emitted event kind is the stronger substitute because it names the branch that actually executed.
Two facts consumers keep having to rediscover
report_auth_failure is version-gated. The invalidate fires only if the credential is still at the record_version you were served, so a stale report about a since-refreshed credential is a silent no-op. This makes liberal reporting safe against staleness — and does nothing against misattribution. A report born from a credential the vault never issued, carrying a version that happens to be current, invalidates a healthy record. Provenance is unfalsifiable from the vault side; guard it structurally at your 401 site rather than inferring it from a cache lookup.
Report only when you believe the CREDENTIAL is invalid, never merely because a call was refused. The vault cannot interpret a status code — GitHub uses 403 for permissions and rate limits, xAI for an entitlement lapse. Same number, opposite meanings, and this surface sees only the number. On 2026-08-17 a consumer reported GitHub's 403 on a permissions failure, marked a seconds-old healthy credential needs-reauth, and every later call then refused at resolution — so the response logging built to diagnose the 403 could never fire. The consequence of the failure disabled the path to its own explanation.
Two consumers are currently branching on this and it has never been published — the classification lived only in source comments and in whatever a consumer inferred from behaviour. Writing it down as contract.
Everything below is read from source, not from the design doc.
The wire class set is closed and golden-pinned
Branch on
class. Never oncode. The class set is pinned by a golden test; the code set is producer detail and will grow. A consumer switching on codes will break on the next added variant; a consumer switching on classes will not.The mapping,
read_surface.rs:378context_overflowmeans REDUCE-AND-RETRY, never wait-and-retryAll three are bounds on one request, not statements about the credential. Each cap is a compile-time constant, so an identical request retried after any backoff fails identically forever.
This is not hypothetical: a careful consumer filed
context_overflowinto a retry-with-backoff arm, describing the codes as "transient load shapes". The name invites exactly that misreading. Reduce the request — fewer items, smaller payload, a lowermin_ttl_ms— or stop.not_found+permanentis authoritative "stop using this account"A vault outage can never produce it. That is pinned by a test named
an_unmapped_store_error_is_never_permanent, which exists because that exact mutation once left the whole workspace green. So a consumer can safely treat it as terminal rather than guessing whether the vault is merely unwell.The internal classification, and the rule that governs adding to it
RefreshError(refresh_adapters/mod.rs:71) is what an adapter returns;InvalidGrantis the one that latchesneeds_reauth.Its disposition is "unserviceable until a human acts" — NOT the OAuth error whose name it borrows. That distinction has already cost an incident:
400 invalid_grant. The refresh token is dead.Decode(wire classtransient) until 2026-08-27, so consumers doing the correct thing for a transient error retried forever — one App JWT mint per attempt, against a vendor rate limit shared by every holder of that App.The test when adding a variant: does a retry have any chance of succeeding without someone taking an action outside this process? If not, it is
InvalidGrantwhatever the provider called it.Do not persist or forward the error strings
Every
RefreshErrorvariant carries raw provider response text. An OAuth error body can echo submitted parameters, so those strings are not safe for a plaintext column or a log. Usevariant_name()andprovider_status(), which carry the diagnostic value without the payload.The
auth_eventsvocabularyFive kinds are emitted. These are the discriminators for "what actually happened", and they are what an operator or an analyser should read — not the record state, which collapses several distinct causes into one value.
refresh_failedvsstale_nonrefreshable_latchis the discriminator that makes timing experiments validBoth produce a latched credential. Only the first involved a network call.
This matters for anyone measuring when a verdict lands: a non-refreshable record short-circuits to an immediate invalidate with no upstream call, so a forced
getand a verdict arriving in the same millisecond reads as spectacular confirmation and proves nothing. Capture the event kind per run rather than assuming it —refresh_failedmeans the refresh path executed,stale_nonrefreshable_latchmeans it did not and the run is void.Refreshability cannot be read before the fact: the
credentialstable has nokindcolumn and refreshability lives inside the encrypted envelope. The emitted event kind is the stronger substitute because it names the branch that actually executed.Two facts consumers keep having to rediscover
report_auth_failureis version-gated. The invalidate fires only if the credential is still at therecord_versionyou were served, so a stale report about a since-refreshed credential is a silent no-op. This makes liberal reporting safe against staleness — and does nothing against misattribution. A report born from a credential the vault never issued, carrying a version that happens to be current, invalidates a healthy record. Provenance is unfalsifiable from the vault side; guard it structurally at your 401 site rather than inferring it from a cache lookup.Report only when you believe the CREDENTIAL is invalid, never merely because a call was refused. The vault cannot interpret a status code — GitHub uses 403 for permissions and rate limits, xAI for an entitlement lapse. Same number, opposite meanings, and this surface sees only the number. On 2026-08-17 a consumer reported GitHub's 403 on a permissions failure, marked a seconds-old healthy credential needs-reauth, and every later call then refused at resolution — so the response logging built to diagnose the 403 could never fire. The consequence of the failure disabled the path to its own explanation.