# Errors

The strings your app branches on when a deed fails to verify, plus the
handful an operator's own boot-time or config-time call can raise. The
first table's source of truth is `crates/grantor-verify/src/guard.rs`'s
`Rejected` enum — the same codes in every language. `BadKey` in the second
table is a separate, unrelated call (`session_jwt`), not a `Rejected`
variant — see its row for where it comes from.

## 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. See `crates/grantor-verify/src/capguard.rs`'s module doc for the full rule set (resource matching, the `nb` predicate language, and why `max_uses` is exposed but not enforced here). |
| `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` rather than `401` deliberately: a
trust-infrastructure problem is not the caller's fault, and answering `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` rather than `401` for the same kind of reason,
pointed the other way: every other code in this table means "this deed did
not check out," and a `401` correctly tells the caller to re-authenticate.
`CapabilityDenied` means the OPPOSITE — the deed checked out fine — so a
`401` would be actively wrong (re-authenticating changes nothing; the deed
was never the problem).

`LicenseExpired` and `WrongChain` join `Chain`/`QuorumDivergence` in that
`503` bucket for the identical reason: neither is the caller's fault, and
re-authenticating fixes nothing — an operator has to renew the license or
fix the RPC config before ANY caller can succeed again.

### 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` in the header and the paths in the body should match the RP's
own configured origin, fully normalized. Renderers: `Rejected::www_authenticate()` /
`Rejected::body_json()` (Rust, public methods); TS and Python expose the same
renderers through their adapter wrappers; Go's equivalents are unexported
internals of the `Guard` type. All four languages pin their output against the
shared conformance vectors as examples.

**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 (this is an operator problem,
not a credential problem).

Ordering is load-bearing: on an enterprise dedicated-registry `DeedVerifier`,
`LicenseExpired` is checked FIRST of all — before `InsecureOrigin`, before
the presented deed is inspected at all — because a lapsed license means
this deployment cannot authenticate anyone, independent of the deed or the
origin. `InsecureOrigin` is checked next, before the presented deed is
inspected (it is a fact about the verifier's own configuration, not the
deed). After that, a token 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 actually attempted (the same
tier as `Chain`), checked once per `AlloyGate` instance rather than on 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 (one that has never heard of Grantor at
all) recognizes neither the scheme name nor the `discovery` parameter. RFC
9728 (OAuth 2.0 Protected Resource Metadata) gives such a client a path it
DOES already know how to follow: 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 — `funnelHints`/
`funnel_hints`/`DisableFunnelHints`), 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, and this document is honest about that rather than
pointing a conforming RFC 9728 client at a URL that would 404. The
`grantor_*` extension fields are what a Grantor-aware client — or a curious
human — 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` (an RP-configured path, omitted — not
null — when not set) names where a holder exchanges a verified deed for a
session, if the RP offers one. Renderers: `Rejected::www_authenticate_dual()` /
`protected_resource_metadata()` (Rust); TS's `protectedResourceMetadata` and
Python's `protected_resource_metadata` are the same free-function builders
exported from their `guard` modules; Go's equivalent is
`Guard.ProtectedResourceMetadata`, a handler factory (Go's adapter owns no
router, so the caller mounts the returned `http.HandlerFunc` itself — see
its doc for why, unlike the other three languages, opting out makes it
answer `404` rather than making the route simply not exist). All four
languages pin their output against the shared conformance vectors.

## 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.
