# Cap spend per virtual key

Give a tenant a hard monthly USD budget — mint the key, shadow the limit, preview the would-refuses, then enforce.

Source: https://tokentriage.com/docs/recipes/cap-spend-per-virtual-key

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';

Put a hard monthly USD ceiling on a single tenant (or one virtual key) so that when it burns through its budget, further requests are refused, with each refusal recorded rather than silently dropped.

## Prerequisites

- Governance is armed — `governance.enabled: true`, or any entry under `governance.limits`.
- A decision log is configured (`ledger.decision_log.path`, which defaults to `~/.tokentriage/decisions.jsonl` and needs no setup), so `budget_usd` (a LedgerSettled limit) has rows to fold. A hard-enforced `budget_usd` limit will not arm without one. The trust-ladder `mode:` is not part of this: the log is written under `ledger`, `shadow` and `route` alike.
- Your tenancy tree is declared under `governance.tenants` (or created via the management API); the node you cap must exist.

<Callout type="note">
`budget_usd` settles **off the decision log**, not on the request hot path, so admission does not block waiting for pricing. That is why the decision log is the hard prerequisite, and why a hard budget with `ledger.decision_log.path` emptied fails validation rather than arming a limit it cannot settle.
</Callout>

## 1. Mint a virtual key for the tenant

Mint a `ttv_`-prefixed key bound to the owner node you want to cap. The secret is printed **once**; what is stored is a SHA-256 hash of it plus a four-character stub for matching.

```console
$ tokentriage keys mint \
    --owner org:acme/team:ml/user:dev1 \
    --label "dev1 laptop" \
    --expiry 720h
```

You should see:

```console
kid:     vk_FEKZdMs2UQRi
expires: 2026-11-07T20:41:14Z
This secret is shown ONCE and cannot be recovered — copy it now:
ttv_EeNpxgdtQYWP…
```

The caller presents it as `Authorization: Bearer ttv_…`.

<Callout type="warning">
`--expiry` is a **Go duration**: `720h` for 30 days, `24h` for a day. It is **not** `90d` or `30d`, because the CLI does not parse day suffixes. Omit the flag (or pass `0`) for a key with no expiry. The `governance.keys.default_expiry` config key does not fill this in; nothing reads it in this build.
</Callout>

## 2. Attach the budget in shadow first

Add a `budget_usd` limit attached to that tenant, in `mode: shadow`. Shadow runs a full evaluation and records a `would_refuse` on the ledger row, and the engine cannot turn a request away while in shadow, so you gather evidence with no blast radius.

Attach the limit to the user node. Pin it by name rather than reaching for `attach: { level: key }`: that template has no key-level nodes to materialize against in this build, so it resolves to nothing and enforces nothing.

```yaml
governance:
  enabled: true
  limits:
    - id: dev1_monthly_budget
      attach: { node: org:acme/team:ml/user:dev1 }
      type: budget_usd
      amount_usd: 200          # dollars-decimal; stored on the nanodollar grid
      period: month            # day | week | month
      posture:
        mode: shadow           # evaluated, records would_refuse, stops nothing
```

Reload the proxy and let live traffic flow through the key. Every request that *would* have breached the cap is stamped `would_refuse: [dev1_monthly_budget]` on its decision-log row.

## 3. Read the would-refuses with `govern preview`

Dry-run the proposed limit set over your logged traffic. Preview folds the ledger through the **same** accounting the live proxy uses, so the `budget_usd` numbers are recomputable by hand.

```console
$ tokentriage govern preview -c tokentriage.yaml
```

The run below is from a sandbox whose upstream is a stub, so the dollar figures are cents of a cent and the cap was set low enough to trip on a handful of requests. It is cut back to the two parts this step turns on, with `# …` where lines were dropped; [turn governance on](/docs/build/governance-rollout#preview-the-cap-against-traffic-you-already-have) prints a report of the same command in full. The shape is what to read:

```console
# …
EXACT — LedgerSettled recomputation (hand-count-verifiable, exact):
  [budget_usd] dev1_monthly_budget @ org:acme/team:ml/user:dev1  burn $0.000038 / limit $0.000020  (frac 1.8900, rows 3, unpriced_excluded 0)  ⟵ WOULD REFUSE NOW
# …
WOULD BE REFUSED RIGHT NOW (exact over the recorded ledger; the LIVE gate may lag
by its disclosed overrun bound — in-flight/streaming rows not yet folded):
  [budget_usd] dev1_monthly_budget @ org:acme/team:ml/user:dev1  burn $0.000038 / limit $0.000020
```

The `⟵ WOULD REFUSE NOW` marker and the `WOULD BE REFUSED RIGHT NOW` block name the tenants the cap would bite today, and `frac` is burn over limit. Preview folds the ledger through the same accounting the live proxy uses, so a `budget_usd` figure here is recomputable by hand from the rows. If the numbers look right, promote it. Add `--json` for the machine-readable shape, or `--log <path>` to preview a specific log. The `-c` flag is required despite advertising a default, and the command errors rather than printing an empty report if the log does not exist yet.

## 4. Promote to enforce — soft, then hard

Climb the ladder one rung at a time. First `enforce: soft` (`mode: on`), where the limit is evaluated in full and a breach lets the request through anyway:

```yaml
      posture:
        mode: "on"
        enforce: soft                 # a breach lets the request through
        thresholds: [0.5, 0.8, 0.95]
```

The `thresholds` fire on fraction crossings from the accountant, whatever the mode, so this is the rung where you confirm the alerting wiring is reaching you without risking traffic. Note that a soft breach writes nothing to the decision row: `would_refuse` is recorded by `mode: shadow` only. If you want the breach itself to alert, put `1` in `thresholds`.

Once you trust the alerts, flip to `enforce: hard` with `fail: open`:

```yaml
      posture:
        mode: "on"
        enforce: hard                 # stops the request on breach
        fail: open                    # prefer availability; disclose a suspended accountant
        thresholds: [0.5, 0.8, 0.95]
```

Reload. A breaching request now gets a **429** carrying `error.refused_by.limit_type: budget_usd` and `unit: usd`, and one function stamps that same value onto the response and onto a single decision-log row, so the two agree.

It comes back like this, from the same sandbox:

```console
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Ratelimit: "org_monthly_budget";r=0;t=1912510
Retry-After: 1912510
X-Tt-Refusal-Type: budget_exceeded
X-Tt-Refused-By: org_monthly_budget
X-Tt-Request-Id: tt_i3f2naenug5aanctu6pcjrofcy
```

`Retry-After` is the seconds until the calendar period rolls, which for a monthly budget stopped early in the month is most of a month. Read the body for `observed` against `limit`, and expect `observed` to sit above the cap: a `budget_usd` counter is folded from ledger rows after the fact, so it discovers the breach slightly late and says so. [How a limit counts](/docs/build/governance-enforcement-classes) has the mechanism.

## Why this is safe

<Callout type="warning">
**`fail: open` against `fail: closed` decides what happens when the accountant behind the budget is stale or dead.**

- **`fail: open`** (recommended). Enforcement is **suspended**, traffic keeps flowing, and the decision row discloses `suspended: [dev1_monthly_budget]` while the response carries `x-tt-coordination: suspended`. (The health panel's `suspensions` list is a third surface, and it only populates once a shared-state tier is configured; on a single node it stays empty and the row is the disclosure.) Nothing is blocked silently and no 429 is invented from a number the gate could not read.
- **`fail: closed`**. An uncheckable hard limit returns a **503 `enforcement_unavailable`** carrying `posture: "fail_closed"`, rather than a 429 it cannot justify. Choose this only when you would rather stop traffic than admit unbudgeted spend.

Pairing `enforce: soft` with `fail: closed` is contradictory, because a soft limit does not stop anything, and `tokentriage validate` turns it down up front.
</Callout>

Because you shadowed before enforcing and previewed the exact ledger-settled numbers, you never enforce blind: the cap you switch on is the cap you already watched decide.

<NextSteps items={[
  { title: "Turn governance on", href: "/docs/build/governance-rollout", description: "The same climb for a whole tenancy tree rather than one key, with what to check at each rung." },
  { title: "How a limit counts", href: "/docs/build/governance-enforcement-classes", description: "Why a spend cap can be passed before it stops anything, and which limits cannot be." },
  { title: "The six limit types", href: "/docs/build/governance-limits", description: "Every key, default and failure mode for budget_usd and the five other types." },
  { title: "CLI reference", href: "/docs/reference/cli", description: "Every flag for tokentriage keys mint/rotate/revoke and tokentriage govern preview." }
]} />
