Skip to content

Contract: refresh-failure classification and the auth_events vocabulary, for consumers doing routing and backoff #18

Description

@iceteaSA

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions