# Errors

The strings your app branches on when a deed fails to verify, plus the few
an operator's own boot-time or config-time call can raise. The first table's
codes are the same in every language. `BadKey` in the second table is a
separate, unrelated call (`session_jwt`), not one of these — see its row.

> Debugging a rejection? Paste the deed into the [Inspector](../../inspector.html)
> for a staged verdict naming the failing check.

## Verifying a presented deed

These are what `DeedGuard`/`DeedVerifier` returns from a normal request —
see [Verify a deed](verify-tokens.md). `code()` is the stable string an app
branches on.

| HTTP | Code | When it happens |
|---|---|---|
| `401` | `InsecureOrigin` | The **verifier's own** configured `origin` is not HTTPS or is a non-routable host, and `allowInsecureOrigin` was not set — a misconfigured deployment, not a bad deed. Checked before anything about the presented deed, on every mode including `user-1271`. Opt in only for local dev / a demo (`DeedVerifier`'s trailing `allowInsecureOrigin` argument). |
| `401` | `MissingDeed` | The caller sent no `X-Grantor-Deed` header. |
| `401` | `MissingChallenge` | The caller sent no `X-Grantor-Challenge` header. |
| `401` | `UnknownChallenge` | The challenge is unissued, expired, or already spent — burned before verification, so this also fires for a flood of bogus deeds. |
| `401` | `BadDeedEncoding` | The deed header is not base64url JSON (a bare JSON object is tolerated). |
| `401` | `UnsupportedMode` | `token.mode` is not one this verifier accepts (`user-sig`/`user-passkey`/`agent-zk` reach `verify_deed`, `user-1271` at the chain-gated layer; `admin-sig` has its own entry point). |
| `401` | `WrongAudience` | The deed was minted for a different `aud`/tenant than this `DeedPolicy`. |
| `401` | `ChallengeMismatch` | The deed is bound to a different challenge than the one presented. |
| `401` | `Expired` | Past its own `exp`, or `exp` exceeds the policy's `max_ttl_secs`. |
| `401` | `Malformed` | Wrong field shape for its claimed mode (e.g. `agent-zk` carrying `pubkey`/`signature`, or `user-sig` carrying `root`/`proof`). |
| `401` | `BadProof` | A signature or proof failed to verify: secp256k1 ECDSA (`user-sig`), the P-256 passkey assertion (`user-passkey`), the Semaphore ZK proof (`agent-zk`), or the wallet's on-chain `isValidSignature` (`user-1271`). |
| `401` | `StaleRoot` | `agent-zk` only — the membership root has aged out of the registry's recent-root window; possible revocation. |
| `401` | `TenantInactive` | **Billing** — the tenant is neither `Active` nor `Grace` on-chain. |
| `503` | `Chain` | An on-chain read (`eth_call`) failed. |
| `503` | `QuorumDivergence` | Multi-RPC quorum only — every configured provider answered, but they disagreed on an on-chain read. One provider may be lying or lagging. |
| `401` | `BadDelegation` | Capabilities — a delegation link's signature failed to recover its claimed `iss`, the chain widened at some link (a child link's grants were not a legal narrowing of its parent's), a link is expired, a link's `to` does not chain to the next link's `iss` (or the last link's `to` does not equal the leaf's proven identity), a link's `parent_ref_hash` does not bind to its predecessor, the leaf's asserted `grants` exceed the chain's effective authority, or the chain's root is not a currently enrolled agent key. |
| `401` | `EpochRevoked` | Capabilities — a delegation link's signed `(epoch_label, epoch)` no longer matches the tenant's live `delegationEpoch[label]` on-chain; the tenant admin called `bumpEpoch`, revoking every link signed against the old value. |
| `401` | `BadDelegationProof` | `zk-chain` only — the structure-hiding delegation deed's Groth16 proof failed to verify against the committed verifying key and the reconstructed public inputs: a malformed proof, a tampered public input (root / effective grants / challenge / tenant / epoch), or a forged chain. Distinct from `BadProof`, which covers the leaf's own signature/membership proof, not the delegation chain — and distinct from `StaleRoot`/`TenantInactive`/`EpochRevoked`, which are fail-closed ON-CHAIN refusals of an otherwise cryptographically valid proof. |
| `403` | `CapabilityDenied` | Capabilities — `CapabilityGuard.authorize` found no effective grant covering the request (wrong resource, wrong action, or a failed `exp`/`nb` caveat). Distinct from every other code in this table: the deed itself is valid and the caller **is** authenticated, they are just not authorized for this particular request. |
| `503` | `LicenseExpired` | Enterprise dedicated-registry only — the operator-signed license bound to this `DeedVerifier`'s `RegistryRef::Dedicated` registry is past `exp + grace_secs`. Checked first, on every verify call, at the CALLER-supplied clock — a license that was still in grace at construction can still lapse mid-deployment. Never diagnose as an attack (`BadProof`) or an outage (`Chain`): the deed and the chain are both fine — renew the license and restart. |
| `503` | `WrongChain` | The RPC endpoint's own `eth_chainId` does not match the chain id this verifier was configured for — a misconfigured deployment (wrong RPC, or a copy-pasted devnet URL left in a prod config), permanent until fixed. Checked once per `AlloyGate` instance, before its first real chain read. |

`Chain` and `QuorumDivergence` are `503`, not `401`, deliberately: a
trust-infrastructure problem is not the caller's fault, and a `401` sends
clients into a login loop that discards good credentials and cannot succeed.
The verifier **fails closed** — if the chain cannot be read (or the quorum
cannot agree), no deed is accepted.

`CapabilityDenied` is `403`, not `401`, for the mirror reason: every other
code here means the deed did not check out, so `401` correctly says
re-authenticate. `CapabilityDenied` means the deed checked out fine — the
caller is authenticated, just not authorized — so re-authenticating changes
nothing.

`LicenseExpired` and `WrongChain` share that `503` bucket for the same
reason: not the caller's fault, and re-authenticating fixes nothing — an
operator must renew the license or fix the RPC config first.

### `grantor-mcp`'s descriptor-pin hint

`CapabilityGuard.authorize` never says which caveat inside a grant failed —
deliberately: teaching the guard itself to report the failing caveat would
be a four-language change with a matrix row and conformance vectors (see
[Capabilities § Enforcing](capabilities.md#enforcing-capabilityguard)), and
guard drift across the four implementations is worse than an unhelpful
message. Without it, an exact grant that denies because a
[pinned tool descriptor](capabilities.md#pinning-a-tool-descriptor-grantortool-desc-v1)
no longer matches looks self-contradictory: "requested `echo`", "grant
allows `echo`", "refused as `CapabilityDenied`" — nothing says the pin, not
the tool name, is the problem.

`grantor-mcp`'s `check`/`wrap` verbs close this gap **broker-side only**,
without touching `CapabilityGuard` or its `code`: when a `CapabilityDenied`
is thrown and a `grantor:tool-desc-v1` pin covered the requested tool but
the presented descriptor hash was not in its pinned set, the deny object
gains an **additive** `hint` field, and the boxed denial artifact gains a
`descriptor` row naming the mismatch — hashes truncated to a 12-character
prefix so the row stays on-screen:

```
┌─ DENIED ───────────────────────────────────────────────────────────────────┐
│  requested      echo                                                       │
│  grant allows   echo                                                       │
│  refused as     CapabilityDenied — no effective grant permits act="call" o…│
│  descriptor     presented deadbeefdead not in pinned(1) {a85008edb2a3}     │
│                                                                            │
│  Denied by the grant, not by a prompt — the server never saw the call.     │
│  what just happened:  https://chaingrantor.com/docs/guide/first-denial.html│
└────────────────────────────────────────────────────────────────────────────┘
```

`pinned(N)` names the pinned set's SIZE, not just its contents — a
`--tools '*' --descriptors <file>` grant (the authoring flow `tools --json`
feeds) routinely pins one hash per descriptor in the file, so a many-hash
pinned set is the common case, not the edge one. The count is placed before
the hash list and is never clipped even when the row itself is; only the
list past two entries is elided with a trailing `…`, e.g.
`pinned(3) {461435a096e6, 48fe892fc87b, …}` — so a reader can always tell a
rollout-of-two from a wrong-file-of-twelve even from a truncated row.

This means one of two things: the server's tool schema changed since the
grant was signed (a routine deploy — re-approve the new descriptor with
`grantor-mcp tools --json` and re-issue the grant via `grant --descriptors`,
or pin both the old and new hash with a `|`-joined set to ride out the
rollout), or the tool was never in the approved set to begin with (grant
the exact resource, or add its descriptor to the pinned set).

The `hint`/`descriptor` row is absent for every other denial shape: no
grant covering the resource at all, an expired grant, a differently-keyed
`nb` predicate, or — importantly — a presented hash that **does** satisfy
the pin (so the real cause is something else, most often the grant's own
`exp`). Its presence is itself diagnostic: if you see it, the pin is the
reason; if you don't, look elsewhere. The greppable `DENY tools/call <tool>:
<code>` log line carries the same hint as an additive ` — <hint>` suffix
after the existing prefix, so `grep DENY` output surfaces the cause without
opening the box.

### 401 funnel: header and body guidance

When returning a `401`, send a `WWW-Authenticate` header and add two fields
to the JSON response body to guide the holder:

```
WWW-Authenticate: Grantor-Deed realm="https://rp.example", discovery="/.well-known/grantor-deed"
```

Response body gains two **additive** fields (the original `error` and
`error_description` are unchanged and always present):

- `discovery`: always present, the path where the holder fetches this RP's
  discovery document (e.g. `/.well-known/grantor-deed`). This is how a
  naive holder that cannot parse the `WWW-Authenticate` header can still
  find the endpoint.

- `learn`: the URL where the holder learns about onboarding, wallet support,
  and how to authenticate. Included by default; the RP can opt out via a
  configuration parameter (`funnelHints: false` in TS, `funnel_hints=False` in
  Python, `DisableFunnelHints: true` in Go). When disabled, this field is
  omitted entirely, so a pre-funnel consumer that does not expect it is
  unaffected.

The `realm` and the body paths should match the RP's configured origin,
fully normalized. Rust exposes these as the public `Rejected::www_authenticate()`
/ `Rejected::body_json()` methods; TS and Python expose the same renderers
through their adapter wrappers; Go's are internal to its `Guard`. All four are
pinned for parity.

**Status class rule:** `401` sends the header plus both body fields; `403`
sends only the body fields (no header — that header means "please authenticate,"
and this caller already did); `503` sends neither (an operator problem, not a
credential problem).

Ordering is load-bearing. On an enterprise dedicated-registry `DeedVerifier`,
`LicenseExpired` is checked FIRST — before `InsecureOrigin`, before the deed
is inspected at all — because a lapsed license means this deployment can
authenticate no one, independent of the deed or origin. `InsecureOrigin` is
next (a fact about the verifier's own config, not the deed). After that, a
deed that's the wrong version, wrong audience/tenant, wrong/replayed
challenge, expired, unparseable, or carrying a bad proof fails before either
chain read, so most rejections cost zero RPC calls. `WrongChain` surfaces
only once a chain read is attempted (the same tier as `Chain`), checked once
per `AlloyGate` instance rather than every call. See [Sovereign tier § What
the verifier checks](../sovereign-tier.md#what-the-verifier-checks) for the
exact order.

### Grantor-Deed scheme grammar

`Grantor-Deed` follows the RFC 7235 `auth-scheme`/`auth-param` shape every
`WWW-Authenticate` challenge uses, ABNF-ish:

```
challenge   = "Grantor-Deed" 1*SP auth-param *( "," OWS auth-param )
auth-param  = token "=" quoted-string
token       = "realm" / "discovery"
```

Both `realm` and `discovery` are `quoted-string`s, escaped per RFC 7230
(backslash first, then quote — see [above](#401-funnel-header-and-body-guidance)).
**`Grantor-Deed` is deliberately NOT a scheme registered with IANA's [HTTP
Authentication Scheme
Registry](https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml)**
— it names no token endpoint (there is none, by design; see
[Sovereign tier](../sovereign-tier.md)) and exists purely as a self-describing
label a Grantor-aware holder recognizes. This is exactly why the RFC 9728
bridge below exists: an off-the-shelf OAuth-shaped client has no reason to
know an unregistered scheme name, so it needs a *registered* signal instead.

### RFC 9728 bridge: dual challenge + protected-resource metadata

A caller that already speaks `Grantor-Deed` needs nothing more — but an
off-the-shelf OAuth-shaped client recognizes neither the scheme name nor the
`discovery` parameter. RFC 9728 (OAuth 2.0 Protected Resource Metadata) gives
it a path it already knows: a `Bearer` challenge carrying a
`resource_metadata` parameter pointing at a well-known JSON document.

When funnel hints are on (the same opt-out as above), the `401`'s
`WWW-Authenticate` header becomes **dual** — `Grantor-Deed` stays first,
unchanged, with the RFC 9728 scheme appended as a bridge, never a
replacement:

```
WWW-Authenticate: Grantor-Deed realm="https://rp.example", discovery="/.well-known/grantor-deed", Bearer resource_metadata="/.well-known/oauth-protected-resource"
```

The RP additionally serves an RFC 9728 protected-resource metadata document
at `/.well-known/oauth-protected-resource`
(`OAUTH_PROTECTED_RESOURCE_PATH`/`OAuthProtectedResourcePath`), mounted the
same way — and behind the same opt-out — as the `/.well-known/grantor-deed`
discovery document:

```json
{
  "resource": "https://rp.example",
  "authorization_servers": [],
  "grantor_deed_discovery": "/.well-known/grantor-deed",
  "grantor_onboard": "https://chaingrantor.com/.well-known/grantor-onboard.json",
  "grantor_note": "no authorization server exists by design; authenticate with a Grantor deed via grantor_deed_discovery"
}
```

`authorization_servers` is deliberately **empty** — there is no authorization
server, by design, so the document says so rather than pointing a conforming
RFC 9728 client at a URL that would 404. The `grantor_*` extension fields are
what a Grantor-aware client reads instead: `grantor_deed_discovery` is the
real discovery document's path, `grantor_onboard` mirrors the `learn` body
field, and an optional `grantor_session_exchange` (omitted, not null, when
unset) names where a holder exchanges a verified deed for a session, if the
RP offers one. Rust renders this with `Rejected::www_authenticate_dual()` /
`protected_resource_metadata()`; TS's `protectedResourceMetadata` and Python's
`protected_resource_metadata` are the same builders; Go's
`Guard.ProtectedResourceMetadata` is a handler factory — Go's adapter owns no
router, so the caller mounts the returned `http.HandlerFunc` itself, and
opting out makes it answer `404` rather than removing the route. All four are
pinned for parity.

## Boot-time / config-time (not caller-facing)

These codes are **not** returned while verifying a caller's deed — they
surface only from an RP's own setup or misconfiguration, before any caller
is involved.

| Code | When it happens |
|---|---|
| `OriginVouchExpired` | This guard's own origin vouch is past its `exp`. Checked before any chain read. Comes from `DeedGuard::verify_own_origin_vouch` / `verifyOwnOriginVouchAt`, the DX check an RP calls once at boot to catch its *own* misconfigured vouch before it causes a stream of failed logins. See [Sovereign tier § Origin provenance](../sovereign-tier.md#origin-provenance). |
| `OriginVouchTtlExceeded` | This guard's own vouch has an `exp` further out than the maximum accepted TTL (90 days). Checked before any chain read. |
| `BadOriginVouch` | The vouch signature is malformed, or the chain says the recovered signer is not a current voucher for this tenant/epoch. |
| `BadKey` | The session signing key passed to `session_jwt`/`sessionJwt`/`SessionJwt` failed to parse — PKCS#8 PEM expected. Not a deed-verification failure: it means the RP's own call is misconfigured. See [Verify a deed § Or one call](verify-tokens.md#or-one-call). |
| `BadConfig` | Enterprise dedicated-registry — `DeedVerifier`/`DeedVerifierJs` construction (or `resolveRegistry`/`resolve_registry`) refused a `RegistryRef::Dedicated` whose license was **already** past `exp + grace_secs` at the clock construction used — the "restart after lapse fails" property. Message starts `"license/registry: "`. **This is a DIFFERENT code from the caller-facing `LicenseExpired`/`503` above** — that one fires when a license lapses WHILE an already-running verifier keeps serving requests. A boot-time health check that only alerts on `LicenseExpired` will never see a deployment that lapsed before it could even start; alert on both `BadConfig` messages containing `"license/registry:"` at boot AND `LicenseExpired` at request time. |

## See also

- [Verify a deed](verify-tokens.md) — the relying-party side.
- [Wallet login](wallet-login.md) and [Agent tokens](agent-tokens.md) — the flows that produce the deeds these errors are about.
- [Sovereign tier](../sovereign-tier.md) — the full reference, including exact verification order.
- [`../llms-full.txt`](../llms-full.txt) — the entire product in one file.
