# Migration Guide — GENC-1506 (the provider refused: a named, non-retried failure)

> **Additive — no migration required.** Nothing was removed, renamed, or given a new meaning. Every
> existing event name, public member, template `part` and failure reason behaves exactly as before.
> Upgrade, and the visible change is that a provider refusing your account produces one clear
> sentence instead of a generic apology — and stops costing doomed requests to discover.
>
> **There is deliberately no new UI.** Unlike GENC-1464 this adds no banner, no blocked state and no
> latch. See [Why no banner](#why-no-banner).
>
> All surfaces below are `@beta`.

---

## Why

When Anthropic refuses a request because the **account** cannot be served — its credit is gone, a
usage cap is reached, or the key is dead — nothing in the stack knew what that meant. The refusal fell
out of the transport as an untyped `Error`, landed in the chat driver's transient-retry catch, was
re-issued up to `MAX_SETUP_TRANSPORT_RETRIES` times against a wall no retry can clear, and the user
finally got:

> Sorry, something went wrong on my end. Please try again in a moment.

Wrong twice over: nothing went wrong on our end, and trying again will not help.

This is **not** the AI-spend budget wall (GENC-1464). That one is *us* declining to spend more on a
caller's behalf, and an administrator can raise the cap. This one is the *vendor* declining to serve
us at all, and nothing in our own system can clear it. The two were being conflated in code — see
[The 402 collision](#the-402-collision).

---

## What was added

### `@genesislcap/foundation-ai`

| Addition | Kind | Notes |
| --- | --- | --- |
| `ProviderRefusedError` | exported class | Fields: `vendorLabel`, `kind`, `upstreamStatus?`, `upstreamType?`, `detail?` |
| `ProviderRefusalKind` | exported type | `'spend' \| 'auth'` |
| `DEFAULT_PROVIDER_REFUSED_MESSAGE` | exported const | The one sentence shown to the user |
| `PROVIDER_REFUSED_CODE` | exported const | `'PROVIDER_REFUSED'` — the proxy's wire code |
| `'provider-refused'` | new `TurnFailureReason` member | Rides `ChatDriverResult.failureReason` and the `tool-loop-end` detail |
| `'provider_refused'` | new `SubAgentFailureReason` member | Terminal for the parent turn too |
| `ChatDriverResult.providerRefused` | new optional field on the `done` arm | `{ vendorLabel, kind, upstreamStatus?, upstreamType? }` |

### `@genesislcap/ai-assistant`

| Addition | Kind |
| --- | --- |
| `ChatDriverConfig.providerRefusedMessage` | new optional field — overrides the sentence |
| `DEFAULT_PROVIDER_REFUSED_MESSAGE`, `PROVIDER_REFUSED_CODE`, `ProviderRefusedError` | re-exported from the `./chat-driver` (Node) entry |

The chat driver gained a typed branch that sits **before** the transient-retry catch. It does not
retry; it appends the sentence to the transcript (or calls `failSubAgent('provider_refused')` when
running as a sub-agent) and ends the turn with `failureReason: 'provider-refused'`.

### Across the sub-agent boundary

`getSubAgentFailure()` now carries a `providerRefused` payload alongside the existing `budget` one, and
`invokeSubAgent()` latches the parent's terminal state from it.

Both halves are needed for a reason that is easy to miss: **a sub-agent is a separate driver instance**,
so anything the child sets on itself is invisible to the parent. The refusal reaches the parent only as a
tool result. Without the latch that result is ordinary, the parent issues another model call straight into
the same wall, and — if that call happens to succeed — the turn can finish with no `failureReason` at all,
reporting a clean turn over a dead account. Without the payload the parent can report `provider-refused`
but not which vendor or which kind, which is the only signal distinguishing "top up" from "rotate the
key".

First attribution wins (`??=`), matching `budgetWallDetail`: batched delegations can pair an attributable
child refusal with an unattributable one, and a plain assignment would let the later `undefined` erase
what the earlier child knew.

---

## One error, two kinds

`kind` distinguishes the operator's fix while the user's experience stays identical:

- **`spend`** — the account cannot pay: credit balance at zero, a workspace/organisation usage cap, or
  a billing/payment-information problem. Someone must top up or raise a cap.
- **`auth`** — the credential cannot be used: revoked, expired or malformed key (`401`), or a key
  without access to the requested model (`403`). Someone must rotate a key or grant access.

Both produce the **same sentence**. The kind reaches `ChatDriverResult.providerRefused.kind`, the
`turn.error` debug entry, and the transport error — never the transcript. That is deliberate: the copy
is cause-free (see below), so `kind` is the only place the distinction survives, and a host's own
alerting is what it exists for.

Two designs were rejected. A type named for spend would *lie* about an expired key, and a debug
timeline reading `provider-spend-refused` for an auth fault misleads the next engineer. Two separate
error types would duplicate a branch, a message and a wire code to express one outcome.

---

## The copy, and the constraints on it

> AI requests are currently being refused by the provider. This isn't related to your account or your
> usage and needs no action from you — please contact your support team so it can be restored.

It names **no cause**, and a rewrite has to keep it that way. Three constraints:

1. **It must not sound like a cap.** Users can often see their remaining AI spend in the same UI, so
   *allowance / limit / quota / budget / usage / credit / balance* risk a visible self-contradiction —
   a message saying there is nothing left, beside a figure showing there is. "or your usage" goes
   further than avoiding the words: it pre-empts the user checking that figure and concluding the
   message is wrong.
2. **It must not imply the bill went unpaid.** *Billing / payment / funds / top up* all read that way.
3. **It must not promise self-healing.** Clearing this needs a human, so "currently" not
   "temporarily", and "so it can be restored" signals that someone must act.

Vendor-neutral and deployment-neutral, so it is already safe for a white-labelled host. Override with
`ChatDriverConfig.providerRefusedMessage` if you want your own wording.

---

## Why no banner

GENC-1464 gave the budget wall a latched banner, a locked composer and per-vendor blocking state. This
deliberately reuses **none** of it (Matt, 2026-08-18): a provider refusal is a distinctly separate
condition from a user's own spend allowance and must not borrow its UI.

Consequences, both accepted:

- **The composer stays live.** A user can send again straight into the same wall. With the
  classification in place that costs exactly **one** refused request per send rather than the 3–18 it
  cost before, and the sentence is self-explanatory.
- **Nothing survives a reload**, because there is no latched state — and nothing needs to. There is
  also no pre-flight available even in principle: a refused account rejects `count_tokens` with the
  same error, so there is no free way to ask "can this account spend?".

---

## The 402 collision

`isBudgetRejection` treats **any** `402` as our own budget verdict, and `402` is also Anthropic's
documented `billing_error` status. A relayed upstream billing failure was therefore reported as the
*user's* spend cap — locking their composer and sending them to an administrator to raise a limit that
had headroom.

Closed by **ordering plus positive detection**, not by loosening the budget predicate:
`providerRefusalOf` runs first in `postWithRetry`, and every trigger in it is a positive signal, so a
bare `402` with no provider envelope still falls through to the budget branch exactly as before. Both
directions are pinned by tests in `post-with-retry.test.ts`.

---

## Detection: measured, not assumed

The classifier is built from **verbatim captures** of the real Anthropic API (2026-08-18), and the
fixtures in `provider-refused.test.ts` are those responses. This matters because the obvious design —
classify on the documented `402 billing_error` — would have fixed **nothing**:

| Condition | Actual response |
| --- | --- |
| Credit balance at zero | `400` + `invalid_request_error` + "Your credit balance is too low to access the Anthropic API…" |
| Workspace usage cap | `400` + `invalid_request_error` + "You have reached your specified workspace API usage limits…" |
| Invalid key | `401` + `authentication_error` + "API key is invalid." (`request_id` is **null**) |

**Reading the `kind` off an explicit-code response.** When the proxy stamps `PROVIDER_REFUSED`, the kind
is resolved from a stated `kind` in **either** wire position (top level on a JSON body, under `details` on
an err frame), then from the relayed provider envelope's own type, and only then defaults to `spend`.
Reading the top level alone reported every auth refusal through our own proxy as `spend` — it stamps the
code with the envelope in `details` and no top-level `kind` — which sends an operator to top up an account
when the fix is a credential rotation. Invisible on screen, since the copy is identical either way.

Both spend refusals arrive in the **generic** bucket with no distinct type, no dedicated header and
nothing structural — so a small list of independent phrase signals (`credit balance`, `usage limit`, …)
is the only handle available. `auth` needs no prose: status plus `error.type` is enough.

False positives are near-impossible for a non-obvious reason: on a refused account the spend check runs
*before* request validation, so a genuine schema `400` is only observable on a healthy account, where
its message quotes field paths and matches nothing in the list.

**Gemini is deliberately not classified.** Google surfaces a spend cap as `429 RESOURCE_EXHAUSTED` —
the same status *and message* as an ordinary rate limit, and `429` is retryable — so a prose probe
there would break the ladder. The structural discriminator that would work
(`details[].QuotaFailure.violations[].quotaId`) is only partly observed. `providerRefusalOf` dispatches
on vendor, so adding Gemini later is a branch, not a rewrite.

---

## Also in this change: the retry ladder honours the server

Found while measuring the Gemini rate limit, and folded in because it lives in the same function.

`postWithRetry`'s ladder was `base * 2^attempt` — 31s across five retries — and read neither
`Retry-After` nor Google's in-body `RetryInfo`. A real rate-limited key asked for **53s**, so a
*legitimate* rate limit burned every retry inside the window we had been told to wait, then failed
opaquely.

Now: the wait is `max(our ladder, what the server asked)`, bounded twice — `MAX_SERVER_REQUESTED_BACKOFF_MS`
(60s) per attempt, and `MAX_TOTAL_SERVER_REQUESTED_BACKOFF_MS` (120s) across the whole sequence. An
explicit `x-should-retry: false` stops the ladder even on a retryable status. An **absent**
`x-should-retry` means "no opinion" and changes nothing — never read as `false`.

The sequence bound is not belt-and-braces. The per-attempt cap alone is no bound at all: the ladder
tops out at 16s, so a 60s hint wins on *every* attempt, and five of them park a turn for five minutes
with no timer running through any of them (the attempt's own timeout is cleared before each backoff, so
a caller abort is the only way out). Past the budget we stop honouring hints and fall back to the
ladder — declining a hint never stops the retrying. Only the *excess over the ladder* is charged, so
the ladder's own growth cannot exhaust the allowance on time we were always going to spend. Worst case
is now ~151s rather than 300s, and the 53s case the module was written for still fits twice over.

> **This is the one part of the change that affects healthy traffic.** Some failures that used to give
> up after 31s will now wait longer before succeeding or failing. That is correct and it is what the
> server asked for, but it is a timing change on a shared path, so review it on its own terms.
