# Migration Guide — GENC-1464 (AI budget exhausted: a first-class blocked state)

> **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 only visible change is that a
> spend-cap rejection now produces a clear, locked UI instead of a generic apology. The one
> exception is copy: the default banner sentence now names the vendor that refused — see
> [Per-vendor budgets](#per-vendor-budgets).
>
> All surfaces below are `@beta`.

---

## Why

When the ai-service proxy refuses a request because the AI-spend budget is used up, it
answers `HTTP 402` with `code: 'BUDGET_EXCEEDED'` (workstream C). Before this change the
client had no idea what that meant. A `402` fell through the transport as an untyped
`Error('Anthropic request error 402: …')`, landed in the chat driver's **transient-retry**
catch, and was re-issued up to `MAX_SETUP_TRANSPORT_RETRIES` times — three doomed requests
against a wall that no retry can clear — before the user finally got:

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

Which is wrong twice over: nothing went wrong *on our end*, and trying again in a moment
will not help. Worse, the composer stayed enabled, so the user's next message hit the same
wall.

Budget exhaustion is now modelled for what it is: a **terminal, out-of-band condition**,
in the same family as `ResponseTruncatedError` — deterministic, not retryable, and fixable
only by a human raising the cap.

---

## What was added

### 1. `@genesislcap/foundation-ai`

| Addition | Kind | Notes |
| --- | --- | --- |
| `BudgetExhaustedError` | exported class | Fields: `vendorLabel`, `budgetUsd?`, `spentUsd?`, `detail?`, `otherVendorAvailable?`, `serverVendorLabel?` |
| `DEFAULT_BUDGET_EXHAUSTED_MESSAGE` | exported const | The one copy shown to the user, shared by the transcript bubble and the assistant's banner |
| `'budget-exhausted'` | new `TurnFailureReason` member | Rides `ChatDriverResult.failureReason` and the `tool-loop-end` activity-bus detail |
| `'budget_exhausted'` | new `SubAgentFailureReason` member | The `{ ok: false }` branch of `requestSubAgent`. **Terminal for the parent turn too** — see [`sub_agent.md`](./sub_agent.md) |
| `ChatDriverResult.budget` | new optional field on the `done` arm | `{ budgetUsd?, spentUsd?, vendorLabel, vendor?, otherVendorAvailable? }` — what the `402` reported, present on a budget failure that carried figures **or** an attributable vendor |
| `VENDOR_LABELS` / `vendorTypeOfLabel` | exported const + function | The vendor display names every transport stamps, and the reverse lookup that turns one back into an `AIProviderType` |
| `BUDGETED_VENDORS` / `isBudgetedVendor` | exported const + function | The vendors the proxy meters (`anthropic`, `gemini`) — i.e. the ones a wall can name, mirroring ai-service's `VENDORS`. `chrome` and `openai` are in `VENDOR_LABELS` for display only: Chrome runs on-device with no pot to exhaust, and the proxy refuses `openai` up front with `400 UNSUPPORTED_PROVIDER`, so neither is ever walled |

### Transport coverage

The classification is keyed on **status OR code** — the contracted `402`, or
`code: 'BUDGET_EXCEEDED'` under any status — and in every wire mode it runs
*before* the retryable-status ladder. Both keys matter: `status` is what the
workstream-C contract guarantees and the only key a plain-text rejection carries,
while `code` is the cheap defence against that contract drifting. `429` is in both
transports' retryable sets, so a proxy that ever mapped the refusal onto it would
otherwise reproduce the exact bug this change removes — five doomed requests and
~31s of backoff before an opaque failure.

- **Legacy JSON** — `402` (or the code under another status) → `BudgetExhaustedError`,
  enriched from the body when it parses. An unparseable or plain-text `402` still
  classifies, just without the extra fields.
- **NDJSON framed** — a terminal `{ t: 'err', status: 402, code: 'BUDGET_EXCEEDED', error,
  details: { … } }` frame → the same typed throw, classified before the
  ladder exactly like `UPSTREAM_STALLED`.

The fields read off the rejection, top-level in the legacy body and under `details` in
the framed one (`error` stays top-level in both). Each is narrowed independently, so a
`null` figure — which the proxy sends in preference to a misleading `0` — does not
suppress the fields beside it:

| field | used for |
| --- | --- |
| `budgetUsd` | the cap in the banner's figures |
| `spentUsd` | the spend in the banner's figures |
| `error` | the proxy's human-readable detail |
| `vendor` | attribution when the transport's own label is unclaimed |
| `otherVendorAvailable` | whether "switch vendor" is honest advice at all |

`402` is deliberately **absent** from every transport's `RETRYABLE_STATUSES`, and a
regression test in each transport's suite asserts `fetch` is called exactly once, so it can
never quietly join the ladder. (`429` still retries — also pinned by a test.) A separate
`post-with-retry.test.ts` pins the *ordering* directly, by injecting a retryable set that
does contain `402`.

**Which transports are covered, precisely** — the detection lives in the shared
`postWithRetry` envelope, but not every transport delegates to it:

| Transport | Covered | Notes |
| --- | --- | --- |
| `AnthropicTransport` | ✅ both `chat()` and `sendStructuredPrompt` | Goes through `postWithRetry` |
| `GeminiTransport` | ✅ both | Goes through `postWithRetry` |
| `ServerOpenAITransport` | ⚠️ typed, but invisible | Does its **own bare `fetch`**, so it classifies the `402` separately. It has no `chat()` path — it serves only `sendStructuredPrompt`, whose single caller `OpenAIProvider.interpretCriteria` catches everything and returns `null`. So a `402` there degrades smart-search **silently** and can **never** latch `blocked`, before or after this change. The typed throw improves the log line and helps a host that catches it directly. |
| `ChromePromptTransport`, `DefaultTransport` | n/a | Local / no-op; there is no proxy and nothing to classify |

Routing `ServerOpenAITransport` through `postWithRetry` wholesale was considered and
declined: it has no retry ladder, no stall timer and no framed mode, so that is a
transport rewrite rather than a fix.

### 2. `@genesislcap/ai-assistant`


| Addition | Kind |
| --- | --- |
| `FoundationAiAssistant.blocked` | public getter/setter (`boolean`) |
| `FoundationAiAssistant.blockedReason` | public getter **and setter** (`string \| null`) |
| `FoundationAiAssistant.setBlocked(blocked, reason?)` | public method |
| `part="blocked-banner"` | new template part (always rendered — see [Accessibility](#accessibility)) |
| `blocked` / `blockedReason` + `setBlocked` | session-slice state and action |
| per-vendor blocking (`blockedVendors`, `setVendorBlocked`, …) | see [Per-vendor budgets](#per-vendor-budgets) |
| `ChatDriverConfig.budgetExhaustedMessage` | new optional field — overrides the transcript copy |

The chat driver gained a typed `BudgetExhaustedError` branch that sits **before** the
transient-retry catch. It does not retry; it appends `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`

> You've reached your AI usage limit. Contact your administrator to raise it.

to the transcript (or calls `failSubAgent('budget_exhausted')` when running as a sub-agent),
and ends the turn with `failureReason: 'budget-exhausted'`.

That copy is deliberately **vendor-neutral**, because the same bundle ships to
white-labelled deployments. Both surfaces are overridable, and you should override
both together or neither: the banner via `setBlocked(true, reason)`, the transcript
bubble via `ChatDriverConfig.budgetExhaustedMessage` (the assistant element passes
your `blockedReason` through automatically, falling back to the shipped default).
Overriding only the banner used to leave a host reading its own wording directly
above the shipped default.

### `setBlocked` and the reason

`reason` distinguishes three cases, not two:

| Call | Effect |
| --- | --- |
| `setBlocked(true, 'text')` | Blocks and sets the explanation |
| `setBlocked(true)` — reason **omitted** | Blocks and **keeps** any existing explanation |
| `setBlocked(true, null)` | Blocks and clears the explanation (banner falls back to the default) |
| `setBlocked(false)` | Unblocks, always clears the explanation, **and clears every per-vendor latch** |

The "omitted keeps" rule is load-bearing. The block has two writers — you, and the
driver's automatic latch — and the driver's write can land *after* yours: the
`tool-loop-end` bus event is published from the driver's `finally`, i.e. before
`sendMessage()` resolves. Treating an omitted reason as "clear it" meant a host
that set detailed copy from the bus had it replaced with generic copy a moment
later, every time.

---

## The blocked state

`blocked` follows the existing `compacting` / `restoring` pattern — session-slice-backed, so
it stays in sync across pop-in/pop-out — with **two deliberate differences**:

1. **It renders alongside the transcript, not instead of it.** `compacting` and `restoring`
   replace the message list with a status view, which is right for a transient rewrite where
   history is mid-flight. A block is indefinite, so hiding the conversation behind it would
   strand the user's work behind a wall they cannot clear. Instead a persistent banner
   (`part="blocked-banner"`) sits above the composer, with the transcript fully readable.
2. **It is latched.** Nothing in the element clears it — not a new turn, not
   **Clear** / **New chat** (`resetSession` explicitly preserves it: starting a new chat does
   not refill the budget), not a pop-in/out. It stays set until the host writes
   `blocked = false`.

While blocked: the composer textarea and send button are disabled, the attach button is
disabled, the suggestion chips are hidden, the placeholder swaps to
`AI usage limit reached`, no suggestion requests are issued (each one would be a
guaranteed `402`), and `send()` / `submitMessage()` refuse. `submitMessage` returns
`{ ok: false, errors: [<the blocked reason>] }` rather than the transient
`'Assistant is busy'`, so a caller looping on "busy" doesn't spin forever.

> **Unrelated fix riding along.** The attach button now shares the text area's full
> disable gate. It previously checked only `busy`, so `compacting`, `restoring` **and**
> `blocked` were all missing from it — meaning files could be attached during a
> compaction or a restore and then never sent, which was already a bug before this
> feature existed. If you relied on attaching mid-compaction, that stops working.

### Scope and lifetime

`blocked` lives on the **session store**, so it is per-`stateKey` and **in memory**.
Two consequences that you must design around rather than discover:

- **It does not survive `switchSession`.** Switching away tears the outgoing
  session's store down completely, so switching back yields a fresh, unblocked
  store. The old session's value is *not* retained.
- **It does not survive a page reload.** It is deliberately **not** in the
  persisted session snapshot. The snapshot is long-lived, so a persisted latch
  would outlive an out-of-band budget raise with no in-element way to clear it —
  the user would come back to a permanently dead composer against a budget that
  has headroom. Re-deriving the state is strictly safer than remembering it.

So the durable source of truth is **your pre-flight** (Option B below), which
should run on mount *and* on every session switch. The driver latch is a safety
net for the first `402` within a live session, not a store.

One thing the latch *does* cross: the `tool-loop-end` topic it rides is forwarded
on the **tab-scoped** channel, so a wall hit in a popped-out window locks the main
window's composer immediately too. That is correct for a shared spend cap and
consistent with how the halo and loop events already propagate.

---

## Per-vendor budgets

The AI-spend budget is **per vendor**: each vendor gets its own cap (the
ai-service side counts `SpentUsdAnthropic` / `SpentUsdGemini` alongside the
untouched combined `SpentUsd`, which is now reporting-only). Internal
(`@genesis.global`) users and local development stay unlimited on every vendor.
So one vendor can be exhausted while another still has headroom, and the
assistant models exactly that.

**This is not breaking.** `blockedVendors` starts empty, and while it is empty
`blocked` reads exactly as it always did.

### What was added

| Addition | Kind |
| --- | --- |
| `FoundationAiAssistant.blockedVendors` | public getter (`readonly AIProviderType[]`) — no setter |
| `FoundationAiAssistant.isVendorBlocked(vendor)` | public method |
| `FoundationAiAssistant.setVendorBlocked(vendor, blocked, reason?)` | public method — mirrors `setBlocked` |
| `FoundationAiAssistant.reachableVendors` | public getter (`readonly AIProviderType[]`) |
| `blockedVendors` / `blockedVendorReasons` + `setVendorBlocked` / `setBlockedReason` | session-slice state and actions |
| `ChatDriverResult.budget.vendor` | new optional field — the typed vendor beside the existing `vendorLabel` |
| `VENDOR_LABELS`, `vendorTypeOfLabel` | exported from `@genesislcap/foundation-ai` |
| `.is-visible` / `.is-partial` | banner classes — `is-visible` whenever the banner has copy, `is-partial` layered on top for the some-vendors-walled state |

### `blocked` is now derived

It reads **"every vendor the registry can currently reach is walled"**, plus the
vendor-agnostic flag. Concretely:

- Reading `blocked` is unchanged for you.
- Writing `blocked = true` / `setBlocked(true, …)` still sets the **vendor-agnostic**
  block — one pot, which is what a single-budget host means. It does not populate
  `blockedVendors`.
- Writing `blocked = false` / `setBlocked(false)` now **also clears every
  per-vendor latch**. That is deliberate: it is what Option B's pre-flight already
  meant, so Option B code is unchanged. A host asserting "the wall is gone" must
  not be overruled by a driver latch it cannot see.

### There is deliberately no "which vendor will the next turn use?"

And there will not be one — no `peekVendorForNextTurn()`. The provider is
resolved **per turn and per agent**: `activeProviderInput` may be an async
function of the turn's context, `OrchestratingDriver` picks its agent with an LLM
`classify()` call, and sub-agents resolve their own. Any pre-turn answer would be
a guess that is wrong precisely on the multi-agent hosts per-vendor budgets exist
for. Resolving against the registry *default* alone was also rejected: with a
mixed registry it locks the composer in the wrong direction.

The element asks the answerable question instead — which vendors are
**reachable**, read from the provider statuses it already loads — and locks only
when all of them are walled.

The one place the exact vendor *is* knowable is suggestions: both suggestion
paths call `providerRegistry.default()`. Suggestion chips therefore hide (and no
request is issued) only when the **default** vendor is walled.

### Partial exhaustion: the composer stays live

While at least one reachable vendor has headroom the composer stays enabled, the
placeholder stays the host's, and `send`/`submitMessage` keep working. The banner
appears and names the walled vendor:

> Anthropic's AI usage limit is reached ($500.00 of $500.00). Switch to Gemini to keep going.

A turn is therefore **allowed to run and fail** if it happens to route to the
walled vendor. That is intentional and honest — there is no auto-switching,
because silently moving the user to another vendor would silently downgrade model
quality. The banner advises; the user (or the host) switches.

When every reachable vendor is walled the banner asks for a raise instead:

> Anthropic's AI usage limit is reached ($25.40 of $25.00). Contact your administrator to raise it.

…and with several walled vendors carrying no copy of their own, the figures do not
fit one sentence, so it names them:

> AI usage limits are reached for Anthropic and Gemini. Contact your administrator to raise them.

A host `blockedReason` still outranks all of it, and `setVendorBlocked(v, true,
'…')` supplies per-vendor copy that is composed into the sentence — **however many
vendors are walled**. As soon as any walled vendor carries copy, the banner reads
one statement per vendor instead of the compact list above, so nothing you set is
dropped when a second vendor goes:

> Anthropic spend is capped for this desk. Gemini's AI usage limit is reached. Contact your administrator to raise them.

A walled vendor with no copy falls back to the boilerplate sentence in that form
(`Gemini's …` above), so the set of statements always covers every vendor named.

The **transcript bubble** stays vendor-neutral. The assistant element passes
`blockedReason ?? DEFAULT_BUDGET_EXHAUSTED_MESSAGE` as
`ChatDriverConfig.budgetExhaustedMessage` — deliberately NOT the composed banner
copy. Only the vendor-agnostic latch writes `blockedReason`, and its wording
carries no switch clause, so the composed "switch to Gemini" advice is
structurally incapable of reaching the transcript. Per-vendor copy lives in
`blockedVendorReasons` and reaches the banner alone.

Ordering alone would not be enough to guarantee this: the driver reads the value
once at construction, but `getOrCreateDriver` keys on the agents list, so an
agents swap *after* a wall rebuilds the driver and would re-snapshot whatever the
banner said at that moment. The split is deliberate — the transcript is a
permanent record of the turn, and advice that stops being true the moment the user
switches vendor belongs on the banner.

> **Copy change.** With a recognised vendor label on the `402`, the default
> full-exhaustion banner now names the vendor ("Anthropic's AI usage limit is
> reached (…)") where it previously said "AI usage limit reached (…)". The generic
> `DEFAULT_BUDGET_EXHAUSTED_MESSAGE` fallback (no figures, no vendor) is unchanged,
> as is `BLOCKED_PLACEHOLDER`.

### Vendor-switch recovery is automatic — with one host obligation

Recovery is a **derivation**, not a mutation. Switch your registry to another
vendor and the observable fires, the provider statuses reload, the walled vendor
drops out of `reachableVendors`, and `blocked` goes false — with the latch left
intact, so switching back re-derives the block. No host code, and nothing can
unblock mid-turn.

**This requires an `ObservableAIProviderRegistry`** (i.e. `registerAIProviders` /
`MutableAIProviderRegistry`), which the element feature-detects. A host with an
immutable registry that changes vendors some other way must call
`setVendorBlocked(vendor, false)` itself. That is the single host obligation this
feature adds.

Also note the fail-safe: if your providers do not implement the optional
`AIProvider.getStatus()`, `reachableVendors` is empty and **any** wall blocks
everything — identical to the pre-per-vendor behaviour, but with no per-vendor
benefit. A `logger.warn` fires the first time that happens.

### Attribution, and when it degrades

A wall is attributed by reverse-looking-up the `vendorLabel` the refused
transport stamped, via the shared `VENDOR_LABELS` map (which the transports now
read their labels from, so the two cannot drift). Where that label is unclaimed —
a white-labelled or multiplexing gateway fronting several upstreams behind one
transport — the `402`'s own `vendor` is tried next. Only if neither resolves does
the wall degrade to the **vendor-agnostic** block — safe, and exactly the old
behaviour.

Attribution does **not** depend on the proxy reporting figures. A figure-less
`402` (a bare gateway rejection, a code-only body, or a framed error whose figures
are `null`) still carries the vendor, so the wall lands on the transport that was
refused rather than on whatever the driver resolved on the previous turn.

### `otherVendorAvailable`: the one thing only the server knows

The registry tells this element which vendors **exist**. Nothing client-side can
tell it which vendors still have **budget**. So when the `402` reports
`otherVendorAvailable: false`, that verdict overrules the inference: every other
*budgeted* vendor is walled in the same pass, the composer locks on the first
response instead of after a second doomed turn, and the banner stops advising a
switch to a vendor whose pot is equally spent.

The sweep runs over `BUDGETED_VENDORS`, **not** over `reachableVendors`. The
verdict is a statement about the proxy's own pots, so it must not be scoped to a
registry snapshot that loads asynchronously and may not have arrived yet — a wall
that beat the statuses home would otherwise discard the verdict entirely, with
nothing to re-run it afterwards.

One consequence for hosts: `blockedVendors` may therefore list a vendor your
registry cannot reach. That is deliberate and costs nothing —

- `blocked` only asks whether every **reachable** vendor is walled, and
- the banner reads the reachability-filtered list, so it still names only vendors
  you can route to (see [The banner names only vendors you can
  reach](#the-banner-names-only-vendors-you-can-reach)).

…but if you render `blockedVendors` yourself, filter it against your own registry
rather than showing it raw.

One boundary remains: only `BUDGETED_VENDORS` are swept. A Chrome provider is
on-device and unmetered, so `Switch to Chrome to keep going.` remains honest.

An **absent** `otherVendorAvailable` (an older proxy) is not a `false` — the
client is back to inferring, and behaviour is exactly as before.

### The banner names only vendors you can reach

Both the walled list the banner names and the free list it offers are filtered by
`reachableVendors`. A wall the registry has since moved away from cannot be hit,
so it is neither named nor announced — a host that ships Anthropic-only, walls,
then swaps to Gemini does not get "limits are reached for Anthropic and Gemini"
in front of a user who never had an Anthropic key. When nothing is reachable the
filter falls back to the unfiltered list, matching the fail-safe `blocked` takes
there.

### `'none'` is not a vendor

`setVendorBlocked('none', …)` is rejected with a `logger.warn` and does nothing.
`'none'` is the no-provider sentinel: latching it would render "none's AI usage
limit is reached" and could never be released by derivation, because `'none'`
never appears in `reachableVendors`. Use `setBlocked` for a vendor-agnostic block.

### Scope and lifetime are unchanged

The per-vendor latches live where the boolean lives: **in memory**, on the
session store, per-`stateKey`. They do not survive `switchSession`, they do not
survive a page reload, and they are deliberately absent from the persisted
snapshot. Your pre-flight remains the durable source of truth.

### Styling

`.blocked-banner.is-visible` carries every visual property and applies whenever
the banner has something to say — including while only **some** vendors are walled
(banner visible, composer live). It is named for visibility rather than for
`blocked` precisely because that partial state has `blocked === false`. Add
`.is-partial` if you want a distinct treatment for it; it is layered on top and
sets nothing but a softer border by default.

---

## How to adopt

### Option A — do nothing

The assistant latches `blocked` itself when a turn ends with `'budget-exhausted'`, so a
host that does no budget checking at all gets a correct locked UI the moment the first
`402` lands, with no code change. It latches from **two** seams, because neither covers
everything:

- the `tool-loop-end` activity-bus detail — which also fires for a **sub-agent's** wall,
  for a turn this element did not start, and for an element swapped in mid-turn
  (pop-in/pop-out), and which crosses to popout windows on the tab-scoped channel;
- the driver's **return value** — which covers the one case the bus cannot: a wall hit
  during multi-agent **classification**, where no tool loop ran and so no
  `tool-loop-end` was published.

Both go through one internal decision point, so the latch is idempotent and never
unblocks.

**What the banner says.** With no host reason set, the banner shows whatever figures the
proxy reported and names the vendor that refused — `Anthropic's AI usage limit is reached
($25.40 of $25.00). Contact your administrator to raise it.` — falling back to the generic
default copy when the `402` carried no figures and no recognisable vendor. When another
vendor still has headroom the closing sentence becomes `Switch to Gemini to keep going.`
instead; see [Per-vendor budgets](#per-vendor-budgets). If you want host-specific wording
(a link to your billing page, a named contact), that is Option B.

### Option B — pre-flight (required if the block must survive a reload or session switch)

Because the latch is in-memory and per-`stateKey` (see [Scope and lifetime](#scope-and-lifetime)),
this is the only way to get a correct locked UI **on mount** and **after a session
switch**. Run it in both places. It also blocks up front, before the user composes a
message that is guaranteed to fail:

```ts
async function applyBudgetGate(assistantEl: FoundationAiAssistant) {
  const { withinBudget, budgetUsd, spentUsd } = await checkAiBudget();
  assistantEl.setBlocked(
    !withinBudget,
    withinBudget
      ? null
      : `AI usage limit reached ($${spentUsd} of $${budgetUsd}). Contact Genesis to increase it.`,
  );
}

// on mount…
await applyBudgetGate(assistantEl);
// …and in your own session-switch handler
await applyBudgetGate(assistantEl);
```

Pass no `reason` to use the default copy:

```ts
assistantEl.blocked = true;
```

Release it once a later check shows headroom (unblocking always clears the reason, so a
stale explanation can never outlive the condition):

```ts
assistantEl.blocked = false;
```

### Option C — branch on the typed failure

For custom UI, telemetry, or a host-owned composer:

```ts
import { BudgetExhaustedError } from '@genesislcap/foundation-ai';

// …from a driver result — the figures now ride along, so you do not have to
// catch the transport error to get them:
if (result.reason === 'done' && result.failureReason === 'budget-exhausted') {
  showUpgradePrompt({ budgetUsd: result.budget?.budgetUsd, spentUsd: result.budget?.spentUsd });
}

// …or from the `tool-loop-end` activity-bus detail, which also carries the vendor:
agenticActivityBus.subscribe('tool-loop-end', (d) => {
  if (d?.failureReason === 'budget-exhausted') showUpgradePrompt({ vendor: d.vendor });
});

// …or directly around a transport/provider call:
try {
  await provider.chat(history, input);
} catch (e) {
  if (e instanceof BudgetExhaustedError) {
    showUpgradePrompt({ budgetUsd: e.budgetUsd, spentUsd: e.spentUsd });
  }
}
```

> If you set `blocked` from the bus subscription, note it fires **before**
> `sendMessage()` resolves — the element's own latch runs afterwards and is a
> no-op while already blocked, so your copy is preserved.

Note that `TurnFailureReason` and `SubAgentFailureReason` are **open unions that grew**. If
you `switch` exhaustively over either with a `never` guard, TypeScript will now flag the new
member — that is the intended prompt to decide how your app should treat it, not a breaking
change to runtime behaviour.

### Feeding the budget meter

Orthogonal to Options A–C (combine it with any of them): the settings modal can show a
**per-vendor budget meter** — one row per vendor with `$spent / $limit (P%)` text and a
progress bar — but only if the host feeds it. The element meters nothing itself; the
meter renders exactly the vendors that currently have figures, fed through the public
`setVendorBudget(vendor, figures)` API from the same budget endpoint the pre-flight
(Option B) already consumes:

```ts
async function feedBudgetMeter(assistantEl: FoundationAiAssistant) {
  // `vendors` is an OBJECT keyed by vendor id, not an array.
  const { vendors } = await (await fetch('/api/budget')).json();
  for (const v of Object.values(vendors)) {
    // An UNLIMITED vendor (`unlimited: true` / `limitUsd: null`) gets no row —
    // simply do not feed it. There is no "unlimited" figure shape: a bar with
    // no cap has nothing truthful to fill to.
    if (v.unlimited || v.limitUsd == null) {
      assistantEl.setVendorBudget(v.vendor, null);
      continue;
    }
    assistantEl.setVendorBudget(v.vendor, { budgetUsd: v.limitUsd, spentUsd: v.spentUsd });
  }
}

// on mount (alongside the Option B pre-flight, if you run one)…
await feedBudgetMeter(assistantEl);
// …and re-run it after every completed turn, so the meter tracks the spend
// that turn just caused.
```

What to expect from that loop:

- **Re-feed after each turn, unconditionally.** The call is idempotent — identical
  figures are a stored no-op — so there is no cheaper "only if changed" protocol worth
  building. `setVendorBudget(vendor, null)` clears a vendor's row.
- **The figures lag by one turn, and that is expected.** The server meters a turn's
  spend as it happens, but the host only re-fetches after the turn completes — so the
  meter the user watches *during* a turn shows the total up to the previous one. The
  meter is a gauge, not a live ticker; the authoritative gate is the server's (the 402
  and the pre-flight), which reads the live account.
- **Spend can exceed the limit.** The last allowed call may overshoot the cap, and the
  row's text shows the real figures (`$62.34 / $50.00 (125%)`); only the bar clamps
  at 100%.
- **Validation is forgiving at run time.** An unbudgeted vendor (`chrome`, `openai`,
  `'none'`, anything unknown) or non-finite/negative figures make the call return
  `false` — with a single warning per element, not one per turn — and change nothing.
  `undefined` figures behave exactly like `null` and clear the row, so an untyped
  per-vendor lookup that misses is a clear, not a crash.
- **`false` also means "too early".** A feed that lands before the element has its
  session store — ahead of the first append, or during a pop-out remount — stores
  nothing and returns `false`, without a warning (a lifecycle window is not bad
  input). Nothing to handle: the per-turn re-feed above lands the figures as soon as
  the element is ready.
- **Lifetime follows the blocked latch, minus persistence.** The figures survive
  pop-in/out and "Clear" / "New chat" (a new chat refills no budget), but are *not*
  written to the session snapshot: the server is authoritative, and your mount-time
  feed is what restores the meter after a reload — a persisted copy could only ever
  show stale dollars.
- **Config:** `chatConfig.ui.showBudgetUsage: false` hides the meter (default is on,
  like `showContextUsage`). Styling hooks: `::part(budget-usage)` and
  `::part(budget-progress)` — see `docs/styling.md`.

---

## Styling the banner

The banner is themed from the design system's own tokens and is addressable as a part:

```css
foundation-ai-assistant::part(blocked-banner) {
  border-color: var(--my-warning-color);
  background: var(--my-warning-surface);
}
```

It carries `role="status"` — a standing condition the user can also read off the disabled
composer, not an interruption worth an `alert`.

### Accessibility

The banner element is **always in the DOM**, and only its text changes when `blocked`
flips. This is deliberate and load-bearing: `role="status"` announces *mutations* to a
region the assistive technology is already observing, and a region inserted into the DOM
already carrying its text is widely missed across NVDA, JAWS and VoiceOver. Keeping it
mounted makes the text change the observable event.

Consequences for hosts:

- `::part(blocked-banner)` now matches at all times. While unblocked the element is empty
  and carries no padding, border or background, so it occupies **zero** space — every
  visible property hangs off an internal `is-visible` class. If you style the part, put
  your visual overrides where they only apply to the populated state (e.g. keep using the
  border/background properties, which the empty state does not set) rather than adding
  `min-height` or `padding` unconditionally.
- The composer's text area additionally carries `aria-disabled="true"` and an `aria-label`
  containing the blocked reason while blocked, so the control's own accessible name states
  why it is unavailable — a disabled control leaves the tab order, so its swapped
  placeholder is never voiced.
- The banner has an `id`, and the text area, **send** button and **attach** button carry
  `aria-describedby` pointing at it whenever the banner is visible — including during
  **partial** exhaustion, where the composer is deliberately still enabled. That state has
  no other accessible signal: `aria-disabled`/`aria-label` are keyed on the full block, and
  a live composer keeps your own placeholder, so a screen-reader user would otherwise reach
  the textarea with nothing to say the next turn might be refused. `role="status"` announces
  the banner text when it changes; the description is what makes the same explanation
  reachable afterwards, on demand, from the control it is about. It is also what gives
  send/attach any explanation at all — they carried none in either state.

  The id is shadow-DOM-scoped, so it cannot collide with your page. If you re-template the
  composer, keep the reference: `aria-describedby` resolves same-root only.
