# Refusal wire reference

Every field, header, and status a governance stop can carry, on the response and on the matching ledger row.

Source: https://tokentriage.com/docs/build/governance-refusal-reference

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

A 429 has landed in someone's error handler, or a row has landed in your ledger, and you need
to know what each field means, whether it will be there next time, and what its absence tells
you.

This page is the field list. It covers the eight stop types governance can produce, the JSON
body, the response headers, the success-side headers a caller can plan against, and the block
the decision row carries for the same request.

For the story behind one 429, which limit fired and at which node and what to change,
read [Why was this request refused?](/docs/build/governance-refusals). For why a spend cap
discloses an approximation at all, read
[how a limit counts](/docs/build/governance-enforcement-classes).

## How a stop is produced

Governance has one exit. When a limit, a scope check, or a credential lookup decides a request
does not go through, the engine hands the proxy a single typed value, and one function turns
that value into everything anyone will ever see about it.

From that one value the function stamps the response headers and builds a body value. The body
bytes and the ledger row's `refused_by_limit` block are both written from the body value, so
those two cannot drift. All of it happens on the shared terminal path every other outcome
uses.

Two consequences are worth relying on. One stopped request produces one ledger row, so a stop
is not a silent drop. And the row carries the same fields the caller received, minus
`error.message`, so a pasted response and your ledger cannot disagree about what happened.
[Governance](/docs/build/governance) covers the same ground in prose.

## Reason to status

`error.type` names the reason, and the status is derived from it, so the two cannot disagree.
The same string rides the `x-tt-refusal-type` header.

| `error.type` | Status | What produced it |
|---|---|---|
| `invalid_key` | 401 | a presented `ttv_` credential did not resolve — unknown, revoked, or lapsed — or, under `require_key: required`, no credential was presented at all |
| `scope_violation` | 403 | the endpoint, model, or provider sits outside the key's scope |
| `rate_limited` | 429 | an `rpm` or `tpm` limit denied the step at its gate |
| `quota_exhausted` | 429 | a `quota_tokens` or `quota_requests` cap is used up for the period |
| `budget_exceeded` | 429 | a `budget_usd` cap is used up for the period |
| `concurrency_exceeded` | 429 | a `concurrency` lease could not be taken |
| `quota_cost_unfittable` | 429 | one request costs more than the window's burst can ever hold |
| `enforcement_unavailable` | 503 | the enforcement question could not be answered, under `fail: closed` with `enforce: hard`, or under `require_key: required` with no loadable key snapshot |

The vocabulary is closed. A ninth value means a version newer than this page.

## The response body

Every governance stop returns `application/json` with one top-level `error` object. Which of
its four members appear is decided by the type. The smallest body is the 401:

```console
$ curl -i -X POST http://127.0.0.1:8788/v1/chat/completions \
    -H 'content-type: application/json' -d '{"model":"gpt-4o-mini","messages":[]}'
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Tt-Refusal-Type: invalid_key
X-Tt-Request-Id: tt_7niasub57sgh4gotj223c2y4f4

{"error":{"type":"invalid_key","message":"invalid or unknown key"}}
```

That request sent no `Authorization` header, against a sandbox running
`require_key: required`. The sandbox points at a stand-in upstream, so the port and the
request id are ours; the field shape is what your own callers see.

`error.type` is the string from the preceding table, always present. `error.message` is human
text, also always present: it names the firing limit id on an exhaustion stop, names the
violated dimension on a 403, and is uniform on a 401. It is the one member the ledger row does
not carry.

### error.refused_by

| Field | Type | Carried on | Left out when |
|---|---|---|---|
| `limit_id` | string | any stop one limit produced | 401, 403, and the identity-store 503 |
| `limit_type` | string | the same stops as `limit_id` | the same |
| `unit` | string | the same stops; `usd`, `tokens`, `requests`, or `concurrent-requests` | the same |
| `node` | string | every body carrying `refused_by` at all | it is the one member with no omit rule |
| `period` | string | a limit with a period: the calendar name (`day`, `week`, `month`) or the window duration (`1m0s`) | `concurrency`, which has none |
| `resets_at` | RFC 3339 string | a calendar-period limit | rate windows and `concurrency` |
| `retry_after_s` | integer seconds | a 429 whose delay is above zero | 401, 403, both 503s, and any zero delay |
| `observed` | number | `budget_usd`, `quota_tokens`, `quota_requests`, `concurrency` | `rpm`, `tpm`, and the 503s |
| `limit` | number | the same stops as `observed` | the same |

Two omission rules matter more than the rest. `retry_after_s` is left out rather than sent as
`0` when no retry time is known, so a client that reads it can act on it. And a 401 carries no
`refused_by` object at all: naming a node or a limit there would confirm which tenant a
presented key belongs to. A 403 does carry the object, holding `node` alone.

`budget_usd` renders `observed` and `limit` in dollars, and every other type renders a whole
count. The dollar counter is kept in nanodollars and divided on the way out.

<Callout type="warning">

`concurrency` reports `observed` as a fixed `0` beside a live `limit`, so a body can read
`"observed":0,"limit":2` while two requests were in flight. Read the live gauge from
`GET /tt/v1/me` instead, where the same limit reports `observed` and `remaining` from the
counter itself.

</Callout>

### error.enforcement

| Field | Type | Carried on | Left out when |
|---|---|---|---|
| `posture` | string | `enforcing` on a limit stop, `fail_closed` on either 503 | the whole object is absent on 401 and 403 |
| `approximate` | bool | every body carrying `enforcement` | present even when `false` |
| `bound` | object | a spend or quota stop with an accountant wired, and a rate stop taken during a coordination-store outage | every other stop |
| `note` | string | travels with `bound` | when there is no `bound` |

This object is where the four counting classes become visible on the wire. Here it is from a
captured `budget_usd` 429:

```json
{"posture":"enforcing","approximate":true,"bound":{"tee_lag_rows":0,"dropped_rows":0},"note":"spend from in-flight and streaming requests admitted before this refusal may not yet be reflected"}
```

Three other limits, driven against the same sandbox, carry three different shapes:

| Firing type | Class | `error.enforcement` |
|---|---|---|
| `rpm` | AdmissionCounted | `{"posture":"enforcing","approximate":false}` |
| `tpm` | ReserveReconcile | `{"posture":"enforcing","approximate":true}` |
| `concurrency` | InflightLease | `{"posture":"enforcing","approximate":false}` |

Two of the four claim precision, two admit an approximation, and the two admissions differ in
shape: `budget_usd` attaches a bound object and a sentence, while `tpm` flags the estimate and
stops. [How a limit counts](/docs/build/governance-enforcement-classes) explains which class
produces which.

## The bound object

`bound` carries only quantitative terms. `approximate` and `note` sit one level up, on
`enforcement`, and are not repeated inside it.

| Field | Type | What fills it |
|---|---|---|
| `tee_lag_rows` | int | the spend and quota accountant: rows queued for folding, not yet folded |
| `dropped_rows` | int | the same queue, counting rows shed under load |
| `in_flight_reserved` | int | nothing in this build |
| `estimate_skew_p95` | float | nothing in this build |
| `convergence_lag_rows` | int | the cross-replica converger, on a fleet with shared state |

`tee_lag_rows` and `dropped_rows` are emitted whenever `bound` exists, zeros included, because
a zero there is the statement that nothing is behind and nothing was shed. The other three are
omitted when unset, and two of them have no producer at all: the envelope defines
`in_flight_reserved` and `estimate_skew_p95`, no code path fills either, and you will not meet
them on a served response. Treat them as reserved.

Which limit types reach which terms: `budget_usd`, `quota_tokens` and `quota_requests` get the
two queue terms, plus `convergence_lag_rows` once a shared-state tier is configured. `rpm` and
`tpm` get a `bound` only while the coordination store is unreachable, and `concurrency` and the
401 get none.

<Callout type="note">

That outage case renders `"bound":{"tee_lag_rows":0,"dropped_rows":0}` on a rate limit, which
has no accountant queue behind it. Both zeros are structural. The `note` beside them is the
field to read, and the row's `suspended` list names the same limit.
[Governance across replicas](/docs/build/governance-distributed) has the outage arithmetic.

</Callout>

## Headers

### On a stopped request

| Header | Value grammar | Emitted on |
|---|---|---|
| `Retry-After` | whole seconds, rounded up | a 429 whose delay is above zero; not on 401, 403, or either 503 |
| `RateLimit` | `"<limit_id>";r=0;t=<seconds>` | a 429 where a limit id is known; `t` is `0` when no delay is known |
| `RateLimit-Policy` | `"<limit_id>";q=<cap>` | a 429 whose firing limit reports a whole-number cap, which in this build means `concurrency` |
| `x-tt-refused-by` | the firing `limit_id` | every stop where a limit id exists |
| `x-tt-refusal-type` | the `error.type` string | every governance stop |
| `x-tt-coordination` | the single value `suspended` | a stop whose admission carried a suspension; absent otherwise, with no `ok` value |
| `x-tt-request-id` | the per-request id, matching the row's `req_id` | every response |

`RateLimit-Policy` is dropped for `budget_usd` and the two quotas, because a `q` in raw
nanodollars or tokens would be read as an IETF request quota. `rpm` and `tpm` omit it for a
different reason: their counter store cannot cheaply report the pair, which is also why those
two carry no `observed` or `limit`.

Header names are case-insensitive, and the server canonicalises them on the way out, so a
`curl -i` capture shows `Ratelimit` and `X-Tt-Refusal-Type`. Match them case-insensitively.
[Response headers](/docs/reference/headers) has the non-governance set.

### On a served request

`governance.keys.disclose_limits` defaults to `true`. With it on, a 2xx served to a resolved
`ttv_` key advertises the tightest applying rate-class limit, measured by remaining fraction:

```console
HTTP/1.1 200 OK
Content-Type: application/json
Ratelimit-Policy: "team_rpm";q=300;w=60
X-Ratelimit-Limit-Requests: 300
X-Ratelimit-Remaining-Requests: 59
X-Tt-Request-Id: tt_tbuhetygwddxiaprkw3j3fkwze
```

A `tpm` limit advertises the token pair instead:

```console
Ratelimit-Policy: "team_tpm";q=20000;w=60
X-Ratelimit-Limit-Tokens: 20000
X-Ratelimit-Remaining-Tokens: 3609
```

| Header | Value grammar |
|---|---|
| `RateLimit-Policy` | `"<limit_id>";q=<cap>;w=<window seconds>`; the `;w=` term is dropped for `concurrency`, which has no window |
| `X-RateLimit-Limit-<unit>` | the cap, with `<unit>` one of `requests`, `tokens`, `concurrent-requests` |
| `X-RateLimit-Remaining-<unit>` | the cap minus current use, floored at zero |

Three things about those captures. The first limit was configured `amount: 300` with
`burst: 60`, and `Remaining` tracks the burst depth rather than the per-minute rate. That same
limit was in `mode: shadow` and advertised anyway. In the second, `20000 − 16391 = 3609` is one
request holding a 16,391-token reserve.

Nothing is advertised when no key resolved, when the key has no rate-class limit, or when the
only rate limit is bound to `store: redis`, whose counter cannot answer a read. Setting
`disclose_limits: false` suppresses all three headers and changes nothing on a stopped request.

## The ledger row block

`decision.refused_by_limit` mirrors the body field for field: `type`, `refused_by`, and
`enforcement`, with the same members and the same omission rules. Beside it,
`decision.governance` carries the admission disclosure. Five of its ten members answer most
questions a stopped row raises:

| Field | Meaning |
|---|---|
| `verdict` | `allow` or `would_refuse`, and no other value |
| `suspended` | limit ids whose guarantee was not in force for this request; `__identity` names a degraded key store |
| `reserved_tokens` | the `tpm` reserve StageB took, when one was taken |
| `serving_key` | which key served the row: kid, node, and rotation predecessor |
| `gate_us` | per-stage gate wall time in microseconds |

`outcome.finish_reason` is `limit_refused` on every governance stop.

State this one plainly, because it costs people a query: `decision.governance.verdict` is not
where a stop is recorded. It holds `allow` or `would_refuse`, so a stopped row reads
`verdict: "allow"` unless a shadow limit happened to fire on the same request. Count
`refused_by_limit`, or filter `outcome.finish_reason` on `limit_refused`. Here is the row for
the 401 shown earlier:

```json
{
  "http": { "path": "/v1/chat/completions", "dialect": "openai", "status": 401, "endpoint": "chat" },
  "decision": {
    "governance": { "kid": "", "node": "", "verdict": "allow", "gate_us": { "a": 0, "b": 0 } },
    "refused_by_limit": { "type": "invalid_key" }
  }
}
```

The block's other five members (`kid`, `node`, `would_refuse`, `user_asserted`,
`rotation_grace`), the rest of the row, and every field outside the governance blocks are
[decision ledger fields](/docs/reference/ledger-fields).

## What a stopped caller is and is not told

The typed value the engine produces has no exported fields and no exported constructor, so
nothing outside the engine can compose one or edit one in transit. Every value that reaches a
caller was built by the gate path that fired, carrying that limit's identity.

What that identity includes is narrowed at two of the statuses, and the narrowing is a choice
rather than an oversight.

A 403 names the violated dimension and stops:

```console
HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Tt-Refusal-Type: scope_violation
X-Tt-Request-Id: tt_l3dgk5yfk4evftdngjxjqwkhom

{"error":{"type":"scope_violation","message":"request refused: model outside key scope","refused_by":{"node":"org:acme/team:search"}}}
```

That key was scoped to `gpt-4o-mini,gpt-4o` and asked for `o3-mini`. The message names `model`
as the dimension. It does not name the two models that would have worked, `refused_by` carries
the holder's own node and nothing else, and there is no `enforcement` object.

A 401 names nothing. One message covers an unknown key, a revoked key, a lapsed key and, under
`require_key: required`, no key at all. There is no `refused_by` and no `enforcement`, and the
row is equally bare. The row's one extra fact is
`suspended: ["__identity"]`, which appears when the key store itself was degraded, so an
operator can tell a possibly-good caller was turned away against a stale view.

## What this page does not cover

Fields, not diagnosis. Which limit fired and what to change about it is
[Why was this request refused?](/docs/build/governance-refusals). Which instance a request
selected in the first place is [the tenancy hierarchy](/docs/build/governance-hierarchy), and
what each type caps is [the six limit types](/docs/build/governance-limits). Why a limit was
suspended instead of stopping the request is
[governance](/docs/build/governance#fail-open-with-disclosure). Management API errors are a
separate contract, RFC 9457 problem+json, documented at
[errors and problem+json](/docs/reference/errors).

<NextSteps items={[
  { title: 'Why was this request refused?', href: '/docs/build/governance-refusals', description: 'Start from a 429 and work back to the limit, the node, and the change that fixes it.' },
  { title: 'How a limit counts', href: '/docs/build/governance-enforcement-classes', description: 'Why two of the four enforcement blocks admit an approximation and two do not.' },
  { title: 'Decision ledger fields', href: '/docs/reference/ledger-fields', description: 'Every other field on the row a stopped request writes.' },
  { title: 'Response headers', href: '/docs/reference/headers', description: 'The full x-tt-* set, including cache, routing and trace headers.' }
]} />
