# Sovereign tier — deeds

The sovereign model is the product: your app authenticates a caller with a
**deed** — a self-certifying credential the caller mints itself, checked
directly against the public on-chain `GrantorRegistry` (the trust anchor +
billing ledger). There is no auth server to run or point at, no key custody
on Grantor's side, and no forge risk against Grantor, because nothing runs.
The only thing your own service takes on is a verifier library dependency
and an RPC connection to read the registry.

The sovereign tier has **seven wire modes**. `agent-zk`, `user-sig`, `user-passkey`,
`user-1271` and `user-zk` are the five your app verifies, and one verifier call
(`verify_deed`/`verify_deed_claims`) handles all five via a mode dispatch — `user-1271` is
the one exception within that dispatch: it is offline-unverifiable by construction (see
[Smart-wallet login](#smart-wallet-login-user-1271) below), so it runs at the gated layer
before the pure claims function ever sees it, rather than inside it. `user-zk` reuses
`agent-zk`'s exact ZK-membership crypto and root-recency check, over a separate,
admin-curated **user** tree instead of the agent tree — see [User
gating](guide/user-gating.md) for the full mode, including admin enrollment and revocation.
`admin-sig` is a sixth mode that exists so Grantor's own control-plane dashboard has no
account-shaped auth left either — it authenticates Grantor's admins, not your users, and it
verifies through a separate, dedicated entry point that `verify_deed` refuses to accept (see
[Dashboard login](#dashboard-login-admin-sig) below). `admin-sig` itself has two holder
shapes, an EOA and an EIP-1271 smart-contract wallet (e.g. a Safe) — both mint the same
`admin-sig` wire mode, just verified through two different entry points. `zk-chain` is the
seventh — ⚠️ **preview**, novel cryptography pending external audit — a ZK proof of an
anonymous, bounded-depth delegation chain; it authenticates an (effective) authority
commitment rather than a principal, and — like `admin-sig` — verifies through its own
dedicated entry point that `verify_deed` refuses to accept. See [Capabilities §
Structure-hiding delegation](guide/capabilities.md#structure-hiding-delegation-preview) for
the full mode.

| Mode | Who | Proves | On-chain check |
|---|---|---|---|
| `agent-zk` | an enrolled agent | ZK membership of the tenant's registry, without revealing *which* member | root recency (**revocation**) + tenant status (**billing**) |
| `user-zk` | a human enrolled in the tenant's own **user allowlist** | ZK membership of that allowlist, without revealing *which* member — see [User gating](guide/user-gating.md) | root recency (**revocation**) + tenant status (**billing**) |
| `user-sig` | a human with a wallet | control of a wallet-derived, app-scoped key | tenant status (**billing**) only |
| `user-passkey` | a human with a passkey (WebAuthn) | control of a browser-bound P-256 credential | tenant status (**billing**) only |
| `user-1271` | a human with an EIP-1271 smart-contract wallet (e.g. a Safe) | on-chain `isValidSignature` approval by that wallet CONTRACT | on-chain `isValidSignature` (**the login check itself**) + tenant status (**billing**) |
| `admin-sig` | Grantor's own dashboard admin (EOA); an EIP-1271 smart wallet is a verified library capability not yet wired into Grantor's own dashboard | control of a wallet address | **none, deliberately** |
| `zk-chain` ⚠️ preview | a delegate at the end of an anonymous, bounded-depth delegation chain rooted at a tenant member | an (effective) authority commitment, hiding hop count and every intermediate key/grant | root recency of the rooting member's tree (**revocation**) + tenant status (**billing**) |

`user-passkey` and `user-1271` are both additive alongside `user-sig`, not replacements —
see [Passkey login](#passkey-login-user-passkey) and [Smart-wallet
login](#smart-wallet-login-user-1271) below for why each exists and when to pick which.

## Every capability, every language

**Both sides ship in all four languages.** Minting and verification exist in
TypeScript, Python, Go and Rust, and `just capability-matrix` fails the build
if any cell is missing:

| | TypeScript | Python | Go | Rust |
|---|---|---|---|---|
| `user-sig` holder | ✅ | ✅ | ✅ | ✅ |
| `user-passkey` mint (assembler) | ✅ | ✅ | ✅ | ✅ |
| `user-passkey` browser ceremony (`registerPasskey`/`signInWithPasskey`) | ✅ | — | — | — |
| `user-1271` mint (assembler) | ✅ | ✅ | ✅ | ✅ |
| `agent-zk` holder (ZK proving) | ✅ | ✅ | ✅ | ✅ |
| `admin-sig` holder (EOA) | ✅ | ✅ | ✅ | ✅ |
| `admin-sig` smart-wallet mint (assembler) | ✅ | ✅ | ✅ | ✅ |
| Deed verification (all four `verify_deed` modes) | ✅ | ✅ | ✅ | ✅ |
| `admin-sig` smart-wallet verify (`verify_admin_deed_smartwallet`) | ✅ | ✅ | ✅ | ✅ |
| Deed guard (challenge store, single-use burn) | ✅ | ✅ | ✅ | ✅ |

The browser-ceremony row is deliberately TS-only and is not a matrix violation: there is no
non-browser WebAuthn authenticator for any language to wrap, the same shape
`just capability-matrix` already accepts for `authenticate()`'s Rust exemption
("compose it yourself"). The mint *assembler* underneath the ceremony — pure
bytes-in-deed-out, no live authenticator required — is the four-language capability, and
that is what ships in **all four** languages, TypeScript included (`mintUserPasskeyDeed` is
re-exported at the TS barrel, the same low-level re-export `mintUserDeed` gets for
`user-sig`) — only the live `navigator.credentials` ceremony wrapped around it is TS-only.

You do not need to hand-write the glue: the **deed guard** ships in each
language with a challenge store, single-use burn and the shared error codes,
plus thin adapters (Express, FastAPI, `net/http`). Rust has no adapter on
purpose — it has no single dominant web framework, and the guard is
framework-agnostic there.

**Every rejection the guard raises is funnel-shaped by default** (agent-native
GTM, 2026-08-10): a 401 (not authenticated) carries a `WWW-Authenticate:
Grantor-Deed realm="<origin>", discovery="/.well-known/grantor-deed"` header
plus `discovery`/`learn` fields in the JSON body — pointing a rejected caller
at the exact document that tells it how to mint a deed, and the global
onboarding manifest beyond that. A 503 (`Chain`/`QuorumDivergence`/
`WrongChain`/`LicenseExpired`) gets neither: that class of rejection is an
RP/network problem, not the caller's credential, so there is nothing to funnel
toward. This is additive on the existing error shape and opt-out per adapter
(a single `funnelHints`-style option), never mandatory — see
[the funnel design](superpowers/specs/2026-08-10-agent-native-gtm-design.md)
and the [MCP server guide](guide/mcp-server.md) for a wired example.

**Which registry every recipe on this page talks to** is a separate axis from
the mode table above: every construction/mint call below takes a
`RegistryRef` (`Registry.canonical()` by default — the SDK pins the
canonical shared registry for you, no address anywhere in the standard SDK).
A custom registry address is expressible only via `Registry.devnet(address)`
(local development, chain-id-gated) or `Registry.dedicated(address,
license)` (an operator-signed enterprise license) — see [Enterprise
registries](guide/enterprise-registry.md) for the full reference.

## What it is

An `agent-zk` deed proves membership without revealing which member. Minting
is `mintDeed` on the `ZkAgent` type in every language
(`grantor_sdk_core::sovereign::mint_deed` in Rust).

> The Semaphore identity secret **never crosses the FFI boundary**. Leaking it
> would retroactively deanonymise every deed that agent ever minted, so the API
> shape — the agent object owns the secret and only emits proofs — is the
> mitigation, not a convention.

An enrolled agent holds a Semaphore identity whose commitment is registered
in your tenant's on-chain membership tree (the same `GrantorRegistry` used by
Grant C's blind-RSA path — see `crates/grantor-issuer/src/http/blind.rs`). To
authenticate, the agent:

1. fetches the tree's current event log and rebuilds its Merkle membership
   path **from chain** (`grantor_sdk_core::treesync::build_membership_proof`
   — the tree-sync path; the same code an issuer-backed agent uses to catch up),
2. produces a ZK proof of membership bound to your challenge + audience +
   expiry (`mintDeed`, or `grantor_sdk_core::sovereign::mint_deed` in Rust),
3. hands you that proof. There is no token exchange, no redirect, no issuer
   round-trip — the proof itself is the credential.

## RP integration recipe

Verification ships as `DeedVerifier` in TypeScript, Python and Go, and as
`grantor_verify::sovereign::verify_deed` in Rust. **Chain reads stay on the
Rust side of the FFI** in every binding: the on-chain tenant check *is* the
billing enforcement, so the shim owns the gate and claims cannot be obtained
without passing through it.

Errors are a structured enum rather than a flat string, so a relying party can
tell `TenantInactive` (billing — the customer must top up) from `BadProof` (an
attack) from `Chain` (an RPC problem worth retrying, and the one case that
should answer 503 rather than 401).

For Rust, add `grantor-verify` with the `sovereign-chain` feature (non-default
— it pulls in `semaphore-rs` + alloy's chain RPC stack, so light builds that
only verify standard OIDC JWTs stay lean). The pure `sovereign` feature
underneath it — envelope, claims verification, `DeedGuard`, discovery,
session-JWT, and the chain PORT itself — links no RPC stack at all; reach for
it directly only if you're supplying your own `DeedChainGate` instead of the
built-in `AlloyGate`:

```toml
# `grantor-verify` is currently path-only within this workspace, not yet
# published to crates.io — this snippet is how another crate in the SAME
# checkout depends on it today; publishing is tracked separately.
grantor-verify = { path = "...", features = ["sovereign-chain"] }
```

The recipe is: **issue a challenge → verify → mint a local JWT → your
existing OIDC stack is unchanged.** This is lifted directly from the working
reference RP in `crates/grantor-verify/tests/sovereign_e2e.rs` (the file that
proves the whole tier with zero `grantor-issuer` process running):

The `DeedGuard` owns the part `verify_deed` deliberately cannot: the anti-replay
challenge is state only your app holds, and a verifier that managed it would be
a server. It issues challenges, remembers them, burns each exactly once, and
stops there — minting a session stays yours.

```rust
// GET /challenge — the guard mints and remembers it.
#[handler]
fn issue_challenge(Data(state): Data<&Arc<RpState>>) -> Json<serde_json::Value> {
    let mut raw = [0u8; 16];
    rand_core::OsRng.fill_bytes(&mut raw);
    Json(serde_json::json!({ "challenge": state.guard.issue_challenge(raw) }))
}

// GET /resource — verify the presented deed, then mint YOUR OWN JWT.
#[handler]
async fn resource(req: &Request, Data(state): Data<&Arc<RpState>>) -> PoemResult<Json<serde_json::Value>> {
    // The deed and the challenge arrive in SEPARATE headers. The challenge is
    // never read out of the deed — doing so would let the caller choose their
    // own nonce, and the replay defence would be decorative.
    let deed_header = req.header(guard::DEED_HEADER)
        .ok_or_else(|| Error::from_status(StatusCode::UNAUTHORIZED))?;
    let deed_json = guard::decode_deed(deed_header)
        .map_err(|_| Error::from_status(StatusCode::UNAUTHORIZED))?;
    let challenge = req.header(guard::CHALLENGE_HEADER);

    let now = now_secs();
    // Burns the challenge BEFORE verifying, so a flood of bogus deeds cannot
    // probe which challenges are live. `Chain` maps to 503, everything else
    // to 401 — an RPC outage is not the caller's fault.
    let claims = state.guard.verify(Some(&deed_json), challenge, now)
        .await
        .map_err(|e| Error::from_status(
            StatusCode::from_u16(e.status()).unwrap_or(StatusCode::UNAUTHORIZED),
        ))?;

    // Your existing OIDC stack, unchanged: sign your own JWT, carrying
    // sub/aud through. `iss`/`iat` are YOUR OWN — most JOSE/OIDC middleware
    // expects both (iss especially), so a real integration should set them
    // even though `grantor_verify::DeedClaims` itself carries neither
    // (there is no issuer to have asserted an `iss`, and no token mint time
    // to report as `iat` — both are meaningless upstream of your own mint).
    let jwt = jsonwebtoken::encode(
        &jsonwebtoken::Header::default(),
        &RpClaims {
            iss: "https://your-rp.example".to_string(),
            sub: claims.sub,
            aud: claims.aud,
            iat: now,
            exp: claims.exp,
        },
        &jsonwebtoken::EncodingKey::from_secret(&state.jwt_secret),
    ).map_err(|_| Error::from_status(StatusCode::INTERNAL_SERVER_ERROR))?;
    Ok(Json(serde_json::json!({ "jwt": jwt })))
}
```

`state.gate` is `CachedGate<AlloyGate>` — `AlloyGate::new(rpc_url,
registry_address, expected_chain_id)` (the RESOLVED address, not a
`RegistryRef` — `AlloyGate` is the lower layer `DeedVerifier::new` builds on
top of after resolving one; see [Enterprise registries § Enforcement
posture](guide/enterprise-registry.md#enforcement-posture-read-this-before-relying-on-it-commercially)
for why composing `AlloyGate` directly bypasses the license gate)
wrapped in a short-TTL cache (`grantor_verify::sovereign_gate`), constructed
once at startup and shared across requests. It is the only network
dependency this path has: your own RPC endpoint into the chain the registry
lives on.

### Multi-RPC quorum

With one provider, that provider's word about the registry IS the
verifier's view of it: a compromised or merely lying node can report a
lapsed tenant active, or a revoked root recent, and nothing notices.
`QuorumGate` composes N independent providers and requires **unanimity** on
every chain read — every read goes to every provider, and one honest
provider vetoes a lie. `DeedVerifier::new(...)` is the degenerate case, a
quorum of one.

**The guarantee is exactly as strong as provider independence, no
stronger.** "Even your RPC provider can't lie to your verifier" is only
true when at least one configured provider is both honest and
independent. If every configured provider agrees on the SAME false
answer — compromised, colluding, or several endpoints resolving to one
shared upstream — quorum sees unanimity and passes it; there is nothing
to disagree with. What unanimity actually buys is that ONE honest,
independent provider vetoes a lie the others tell; it buys nothing
against a lie all N happen to agree on. Pick providers that don't share
infrastructure, or this reduces to a single point of failure with extra
steps.

Two ways a quorum read can fail, and both fail closed to the same 401/503
split every other chain read uses:

- **`Chain` (HTTP 503)** — a member errored or was unreachable. Same
  mapping as the single-provider case.
- **`QuorumDivergence` (HTTP 503)** — every member answered, and they
  disagreed. **This is the attack signal the feature exists to produce —
  alert on it.** A single occurrence can be innocent: two providers sitting
  at different block heights right after a state change (a `drawPeriod`
  flipping a tenant active, say) can diverge on one read and agree again on
  the next, because `CachedGate` never caches an error — agreement is
  re-established fresh every TTL window, never remembered stale. A
  *sustained* or *repeated* divergence is the one worth paging on: a
  provider that keeps disagreeing with the others is either broken or
  lying, and a verifier has no way to tell those apart from outside — that
  judgment call belongs to whoever operates the RP, which is why this stays
  an error the RP observes rather than something the library resolves for
  you.

**The trade is availability, deliberately.** Any one member down fails the
whole read closed, so a quorum verifier is only as available as its
*least*-available member, never its best one. That is the point, not a
defect: the trust property costs exactly what an independently-failing
member costs you. Pick 2-3 providers that are *actually* independent —
different infra, different operators. Two endpoints behind the same
upstream aren't a quorum; they're one provider with a proxy in front, and
they will never produce a `QuorumDivergence` worth trusting.

Construction takes the same parameters as the single-URL constructor, with
`rpc_url` replaced by a list:

```rust
use grantor_verify::verifier::DeedVerifier;

let verifier = DeedVerifier::new_quorum(
    &["https://rpc-a.example".to_string(), "https://rpc-b.example".to_string()],
    &registry_ref, chain_id, tenant_id, audience, origin, max_ttl_secs, cache_ttl_secs,
    allow_insecure_origin, now_unix,
)?;
```

```ts
import { DeedVerifier } from "../../sdk/verify/ts/pkg/grantor_verify_wasm.js";
import { Registry } from "../../sdk/verify/ts/src/registry.js";

const verifier = DeedVerifier.newQuorum(
  ["https://rpc-a.example", "https://rpc-b.example"],
  Registry.canonical(), CHAIN_ID, TENANT_ID, AUDIENCE, ORIGIN, MAX_TTL_SECS, CACHE_TTL_SECS,
  ALLOW_INSECURE_ORIGIN, Math.floor(Date.now() / 1000),
);
```

```python
from grantor_verify.grantor_verify_uniffi import DeedVerifier
from grantor_verify import registry

verifier = DeedVerifier.new_quorum(
    rpc_urls=["https://rpc-a.example", "https://rpc-b.example"],
    registry_ref=registry.canonical(), chain_id=CHAIN_ID, tenant=TENANT_ID,
    audience=AUDIENCE, origin=ORIGIN,
    max_ttl_secs=MAX_TTL_SECS, cache_ttl_secs=CACHE_TTL_SECS,
    allow_insecure_origin=ALLOW_INSECURE_ORIGIN, now_unix=NOW_UNIX,
)
```

```go
verifier, err := grantor_verify_uniffi.DeedVerifierNewQuorum(
    []string{"https://rpc-a.example", "https://rpc-b.example"},
    guard.RegistryCanonical(), chainID, tenantID, audience, origin, maxTtlSecs, cacheTtlSecs,
    allowInsecureOrigin, nowUnix,
)
```

A bare Rust `DeedGuard` that supplies its own gate instead of going through
`DeedVerifier` composes the two underlying types directly — `CachedGate` on
the outside, so agreement is re-checked every TTL window rather than
remembered. **This composition bypasses the enterprise-license gate**
(see [Enterprise registries §
Enforcement posture](guide/enterprise-registry.md#enforcement-posture-read-this-before-relying-on-it-commercially)):
`AlloyGate::new` takes an already-resolved address, not a `RegistryRef`, so
nothing here checks a license — resolve a `RegistryRef` yourself first
(`grantor_sdk_core::resolve_registry`) if you need that check on this path:

```rust
use grantor_verify::sovereign_gate::{AlloyGate, CachedGate, QuorumGate};

let gate = CachedGate::new(
    QuorumGate::new(vec![
        AlloyGate::new(rpc_a, registry, expected_chain_id),
        AlloyGate::new(rpc_b, registry, expected_chain_id),
    ]),
    cache_ttl_secs,
);
```

**Production note on `state.challenges`:** the example above is a plain
`HashSet<String>` that only ever grows on issue and shrinks on successful
redemption — an abandoned challenge (issued, then never redeemed) stays in
the set, and stays redeemable, forever. A production RP needs TTL/eviction
on this store (e.g. an expiring cache keyed by challenge, or a periodic
sweep) so an unused challenge cannot be redeemed arbitrarily far in the
future and memory doesn't grow unbounded under abandoned sessions.

## Discovery — how an agent finds all this

An application that accepts deeds publishes `/.well-known/grantor-deed`:

    { "v": 1, "tenant": 42, "audience": "api.example.com",
      "modes": ["user-sig", "agent-zk"], "challenge_endpoint": "/auth/challenge",
      "max_ttl_secs": 300, "chain": { "id": 42161, "registry": "0x…" },
      "origin_vouch": { "signature": "…", "epoch": 0, "exp": 1234567890 } }

`origin_vouch` is REQUIRED — `parse_discovery` rejects a document without it,
and the builder additionally refuses to emit one whose vouch has expired or
whose TTL exceeds the 90-day ceiling. See [Origin
provenance](#origin-provenance).

The guard serves it from the same `DeedPolicy` it verifies against, so what is
advertised cannot drift from what is enforced. The TypeScript (`grantorExpress`)
and Python (`GrantorDeps`) adapters mount it for you when you pass `app` plus
`challengeEndpoint`/`chainId`/`modes`. **Go does not auto-mount** — its
adapter deliberately owns no router (unlike Express/FastAPI, Go has no single
dominant one to couple to), so it stays a zero-dependency package; call
`guard.Discovery(...)` yourself and register the returned `http.HandlerFunc`
on whatever mux you use.

An agent needs only the origin and a way to sign — `authenticate(origin,
signMessage)` (`authenticate` in TS/Python/Go) composes discover → challenge →
mint for the `user-sig` mode, the mode an autonomous agent uses (permissionless,
no enrolment round trip). It ALSO requires a `chainReader` (an `eth_call` seam)
and a `registry`/`rpcUrl` pair (the `RegistryRef` you trust, resolved via a
live `eth_chainId` read over `rpcUrl` — defaults to `Registry.canonical()`,
the SDK pins the canonical registry for you; a custom address exists only on
the licensed [dedicated path](guide/enterprise-registry.md)) — see
[Constructing a chainReader](#constructing-a-chainreader) below for where
these come from and why both are required together:

```ts
import { authenticate } from "@grantor/agent";
import { Registry } from "@grantor/agent/registry";

const deed = await authenticate("https://api.example.com", signMessage, {
  chainReader,
  registry: Registry.canonical(),
  rpcUrl,
});
```

```python
from grantor_agent.authenticate import authenticate
from grantor_agent import registry

deed = await authenticate(
    "https://api.example.com", sign_message,
    chain_reader=chain_reader,
    registry=registry.canonical(),
    rpc_url=rpc_url,
)
```

```go
deed, err := authenticate.Authenticate("https://api.example.com", sign, &authenticate.Options{
	ChainReader: chainReader,
	Registry:    guard.RegistryCanonical(),
	RpcURL:      rpcURL,
})
```

The `chainRegistry`/`chain_registry`/`ChainRegistry` option each language
used to take (a plain, holder-pinned address) is **deleted** — passing it now
throws/raises/errors naming the replacement, rather than silently falling
through to the canonical default. **Python's `authenticate()` is now
`async`** (it used to be synchronous) — call it with `await`, matching
TS/Go's already-async shape.

`authenticate()` takes **only the origin** — there is no second URL parameter
for the challenge endpoint. The challenge is always resolved against the same
origin discovery was fetched from, structurally: a caller cannot discover at
one origin and authenticate at another, because there is nowhere to pass a
different one in. If the application does not accept deeds at all (404 on
discovery), or does not accept `user-sig` specifically, it refuses **before**
ever asking `signMessage` to sign, naming the modes the application does
accept.

### Constructing a `chainReader`

`chainReader`/`chain_reader`/`ChainReader` is the ONE `eth_call` seam
`authenticate()`/`signInWithDeed()` use to check origin provenance before
signing anything — see [Origin provenance](#origin-provenance) below for
what it protects against. It is entirely caller-supplied and MUST NEVER be
derived from the discovery document (a hostile origin would simply name its
own node). `registry`/`rpc_url` — resolved to the registry address
`chainReader` is queried against — is REQUIRED alongside it: the document's
own `chain.registry` is compared against this resolved pin and a mismatch is
refused, never used as the `eth_call` target directly (see [Origin
provenance](#origin-provenance) for why: a hostile origin can publish any
contract it likes as `chain.registry`, including one whose `isOriginVoucher`
always answers `true`). `registry` defaults to `Registry.canonical()` — the
SDK pins the canonical registry for you; get a custom address the same way
you get the RP's expected `tenantId`/`audience` — out of band, from the RP's
own documentation or operator, never from anything the RP's discovery
document says about itself — and a custom registry address on a real chain
requires an [enterprise license](guide/enterprise-registry.md)
(`Registry.dedicated(address, license)`); the standard SDK cannot express
one otherwise.

**Browser (`window.ethereum`)** — a wallet provider already speaks this
shape:

```ts
// `data` arrives as a Uint8Array — no `Buffer` here on purpose: Vite and
// webpack 5 don't polyfill Node globals, so this is the one recipe on this
// page that actually has to run in a browser, not just compile.
const toHex = (bytes) =>
  "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");

const chainReader = {
  call: ({ to, data }) =>
    window.ethereum.request({
      method: "eth_call",
      params: [{ to, data: toHex(data) }, "latest"],
    }),
};
```

**Server-side (TypeScript/Node)** — a minimal hand-rolled `eth_call` over any
JSON-RPC endpoint (this is exactly why `chainReader` is a raw `eth_call` seam
rather than an `alloy`/`ethers` dependency — it keeps `@grantor/agent` free
of a multi-MB chain library):

```ts
async function makeJsonRpcChainReader(rpcUrl) {
  return {
    call: async ({ to, data }) => {
      const res = await fetch(rpcUrl, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          jsonrpc: "2.0", id: 1, method: "eth_call",
          params: [{ to, data: "0x" + Buffer.from(data).toString("hex") }, "latest"],
        }),
      });
      const { result } = await res.json();
      return result;
    },
  };
}
```

**Server-side (Python)**:

```python
import requests

def make_chain_reader(rpc_url):
    def chain_reader(to: str, data: bytes) -> bytes:
        resp = requests.post(rpc_url, json={
            "jsonrpc": "2.0", "id": 1, "method": "eth_call",
            "params": [{"to": to, "data": "0x" + data.hex()}, "latest"],
        })
        return bytes.fromhex(resp.json()["result"][2:])
    return chain_reader
```

**Server-side (Go)**:

```go
type jsonRPCChainReader struct{ rpcURL string }

func (r jsonRPCChainReader) Call(to string, data []byte) ([]byte, error) {
	body, _ := json.Marshal(map[string]any{
		"jsonrpc": "2.0", "id": 1, "method": "eth_call",
		"params": []any{map[string]string{"to": to, "data": "0x" + hex.EncodeToString(data)}, "latest"},
	})
	resp, err := http.Post(r.rpcURL, "application/json", bytes.NewReader(body))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	var out struct {
		Result string `json:"result"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, err
	}
	return hex.DecodeString(strings.TrimPrefix(out.Result, "0x"))
}
```

**Rust** uses `AlloyGate` directly — see the Rust composition example below;
it already implements the equivalent seam over `alloy`, since a Rust holder
is not paying the wasm-bundle cost `chainReader` exists to avoid for the
other three languages.

That same `origin` argument is also what gets **signed** — never a value read
out of the discovery document. `authenticate()` passes it straight through to
`mintUserDeedFromWallet` as the `origin` parameter (immediately after `aud` in
every language), so the deed is bound to the origin the agent actually talked
to, not to anything the document claimed. See [Origin
binding](#origin-binding) below for why this matters.

For `agent-zk` (an already-enrolled agent proving ZK membership — see
[Minting an `agent-zk` deed](#what-it-is) above), `authenticate()` does not
apply: compose `discover()` and a challenge fetch by hand, then call
`ZkAgent.mintDeed` yourself:

    const d = await discover("https://api.example.com");
    if (!d) return;                       // does not accept deeds
    const res = await fetch(new URL(d.challenge_endpoint, origin));
    const { challenge } = await res.json();
    // registryAddress and rpcUrl are YOUR configuration. Never d.chain.registry:
    // a document-supplied eth_call target lets a hostile origin point the
    // provenance check at a contract it controls, which answers `true`.
    if (d.chain.registry.toLowerCase() !== registryAddress.toLowerCase()) return;
    const v = d.origin_vouch;
    // `registryAddress` is wrapped in a RegistryRef only at the mint call —
    // Registry.canonical()/Registry.dedicated(addr, license) for a real
    // deployment; Registry.devnet(addr) shown here matches the pinned
    // comparison above (a devnet address is what most local recipes pin).
    const deed = await agent.mintDeed(
      rpcUrl, Registry.devnet(registryAddress), d.tenant, d.audience, origin, challenge, exp,
      v.signature, v.epoch, v.exp,
      false, // allowInsecureOrigin — true only for local dev against http/localhost
      Math.floor(Date.now() / 1000),
    );

**`mintDeed` verifies origin provenance before it proves.** It takes the
tenant admin's vouch and checks it against the registry **you** pinned, and
refuses before generating a proof if the origin cannot show one — mirroring
what `authenticate()` does for `user-sig`. This is what closes [Origin
binding](#origin-binding) items 3 and 4.

**`mintDeed` also enforces the fail-closed origin policy before any of
that.** `allowInsecureOrigin` (default-secure `false`) is forwarded to
`enforce_origin_policy`, checked right after `origin` is canonicalised and
before origin provenance or any chain call: a non-HTTPS or non-routable
`origin` is refused with `InsecureOrigin` rather than building (and
discarding) a real Semaphore membership proof for a deployment that could
never be production. Pass `true` only for local dev / a demo against
`http://localhost:...`.

Without this check, a hostile origin publishing a victim tenant's
`tenant`/`audience` could induce a holder composing this recipe to hand over a
valid Semaphore membership proof for the victim's registry plus a nullifier
comparable across every origin sharing that `(tenant, audience)`. There is no
`authenticate()`-style wrapper for `agent-zk` to hide such a gap in —
`mintDeed` is the only `agent-zk` mint API there has ever been, so the check
lives here.

**⚠️ Two things you must still get right, because they are yours, not the
SDK's.** `registryAddress` and `rpcUrl` must come from YOUR configuration.
Passing `d.chain.registry` makes the pin vacuous and the SDK cannot tell —
it never sees the document. Compare the document's value against your pin and
refuse on mismatch, as the snippet above does; `authenticate()` is structurally
safe here only because it reads the document itself and does that comparison
for you.

Fetch discovery from the **same origin** you will present the deed to, and
pass that SAME origin — never `d.audience` or anything else out of the
document — as `mintDeed`'s `origin` argument. See [Origin
binding](#origin-binding) below for why: without it, composing this by hand
is exactly where a caller could accidentally (or be tricked into) signing the
wrong origin, since `authenticate()`'s structural guarantee no longer applies
once you're composing the calls yourself.

**Rust has no `authenticate()`.** `grantor-sdk-core` is deliberately
HTTP-free — that is what keeps it wasm-clean, and `just sdk-wasm-check` guards
it in CI — so there is nothing in Rust to fetch a URL with. A Rust holder
composes the flow by hand. This is composition, not a missing capability:
`just capability-matrix`'s Rust column is marked "compose it yourself" for this
one row rather than a bound function, because there is no HTTP client to bind.

**But composing it means composing the SECURITY CHECK too, not just the happy
path.** What `authenticate()` does for the other three languages, in order:

1. Canonicalise your own origin (`normalize_origin`) — once, before any fetch,
   so the URL you fetch and the origin you sign can never diverge.
2. Fetch `discovery::DISCOVERY_PATH` from that origin with your own client, and
   validate the response through `discovery::parse_discovery`. Never
   hand-parse — that validator is shared with every other language on purpose.
3. **Origin provenance, BEFORE anything is signed** ([Origin
   provenance](#origin-provenance)): recover the vouch signer with
   `originvouch::origin_vouch_signer(tenant, audience, YOUR_OWN_ORIGIN, epoch,
   exp, sig)`, reject `exp <= now` and `exp - now >
   ORIGIN_VOUCH_MAX_TTL_SECS`, then confirm `isOriginVoucher` on-chain and
   refuse if it is false or the read fails. **Pass your OWN observed origin,
   never the document's** — that substitution is the entire attack this
   defeats. **Read against a registry address YOU pinned out of band, never
   `chain.registry` from the document**, and over an RPC endpoint of your own:
   a hostile origin publishes whatever contract it likes there, including one
   whose `isOriginVoucher` always answers `true`.
4. Only then fetch the challenge endpoint the document names, and mint —
   `usersig::derive_root_seed(origin, …)` → `derive_app_key(seed, tenant,
   origin)` → `mint_user_deed(…)` (see [User login](#user-login-user-sig)),
   or `sovereign::mint_deed` for `agent-zk`.

Step 3 is the one that is easy to leave out, and leaving it out is silent —
everything still works, against every origin. `crates/grantor-verify/tests/sovereign_e2e.rs`
composes exactly this (`holder_check_origin_provenance` /
`holder_authenticate_user_sig`) against a live chain and asserts the signing
callback is **never invoked** for an unvouched origin; treat it as the
reference implementation of this list.

## Fail-closed origin policy

Separate from origin *binding* (below) and origin *provenance* (above): this is
about whether an origin is even well-formed enough for production, not about
who it is cryptographically tied to or vouched for. `enforce_origin_policy(origin,
allow_insecure_origin)` (`grantor_sdk_core`) refuses two things by default —
a scheme other than `https`, and a non-routable host
(`localhost`/`*.localhost`/`*.local`/`0.0.0.0`, and loopback/private/link-local/ULA
IPv4 and IPv6 literals). `normalize_origin`'s structural check (a malformed
scheme, an embedded `|`, userinfo, a real path) still runs first regardless of
the flag and surfaces as `SdkError::Input`/`DeedError::Malformed`; only a
well-formed-but-insecure origin gets `SdkError::InsecureOrigin`/
`DeedError::InsecureOrigin`.

**The default is secure everywhere the parameter exists.** `allow_insecure_origin`/
`allowInsecureOrigin` defaults to (and should stay) `false` in any real
deployment; a service configured with `http://localhost:3000` fails loudly at
boot/mint time rather than silently accepting a non-routable, non-HTTPS origin.
Set it `true` only for local dev or a demo — every
recipe on this page that talks to `http://localhost` passes `true` explicitly,
never by omission (see `docs/deploy/demo.md`'s note on `deploy.sh` computing it
from `$ORIGIN` for exactly this reason).

It is enforced at exactly **four** call sites, independently, because there is
no single point in the code path that sees every origin:

1. **The verifier's own configured origin** — `DeedPolicy.allow_insecure_origin`
   / `DeedVerifier`'s `allowInsecureOrigin` constructor argument (second-to-last,
   immediately before `nowUnix`).
   Checked as literal step 0 of `verify_deed_claims`/`verify_user_1271` —
   before the mode check, before the deed's own origin comparison, before
   either chain read. See [What the verifier checks](#what-the-verifier-checks)
   below and [Errors](guide/errors.md).
2. **The discovery document** — `discovery_document` (`grantor-verify`) runs
   the same check on `policy.origin` before assembling the document at all, so
   a misconfigured RP never advertises a document a holder could authenticate
   against.
3. **The origin vouch a tenant admin signs** — `origin_vouch_binding`
   (`grantor_sdk_core::originvouch`) refuses to build the vouch message for an
   insecure origin, so an admin cannot sign a vouch for `http://localhost` that
   ends up attached to a real tenant.
4. **Minting an `agent-zk` deed** — `mint_agent_deed`/`ZkAgent.mintDeed`
   (`grantor_sdk_core::agent`) checks it right after canonicalising `origin`
   and before origin provenance or any chain call, refusing before building
   (and discarding) a real Semaphore proof for a deployment that could never be
   production.

**Deliberately NOT enforced:** the raw `user-sig`/`user-passkey` mint bindings
(`mint_user_deed`/`mint_user_passkey_deed` take no `allow_insecure_origin`
parameter at all — for `user-sig` the security-relevant enforcement point is
`authenticate()`'s own config plus the verifier's policy, not the bare mint
call), and `admin-sig` (its binding takes no `origin` argument in the first
place — see "`admin-sig` is deliberately exempt" under [Origin
binding](#origin-binding) just below).

## Origin binding

Every `user-sig`/`agent-zk` mint call and every `DeedVerifier` take an
`origin` — the scheme+host+optional-port the holder actually talked to
(`https://api.example.com`; a path is rejected, but a bare trailing slash is
tolerated and folded — see below). It sits immediately after `aud`/`audience`
in every mint call and every verifier constructor, in every language, so a
transposition between the two is visually obvious.

**It joins the SIGNED material, not the wire envelope.** `user_binding`
(`user-sig`) and `sovereign_signal_hash` (`agent-zk`) fold `origin` in
alongside `tenant`/`aud`/`challenge`/`exp`; `Deed` itself gains no `origin`
field — a value the holder supplied and the verifier trusted would defeat the
whole point. `DeedVerifier`/`DeedPolicy` carry `origin` as **your own
configuration**, normalised (`normalize_origin` — lowercases the host, strips
a default port, tolerates a single bare trailing slash) so two
differently-spelled configurations of the same origin cannot silently
diverge: `DeedVerifier`/`DeedVerifierJs` normalise once at construction,
`grantor_verify::sovereign::verify_deed_claims` normalises `DeedPolicy.origin`
on every call (so a bare Rust struct literal gets the same protection), and
every mint entry point (`mintUserDeed`/`mint_user_deed`/`MintUserDeed`,
`mintDeed`, `mintUserDeedFromWallet`) canonicalises `origin` before signing —
**including both functions a Rust holder calls directly**,
`grantor_sdk_core::usersig::mint_user_deed` (see the snippet below) and its
`agent-zk` twin `grantor_sdk_core::sovereign::mint_deed`. The first is why
`mint_user_deed` is fallible rather than the plain `Deed`-returning function
it started as. Both were once exceptions to this sentence, which is the
reason the sentence now names them: the claim was written before it was
true — so a holder's `https://API.example.com/` and an RP's
`https://api.example.com` — the same origin, spelled two ways a browser or an
operator could equally produce — bind the SAME credential rather than
silently failing at the signature. `normalizeOrigin`/`normalize_origin`/
`NormalizeOrigin` is exported from every holder-side binding so a caller can
canonicalise once, up front, before either fetch — see `authenticate()`
below. Verification rebuilds the binding from the normalised configured
origin and the challenge you issued; it never reads an origin back out of
the token.

**Why this exists.** Before origin joined the signed material, a hostile
origin A could publish victim B's `tenant`/`audience` in its own discovery
document, relay a challenge it fetched from B, and collect a deed: the holder
signs against B's genuine tenant/audience/challenge with its real key for B,
so the signature/proof is entirely valid — it just says nothing about WHERE
the holder was. A replays that deed to B, whose verifier saw a correct
audience, a live challenge it issued, and a valid signature/proof — full
impersonation, no cryptographic material forged. Binding the origin closes
it: B's verifier only ever rebuilds the binding with B's own origin, so a
deed signed while talking to A can never reconstruct that same binding, and
fails as `BadProof`.

**What this closes, and what still does not.** Four separate claims, kept
separate on purpose — conflating them is exactly the overclaim this section
existed to correct once already:

1. **Impersonation: closed.** As above — B's verifier only ever rebuilds the
   binding from its own configured origin, so a deed signed while the holder
   was talking to A can never reconstruct it and fails as `BadProof`.

2. **`user-sig` pseudonym harvesting: closed.** `derive_app_key` folds
   `origin` into its HKDF info (`tenant:{t}|origin:{o}`, `usersig.rs`; `aud`
   is deliberately not part of that scope — see "User login" below), so the app key — and therefore `pubkey`/`sub` — is scoped
   to the origin the holder actually derived against. A publishing B's
   `tenant`/`audience` and getting a holder to authenticate no longer
   collects B's pseudonym; it collects a pseudonym scoped to **A**, unrelated
   to the one B would see. Since `user-sig` carries no membership proof, that
   pseudonym is the entirety of what `user-sig` could ever leak to A — so
   origin-scoping the key closes the harvest completely, not just partially.

   ⚠️ Note the boundary: this is about what a hostile origin learns from a
   holder that *derives correctly*. It is **not** a defence against a hostile
   origin that obtains the root seed, because the origin is an argument the
   deriving party supplies. That is closed separately, by origin-scoping the
   SEED itself — see "User login" below.

3. **`agent-zk` cross-origin linkability: closed by prevention.**
   `sovereign_external_nullifier` deliberately still scopes on `(tenant,
   audience)` only — origin was considered and NOT added to it, for two
   reasons. First, audience already separates different relying parties, so
   adding origin would buy separation only in the narrow case of two origins
   sharing one tenant *and* audience — essentially just the hostile-origin
   case this section is about. Second, origin-scoping it would break the
   property that the sovereign tier's `sub` equals the blind tier's
   `app_scoped_pseudonym` (`blindclient.rs`) — the mechanism by which an
   agent recognised on one tier is recognised as the same principal on the
   other. This leaves a hostile origin A that publishes victim B's
   `tenant`/`audience` able to collect a nullifier that repeats every time
   the same agent authenticates to A, comparable against nullifiers seen
   elsewhere sharing that same `(tenant, audience)`. [Origin
   provenance](#origin-provenance) below closes this **by prevention** — a
   holder that checks provenance before minting never authenticates to A at
   all, so there is no nullifier for A to collect.

   That check runs on the `agent-zk` mint path: `ZkAgent.mintDeed` takes
   the vouch and verifies it against the caller's PINNED registry **before
   generating a proof**, in all four languages — `mintDeed` is the only
   `agent-zk` mint API and no `authenticate()`-style wrapper exists for it,
   so the check lives there. The ordering, not merely the error, is pinned
   per language; see "How the ordering is proven" below.

4. **`agent-zk` membership disclosure: closed by prevention.** Without this
   check, A
   would additionally collect a valid Semaphore proof that the holder is
   enrolled in B's on-chain registry — evidence of membership, not merely a
   pseudonym. No signed-material binding could ever close this on its own:
   demonstrating membership is what the proof is *for*, and scoping what a
   proof is bound to changes who can *use* the proof, not whether an
   untrusted origin can *extract* the fact of membership by asking a holder
   to produce one at all. [Origin provenance](#origin-provenance) closes it
   the only way it could be closed — by stopping the holder from ever
   producing that proof for A. As with item 3, this rests entirely on
   `mintDeed` refusing **before** it proves, which is why the tests assert
   that no membership proof was built rather than merely that an error was
   raised.

   ### How the ordering is proven

   Refusing "at some point" would be worthless here: a proof generated and
   then discarded has still been built, and building it tells the RPC endpoint
   which tenant the agent belongs to. So each language asserts that proving was
   never entered, and each assertion was verified by moving the check after
   `build_membership_proof` and confirming that specific test fails:

   * **TypeScript / Python / Go** use a deliberately *unregistered* agent
     against the hostile origin. If proving ran first the failure would be
     `NotAMember`; getting `BadOriginVouch` instead is what proves the order.
   * **Rust** has no `ZkAgent` — a holder composes the recipe by hand — so
     `hostile_origin_is_refused_before_an_agent_zk_proof_is_built` in
     `crates/grantor-verify/tests/sovereign_e2e.rs` counts membership fetches
     against a live anvil and requires **zero**.
   * Each also mints successfully at the genuinely vouched origin, so none of
     the above can pass because provenance is simply broken shut.

Both `agent-zk` items above close through **Part B (origin provenance)**:
the tenant admin vouches offline that an origin speaks for the tenant, and
the holder verifies that on-chain *before* signing or proving, so the
interaction with a hostile origin never happens in the first place —
prevention, not scoping after the fact. **Part B covers both the
`user-sig` holder path** (`authenticate()` and `signInWithDeed()`) **and
the `agent-zk` mint path** (`ZkAgent.mintDeed`), in all four languages — see
items 3 and 4 above for the ordering proof (provenance-before-proving, not
merely provenance-before-return, pinned per language). The `agent-zk` half
of the check lives once, in `grantor_sdk_core::agent::mint_agent_deed`
(`crates/grantor-sdk-core/src/agent.rs`), which every language's
`ZkAgent.mintDeed` marshals into rather than duplicating the check per shim.
The check is still holder-side, not RP-enforceable — see "Only as good as a
holder that runs the check" below. See [Origin provenance](#origin-provenance)
below for what this closes, what it still does not, and its cost.

**This enforces nothing.** `verify_user_sig` recomputes `sub` from the
pubkey alone; it has no way to observe which origin a holder derived
against, so origin-scoping is not something a verifier checks or can check —
it is a property a holder gets for free by deriving correctly, and loses if
it (or a hostile origin's SDK fork) derives against the wrong origin. It is
a privacy property for the holder, not an authorization control, and it adds
no gate a verifier enforces.

**Cost.** A relying party that legitimately serves one audience from two
origins (a staging origin and a production origin sharing a tenant and
audience, say) now has its users derive two different `user-sig`
pseudonyms from the same wallet — one human looks like two accounts. That
is the same tradeoff a browser's same-origin policy makes for cookies and
storage, not a defect specific to this design.

**`authenticate(origin, ...)` makes the fix structural, not just available.**
It canonicalises `origin` ONCE, at the top, before EITHER the discovery fetch
or the challenge fetch, and uses that SAME canonical value for both fetches
AND the mint call — never anything read out of the document. A document
cannot influence what origin ends up in the deed, because the document is
never consulted for it, and a malformed origin (a real path, userinfo, …) is
refused before any network call rather than surfacing as an undiagnosable
`BadProof` two fetches later.

**Redirects do not change what gets signed.** All three SDKs' discovery and
challenge fetches follow redirects by default (plain `fetch`/`requests`/
`net/http` behaviour) and none of them reads the response's final URL for
anything — `origin` is signed exactly as the CALLER supplied it (after
canonicalisation), never re-derived from wherever the fetch actually landed.
This is a deliberate choice, not an oversight: binding to the final URL's
origin would mean a redirect an attacker controls (or one an operator adds
later for an unrelated reason — a `www.` canonicalisation, a load balancer
migration) silently changes what a credential is bound to, with no visibility
to the caller. The origin you pass to `authenticate()` MUST be the one your
RP is actually configured with (its `DeedVerifier`/`DeedPolicy.origin`) — if
discovery or the challenge fetch redirects to a different origin, the deed
still signs the ORIGINAL one, and verification then depends on which origin
the RP is configured with, not on where the fetch happened to end up.

**`admin-sig` is deliberately exempt.** Its binding is human-readable prose
that already leads with the requesting domain ("Only sign it if you are on
{aud} right now") and it never appears in a discovery document's `modes`, so
it is unreachable by the attack origin binding closes — see [Dashboard
login](#dashboard-login-admin-sig).

## Origin provenance

Origin binding (above) closes impersonation and `user-sig` pseudonym
harvesting, but leaves two `agent-zk` exposures open on its own: a hostile
origin A that publishes victim tenant B's `tenant`/`audience` can still
*solicit* an interaction — collecting a repeating nullifier, and a valid
Semaphore proof of membership in B's registry — even though A can never
present the resulting deed to B. Signed-material binding cannot close
either: both are things A learns by *asking* a holder to sign or prove, not
by replaying what the holder produced.

**The fix is prevention, not further scoping.** A tenant admin signs an
offline vouch — human-readable prose, the same convention as `admin-sig`'s
binding — that a specific origin currently speaks for the tenant:

    {origin} is claiming to act for Grantor tenant {tenant}.

    Signing this authorises {origin} to ask people and agents for deeds
    belonging to the tenant below. Only sign it if you administer that
    tenant AND you control {origin} — anyone holding this signature can
    make holders believe {origin} speaks for you.

    Tenant: {tenant}
    Audience: {aud}
    Origin: {origin}
    Epoch: {epoch}
    Expires (unix seconds): {exp}

    grantor-origin-v1

The guard publishes that signature in its discovery document
(`origin_vouch` — **required**, not optional: an optional vouch is one a
hostile origin simply omits, and a holder that tolerates its absence gains
no protection at all). Before signing or proving anything, the holder
recovers the vouch's signer against the origin **it itself observed** —
never a value read out of the document, for the same reason `origin`
joins the signed material above — and asks the chain
`isOriginVoucher(tenant, signer, epoch)`:

```solidity
function isOriginVoucher(uint256 id, address signer, uint64 epoch)
    external view returns (bool)
{
    return isAdmin[id][signer] && epoch == originEpoch[id];
}
```

A tenant admin revokes a compromised or decommissioned origin's vouch with
`bumpOriginEpoch(id)` — every outstanding vouch (all signed against the
old epoch) stops verifying immediately, with no expiry to wait out.

**What this closes today.** `agent-zk` cross-origin linkability and
membership disclosure — the two items [Origin binding](#origin-binding)
leaves open above — close **by prevention**: a holder that checks
provenance before minting never authenticates to an origin that cannot show
a vouch, so the interaction with a hostile origin never happens in the
first place, and there is nothing left for A to solicit.

The provenance check is wired into every mint-side entry point, in all four
languages: `authenticate()` and `signInWithDeed()` (`user-sig`) *and*
`ZkAgent.mintDeed` (`agent-zk`); see
[Origin binding](#origin-binding) items 3-4 above for the ordering proof
(each language asserts proving was never *entered*, not merely that an
error came back). The `agent-zk`
half of that check lives once, in `grantor_sdk_core::agent::mint_agent_deed`
(`crates/grantor-sdk-core/src/agent.rs`), which every language's
`ZkAgent.mintDeed` marshals into rather than duplicating the check per shim
— a single shared implementation, rather than one copy per shim, is what
keeps the check from silently drifting out of sync between languages.

The check remains holder-side, not RP-enforceable, and every residual
limit documented in this section (a compromised admin key, a holder that
skips its own check, a multi-origin RP) applies regardless.

**What this does NOT close.** A compromised tenant admin key can vouch for
any origin it likes. This is not a new exposure — that same key can already
add other admins and change the tenant's tier — and **`bumpOriginEpoch`
does not mitigate it**: the compromised key simply signs a fresh vouch at
the new epoch, immediately, exactly as a legitimate admin would (proven,
not merely asserted, by `origin_vouch_authenticates_then_revoked_by_epoch_bump`
in `crates/grantor-verify/tests/sovereign_e2e.rs` — the same key that gets
bumped can re-vouch and pass again the moment it does). `bumpOriginEpoch`
revokes an origin, not an admin.

**Only as good as a holder that runs the check.** This is a holder-side
control. A holder that skips it — or a fork of the SDK that strips it out —
gains nothing from a vouch existing; it will happily sign for whatever
origin asks.

**Part A's origin-scoped key derivation is not an independent backstop here.**
It scopes the per-app KEY, but the origin is an argument the deriving party
supplies, and in a browser a hostile page is the deriving party — "the holder
derives correctly" protects only a holder that is actually doing the
deriving. The root seed itself is also origin-scoped, with the signed message
naming the origin in prose; see [Why the seed is
origin-scoped](#why-the-seed-is-origin-scoped) under [User
login](#user-login-user-sig) for what that buys and does not buy, and
`crates/grantor-verify/tests/usersig_root_seed_scope.rs` for the regression
pin.

There is no automatic, unskippable holder-side backstop behind the origin
provenance check: a holder that skips it is relying on a human reading a
wallet prompt.

**The guard's own self-check is DX, not enforcement.** It lets an RP catch its
OWN misconfigured vouch at boot — one signed for the wrong origin, one that has
quietly expired, one whose TTL no holder will accept — instead of discovering it
from a stream of failed logins. It is anti-drift tooling for the operator, not a
security control: nothing stops a bare RP that never calls it from being
perfectly secure, because enforcement lives entirely on the holder side, above.
Conversely, calling it buys an RP nothing if its holders don't check provenance
either.

It comes in two halves, split by whether the check needs a chain read:

* **Offline, and unavoidable** — building the discovery document refuses an
  expired vouch or one whose TTL exceeds the 90-day ceiling
  (`ORIGIN_VOUCH_MAX_TTL_SECS`). This is in the shared document builder, so
  every language gets it whether or not the operator remembers to run a
  self-check, and it fails at the point where the vouch would otherwise be
  *advertised*. Advertising a vouch that cannot work is worse than not
  advertising: the holder's resulting failure names nothing, this one names the
  field.
* **Chain-backed, and explicit** — `verifyOwnOriginVouchAt` (Rust:
  `DeedGuard::verify_own_origin_vouch`) additionally confirms the recovered
  signer genuinely is an admin for this tenant at this epoch, and returns the
  seconds remaining when the vouch expires within 14 days so an operator can
  warn on it. Call it once at boot. It reads through the **uncached** gate on
  purpose: a cached `true` would keep a `bumpOriginEpoch`-revoked origin passing
  for a TTL window, and a boot-time check has nothing to amortise.

**The cost you accept.** Checking `isOriginVoucher` before minting means
login now depends on the holder having a working chain connection: the
wallet's own provider being connected, and pointed at the right chain.
A signature-only signer with no RPC access of its own — one wired to
produce raw signatures and nothing else — cannot perform this lookup and
cannot complete a login. This mirrors every other chain read in the
sovereign tier (root recency, tenant status): the tier trades "no auth
server" for "a working RPC connection is now part of the auth critical
path," and origin provenance is one more read on that same path, not a new
kind of dependency.

**Nothing new is published on-chain.** The vouch rides `isAdmin` — already
a public mapping — plus one `uint64` epoch counter per tenant
(`originEpoch`). An on-chain *origin registry* (storing which domain a
tenant claims, so anyone could look it up directly) was considered and
**rejected**: it would publish a permanent, enumerable `tenant → domain`
map, which — combined with the `drawPeriod` events the billing model
already emits — would disclose who Grantor's customers are and when their
subscriptions lapse. `isOriginVoucher` answers only "does this address
currently vouch for this tenant at this epoch", never "what does this
tenant claim as its origin", so nothing about a tenant's actual domain(s)
is ever readable from chain state.

## User login (`user-sig`)

A human signs in with their wallet and gets a stable pseudonym scoped to the
`(tenant, origin)` deployment — still with no Grantor service involved.

**This holder path ships in TypeScript, Python, Go and Rust** — `userRootBinding`,
`deriveRootSeed`, `deriveAppKey`, `userSub`, `userBinding` and `mintUserDeed`
are exported from `sdk/ts` (wasm-bindgen), `sdk/python` and `sdk/go` (UniFFI),
all asserted against the same `sdk/conformance/vectors.json` entries the Rust
core defines, so the four bindings are proven identical rather than merely
parallel implementations. The double-sign-and-compare determinism check
(below) lives once in `grantor-sdk-core` and every binding calls into it —
no language reimplements it. The Rust snippet below is the source of truth;
see `sdk/README.md` for the equivalent TS/Python/Go calls.

```rust
use grantor_sdk_core::usersig::{derive_root_seed, derive_app_key, mint_user_deed};

// The wallet signs a human-readable message NAMING THIS ORIGIN
// (`user_root_binding`). `derive_root_seed` signs it TWICE and errors if the
// results differ — a wallet that signs non-deterministically would otherwise
// mint the user a brand-new identity on every login, silently. Failing
// loudly is the point.
//
// The seed is per-origin, so ONE POPUP PER ORIGIN — not one popup for the
// whole web. A single fixed message identical at every origin would let
// any site holding one `personal_sign` derive the holder's key at every
// other relying party. See "Why the seed is origin-scoped" below.
let seed = derive_root_seed(origin, |msg| wallet.personal_sign(msg))?;

// The key is derived locally for (tenant, origin), so one popup still covers
// every AUDIENCE this deployment serves. `origin` is the origin YOU actually
// obtained this challenge from — never a value taken out of a discovery
// document. The SAME canonical `origin` feeds every call here; deriving with
// one origin and minting with another would produce a deed whose pubkey no
// verifier can tie to its binding.
let app_key = derive_app_key(&seed, tenant_id, origin)?;
// `mint_user_deed` CANONICALISES `origin` before signing (`normalize_origin`)
// and is fallible for exactly that reason — a genuinely malformed origin (a
// real path, userinfo, …) is refused here, at mint time.
let token = mint_user_deed(&app_key, tenant_id, audience, origin, &challenge, exp)?;
```

**`aud` is deliberately NOT in the derivation scope**, so one
`(tenant, origin)` yields ONE subject across every audience that deployment
serves. That is what makes a role assignable: a role system must be able to
say "this subject has this role", and it cannot if the same human is a
different opaque `sub` at each of an operator's own APIs. `aud` keeps the job
OAuth gives it — it stays in the signed `user_binding`, so a deed minted for
one audience still cannot be replayed at another. **`aud` restricts; `sub`
identifies.** The accepted cost: two audiences under one `(tenant, origin)`
are correlatable by that operator. Across tenants and across origins nothing
changed, and `tenant` now carries the whole cross-customer separation burden.

### Why the seed is origin-scoped

`derive_root_seed` signs a human-readable message naming the origin, mirroring
`admin-sig`'s binding, and the resulting seed is scoped to that origin. A
single fixed message identical at every origin would instead let any site
that obtained one `personal_sign` derive the holder's key at every relying
party — full impersonation. Origin-scoping the seed closes that.

**Do not describe `user-sig` itself as structurally closed.** `personal_sign` lets the
requesting page choose the message freely, so a hostile page can request the
*victim's* origin-scoped message rather than its own, and the wallet — which
cannot tell who is asking — will sign it. What stands there is the human
reading a prompt that names an origin they are not on. That is the same
phishing-resistance ceiling `admin-sig` deliberately accepts. Making it a hard
guarantee needs a signing primitive the BROWSER binds to origin (passkeys /
WebAuthn), not a better string. Pinned by
`residual_risk_a_hostile_page_may_request_another_origins_message` in
`crates/grantor-verify/tests/usersig_root_seed_scope.rs`.

What origin-scoping the seed buys, unconditionally: a seed captured at one
origin is useless at any other, so compromise is contained to the origin the
user actually signed at.

**A signing primitive the browser binds to origin is built, as
[`user-passkey`](#passkey-login-user-passkey).** For holders
who use a passkey, this residual is **closed**, and closed **over-determined**, not by one
lucky check: WebAuthn binds the ceremony to an origin TWICE, independently, both written by
the browser itself rather than application code — `clientDataJSON.origin` (the origin the
ceremony actually ran on) and the credential's `rpId`, hashed into
`authenticatorData.rpIdHash` (the origin the credential itself is scoped to, fixed at
registration) — and the verifier checks both against its OWN configured `cfg.origin`, then
recomputes `sub` from the credential public key, all **cfg-anchored, none read from the
wire**. A hostile page cannot make either browser-written field lie about the page it is
really running on, the way it can make `personal_sign` sign an arbitrary requested string.
Mutation-pinned in `crates/grantor-verify/tests/userpasskey_verify.rs`
(`passkey_refuses_a_deed_whose_clientdata_origin_is_not_the_rp_origin`): deleting the
`clientDataJSON.origin` check does **not** make the mismatched-origin deed verify — it still
fails, now via `rpIdHash`, so the mutation observes `DeedError::BadProof` rather than
acceptance. That is what "over-determined" means concretely: no single field is *the*
reason cross-origin deeds are refused, so removing one does not reopen the hole.

**`user-sig` is unchanged and remains the compatibility path** — it is not "fixed" by
`user-passkey` existing, it retains exactly the residual documented above. Pick
`user-passkey` when you can require a passkey; pick `user-sig` when you need to accept any
wallet a human already has (compatibility). See [Passkey
login](#passkey-login-user-passkey) for the full mode.

**What this proves — and what it does not.** `user-sig` proves *"I control a
key"*, not *"I control a wallet"*. Permissionless login is open by
definition: anyone can generate a key. The wallet's role here is
**portability of identity across devices**, not gatekeeping. Requiring
membership is a separate, gated mode (allowlist / token-gate / DAO) that is
not built yet.

**There is no on-chain user revocation**, because there is no on-chain user
state to revoke — no registration, no commitment, no nullifier. An RP that
wants to ban someone bans the `sub` on its own side. That is a property of
permissionless login, not a gap.

### What the chain sees

The only on-chain read on this path is the tenant's billing status.

| Scenario | `sub` | Chain validation |
|---|---|---|
| Same wallet, 2 audiences, same tenant + origin | **1 shared** — `aud` is not in the key scope, so a role can name the subject | One check: that tenant's status |
| Same wallet, 2 origins, same tenant | 2 different, unlinkable | One check: that tenant's status |
| Same wallet, 2 apps, different tenants | 2 different, unlinkable | Two independent per-tenant checks |
| 1 user or 10M users | — | Identical; the chain never sees them |

So user logins cost **zero gas**, leave **zero on-chain footprint**, and scale
without touching the chain. Everything here is scoped by the on-chain tenant
id rather than by any Grantor infrastructure, so a `sub` never breaks because
of how or where an operator runs their own relying-party code.

## Passkey login (`user-passkey`)

A human logs in with a **passkey** (WebAuthn) instead of a wallet, and your app gets the
same shape of stable, app-scoped pseudonym `user-sig` gives — except origin binding is
enforced by the browser's own WebAuthn implementation, not by a human reading a signing
prompt. Additive alongside `user-sig`: nothing about wallet login changes, and `user-sig`
remains the compatibility path for a caller who only has a wallet. See [Why the seed is
origin-scoped](#why-the-seed-is-origin-scoped) above for the residual this
closes and why it is closed **for passkey holders specifically**, not retroactively for
`user-sig`.

### The ceremony (browser, TypeScript)

1. **Registration, once per `(rpId, human)`.** `registerPasskey({ rpId, userName, userId })`
   calls `navigator.credentials.create()` with user verification required, extracts the raw
   P-256 credential public key, and returns `{ credentialId, credentialPubkeyHex }` for your
   app to persist against that human.
2. **Your app issues a challenge** — the same single-use `challenge` endpoint every deed
   mode uses (the [deed guard](guide/verify-tokens.md)).
3. **Login.** `signInWithPasskey({ credentialId, credentialPubkeyHex, tenant, aud, origin,
   challenge, exp })` calls `navigator.credentials.get()`, then assembles a `Deed` from the
   resulting WebAuthn assertion.

```ts
import { registerPasskey, signInWithPasskey } from "@grantor/sdk";

// once, at enrollment — persist credentialId/credentialPubkeyHex against this human
const { credentialId, credentialPubkeyHex } = await registerPasskey({
  rpId: "app.example.com",
  userName: "alice",
  userId: "alice-internal-id",
});

// each login
const deed = await signInWithPasskey({
  credentialId,
  credentialPubkeyHex,
  tenant: tenantId,
  aud: audience,
  origin,          // the origin YOU are actually running on
  challenge,        // from your app's own /challenge
  exp,
});
```

Your app then verifies `deed` exactly like any other — [Verify a deed](guide/verify-tokens.md)
— and mints its own session however it already does.

### The assembler — four languages

One pure, cross-language primitive underlies all of this:
`mint_user_passkey_deed(credential_pubkey, authenticator_data, client_data_json,
assertion_sig, tenant, aud, origin, challenge, exp) -> Deed`, in `grantor-sdk-core`. It does
no I/O and touches no live authenticator — it assembles a `Deed` from already-captured
WebAuthn assertion bytes, which is what lets Python, Go and Rust mint (and their conformance
suites test) a `user-passkey` deed with fixed fixture bytes, with no browser anywhere in the
process:

```rust
use grantor_sdk_core::userpasskey::{mint_user_passkey_deed, passkey_sub};

// credential_pubkey/authenticator_data/assertion_sig are lowercase hex;
// client_data_json is base64url of the EXACT bytes the assertion signed —
// re-serializing the JSON would break the signature.
let deed = mint_user_passkey_deed(
    &credential_pubkey_hex, &authenticator_data_hex,
    &client_data_json_b64url, &assertion_sig_hex,
    tenant_id, audience, origin, &challenge, exp,
)?;

// the same pseudonym the verifier recomputes — useful for a holder that
// wants to know its own sub before minting
let sub = passkey_sub(tenant_id, origin, &credential_pubkey_hex);
```

Python and Go expose the same primitive as `mint_user_passkey_deed`/`MintUserPasskeyDeed`
and `passkey_sub`/`PasskeySub`. **TS ships it too** — `mintUserPasskeyDeed`/`passkeySub` are
re-exported from `@grantor/sdk`'s actual barrel (`sdk/ts/src/index.js`), the same low-level
re-export `mintUserDeed` gets for `user-sig` — so all **four** languages carry the bare
assembler, proven byte-identical against the same fixed WebAuthn fixture in
`sdk/conformance/vectors.json`'s `userpasskey` block: the "`user-passkey` mint (assembler)"
row in `sdk/capability_matrix.py` probes the SAME symbol name
(`mintUserPasskeyDeed`/`mint_user_passkey_deed`/`MintUserPasskeyDeed`) in every language, all
four or it would not have shipped.

`registerPasskey`/`signInWithPasskey` (above) are TS/browser **convenience glue built on
top of** that assembler — they own the `navigator.credentials` ceremony and then call
`mintUserPasskeyDeed` for you — exactly the relationship `mintUserDeed` (raw) has to
`signInWithDeed` (wrapper) for `user-sig`. **Only the live ceremony itself is TS-only, by
platform reality rather than a capability gap** — there is no non-browser WebAuthn
authenticator for any other language to wrap, the same shape `just capability-matrix`
already accepts for `authenticate()`'s Rust exemption. The assembler underneath it is not
TS-only; it is four-language, like everything else in this tier.

### `sub` — one pseudonym per `(tenant, origin, credential)`

```
sub = hex(SHA256(
  "grantor-sovereign-user-passkey-sub-v1|tenant:{tenant}|origin:{origin}|pubkey:{credential_pubkey_hex}"
))
```

Verifier-recomputed from its own `cfg.tenant`/`cfg.origin` and the presented
`credential_pubkey` — never trusted off the wire, so a forged `sub` is structurally
impossible, exactly like `user-sig`. Unlike `user-sig`, `(tenant, origin)` cannot be folded
into *key derivation* (a passkey's private key never leaves the authenticator, so there is
no seed a holder-side app could scope per-context) — folding them into the `sub` hash instead
recovers the same property at the identifier level: one human = one `sub` per
`(tenant, origin)`. `aud` is not in scope either, for the same reason it is not in
`user-sig`'s: `aud` restricts where a deed is valid, `sub` identifies who holds it.

### The binding contract — what is cryptographic vs. what is policy

WebAuthn only lets your app control the **challenge**; the assertion signs
`authenticatorData ‖ SHA256(clientDataJSON)`, and `clientDataJSON` carries only
`type`/`challenge`/`origin`/`crossOrigin`. So the binding splits in two:

- **`origin` and `challenge` are bound CRYPTOGRAPHICALLY** — `origin` is written by the
  browser itself into `clientDataJSON` (a hostile page cannot forge it), and both `origin`
  and `challenge` are covered by the assertion signature. This is what closes the
  cross-origin residual `user-sig` cannot close on its own (see [Why the seed is
  origin-scoped](#why-the-seed-is-origin-scoped)).
- **`tenant`, `aud`, `exp` are bound by VERIFIER POLICY plus the single-use challenge**, not
  by anything inside the assertion. Your verifier issued *this* challenge for *its own*
  `(tenant, aud, origin)` policy and burns it exactly once; it rejects any deed whose
  `tenant`/`aud` do not match its own config. An attacker cannot retarget a captured
  assertion to a different verifier (that verifier issued a different challenge —
  `clientDataJSON.challenge` mismatch) or to a different `aud`/`tenant` at the *same*
  verifier (policy mismatch) — and `sub` is recomputed from `(tenant, origin, pubkey)`, so a
  mutated envelope cannot keep a valid `sub` either.

This is the same trust model the sovereign challenge already provides for every mode;
`user-passkey` leans on it for `tenant`/`aud`/`exp`, where `user-sig` additionally folds
those into its signed `user_binding` string. `signCount` is deliberately **ignored** — the
single-use challenge is the replay defense; a monotonic-counter check would add
authenticator-state assumptions without improving on single-use.

### What the verifier checks

`verify_user_passkey` runs as the third arm of `verify_deed_claims`'s mode dispatch,
after the four checks shared with `user-sig`/`agent-zk` (mode, aud+tenant, challenge, expiry
— see [What the verifier checks](#what-the-verifier-checks) below):

1. **Shape** — a `user-passkey` deed must carry `credential_pubkey`/`authenticator_data`/
   `client_data_json`/`assertion_sig` and none of `root`/`proof`/`pubkey`/`signature`
   (rejected symmetrically the other way too: a `user-sig`/`agent-zk` deed carrying any
   passkey-only field is `Malformed`).
2. **`clientDataJSON`** — `type == "webauthn.get"`; `challenge` matches the challenge you
   issued; `origin` matches your own configured origin, checked by the browser rather than a
   human — this is what closes the cross-origin phishing residual `user-sig` cannot close on
   its own.
3. **`authenticatorData`** — `rpIdHash` (bytes 0–32) equals `SHA256(host(cfg.origin))`; the
   flags byte has both UP (user present) and UV (user verification) set.
4. **Assertion signature** — ES256 (P-256) verified over
   `authenticatorData ‖ SHA256(clientDataJSON)` with `credential_pubkey`.
5. **`sub` recompute** — from `cfg.tenant`, `cfg.origin` and the presented
   `credential_pubkey`; must match exactly.

There is no step 7/8 membership or revocation check here, same as `user-sig`: a
permissionless login has no membership root. Step 8's tenant-status (billing) check still
applies — `user-passkey` costs your app the same **zero gas, zero on-chain footprint** as
`user-sig`.

## Smart-wallet login (`user-1271`)

A human logs in with an **EIP-1271 smart-contract wallet** — a Safe, or any other contract
that implements `isValidSignature(bytes32,bytes)` — instead of an EOA. Additive alongside
`user-sig`/`user-passkey`, not a replacement: pick `user-1271` when your users' wallets are
multisig/smart-contract accounts; `user-sig`/`user-passkey` remain the maximally-private
options for an EOA or a passkey holder.

**Why this is NOT "EIP-1271 on the `user-sig` verify path."** An earlier note (now corrected
— see the launch runbook) described this as riding `user-sig`. It cannot: `user-sig`'s
wallet signature never reaches the verifier at all. The wallet only signs
[`user_root_binding`](#user-login-user-sig) once, HOLDER-SIDE, to derive a `root_seed`; from
there a brand-new local ECDSA keypair is derived via HKDF, and THAT local key signs the
deed the verifier actually checks — pure local ECDSA math with no chain read. A smart
wallet has no recoverable private key to derive a local keypair's seed from in the first
place, and there is no reproducible raw signature to hash even if it did (a Safe's approval
is aggregated on-chain, not a single deterministic `personal_sign` output a holder could
double-sign-and-compare). So `user-1271` is not a variant of `user-sig`'s flow — it is a
different verification path entirely, built on the SAME `Erc1271Reader` on-chain gate
`admin-sig`'s smart-wallet variant uses (below).

### The pseudonymity tradeoff — read before picking this mode

**`user-1271`'s `sub` is linkable to the wallet's on-chain address by anyone who knows that
address.** This is a real, deliberate difference from `user-sig`/`user-passkey`:

- `user-sig`'s `sub` is derived from a locally-held app-scoped key that never appears
  on-chain anywhere; `user-passkey`'s `sub` is derived from a credential public key that
  lives only in an authenticator. Neither is discoverable by anyone who merely knows the
  human's wallet address — there is no on-chain artifact to correlate against.
- `user-1271`'s `sub` is `hex(SHA256("...tenant:{t}|origin:{o}|wallet:{addr}"))` — a
  deterministic hash of the wallet's PUBLIC, on-chain address. Anyone who already knows that
  address (which is inherently public — every EIP-1271 wallet is a deployed contract) can
  recompute the same hash for a guessed `(tenant, origin)` pair and confirm whether "this
  smart wallet" is "that `sub`". The pseudonym hides nothing from an observer who already
  has the one input smart-contract wallets cannot avoid publishing: their own address.

**Pick `user-1271` for its OTHER property, not privacy: social recovery.** A smart-contract
wallet's whole point is that no single stolen owner key is game-over — a Safe with a 2-of-3
threshold survives one compromised signer. `user-sig`/`user-passkey` give you the strongest
pseudonymity this tier has; `user-1271` trades that away for wallet-level key-loss/theft
resilience. Use whichever tradeoff your users actually need — they are not ranked, they
answer different questions.

### The assembler — four languages

Pure, cross-language, exactly like `user-passkey`'s: `mint_user_1271_deed(signer_addr,
wallet_sig, tenant, aud, origin, challenge, exp) -> Deed` in `grantor-sdk-core::user1271`
does no I/O and calls no chain — it assembles a `Deed` from a wallet CONTRACT address and
whatever opaque signature bytes that contract's own scheme produced (a Safe threshold
signature, an ERC-4337 account's own scheme, …). It does not interpret those bytes at all;
only the verifier's on-chain `isValidSignature` call does that.

```rust
use grantor_sdk_core::user1271::{mint_user_1271_deed, user1271_sub};

// signer_addr_hex/wallet_sig_hex are whatever your wallet SDK (e.g. Safe's) already
// produced for you — obtaining them is app glue, not this SDK's job (see below).
let deed = mint_user_1271_deed(
    &signer_addr_hex, &wallet_sig_hex,
    tenant_id, audience, origin, &challenge, exp,
)?;

// the same pseudonym the verifier recomputes
let sub = user1271_sub(tenant_id, origin, &signer_addr_hex);
```

Ships as `mintUser1271Deed`/`mint_user_1271_deed`/`MintUser1271Deed`/`mint_user_1271_deed`
(TS/Python/Go/Rust) — the "`user-1271` mint" row in `sdk/capability_matrix.py`, re-exported
at the TS barrel the same way `mintUserPasskeyDeed` is.

### Obtaining the wallet's signature is app glue, not this SDK's job

This SDK never talks to a Safe (or any other smart-contract wallet) directly — producing
`wallet_sig_hex` is exactly the same kind of glue `user-sig`'s wallet-signing callback is
for an EOA, just one layer further out: a Safe's own SDK (`@safe-global/protocol-kit`) or a
library like `wagmi`/`viem`'s `signMessage`/`readContract` helpers gets you a signature the
wallet contract will accept for a given hash. What this SDK controls, and pins exactly, is
the message a genuine wallet must approve: the SAME `user_binding` string `user-sig` signs
(`grantor_sdk_core::usersig::user_binding(tenant, aud, origin, challenge, exp)`), hashed
with `eip191_hash` — your app-glue code obtains a signature/approval over that exact hash,
then hands the raw bytes to `mint_user_1271_deed`. The verifier rebuilds the identical hash
from ITS OWN policy and the challenge IT issued (never from the token) before asking the
wallet contract, so an app cannot influence what the wallet is actually asked to approve.

### What the verifier checks

`user-1271` cannot be checked by the pure `verify_deed_claims` at all — there is no local
cryptography to run, only an on-chain read — so it is handled as a special case inside the
gated `verify_deed`, BEFORE the pure function ever sees the token:

0. **Fail-closed origin policy** — `enforce_configured_origin(cfg)` runs first, same as
   `verify_deed_claims`'s own step 0 and for the same reason: `user-1271` reads `cfg.origin`
   in its own branch rather than calling `verify_deed_claims`, so without a copy of this
   check here a misconfigured insecure origin would refuse every other mode but silently
   keep authenticating `user-1271` logins. See [Fail-closed origin
   policy](#fail-closed-origin-policy).
1. **Policy match + shape** — `tenant`/`aud`/`challenge`/`exp` checked against your own
   config exactly like every other mode; `signer` + `signature` required, every other
   mode's fields (`root`/`proof`/`pubkey`/`credential_pubkey`/`authenticator_data`/
   `client_data_json`/`assertion_sig`) forbidden — `Malformed` otherwise, before any chain
   read.
2. **Rebuild the binding YOU issued** — `user_binding(cfg.tenant, cfg.audience, origin,
   expected_challenge, token.exp)`, hashed with `eip191_hash` — never read from the token.
3. **Ask the wallet contract** — the ONE chain call this mode needs:
   `gate.is_valid_signature(signer, hash, signature)`. Fail-closed by contract: an RPC
   error, a revert, or any non-magic return maps to refusal, never acceptance.
4. **`sub` recompute** — from `cfg.tenant`, the normalized origin, and the presented
   `signer` address; must match exactly, so a forged `sub` is structurally impossible.
5. **Billing** — `gate.tenant_is_active(cfg.tenant)`, exactly like `user-sig`/`user-passkey`/
   `agent-zk`. `user-1271` is a login mode, not an administrative one, so unlike `admin-sig`
   it stays gated on billing.

Proven against a REAL, live-deployed `ERC1271WalletMock`
(`crates/grantor-verify/tests/eip1271_e2e.rs`), not only a fake `Erc1271Reader` — including a
malicious wallet that always returns a non-magic value, and a real signature from the WRONG
owner.

## Dashboard login (`admin-sig`)

Grantor's own control-plane dashboard used to be the one account-shaped thing left in the
product: a SIWE message exchanged for a session cookie, checked separately against on-chain
`is_admin`. It now authenticates the same way it tells you to — with a deed. There is no
SIWE code left in the controlplane.

`admin-sig` is mechanically SIWE repackaged as a deed: the wallet signs a domain-separated,
HUMAN-READABLE message (leading with the requesting `aud`, stating in plain words that
signing grants administrative access, and warning against signing it anywhere else — the
`personal_sign` equivalent of SIWE's wallet-rendered warning, since a compact string gives a
wallet nothing to show a signer) that carries labelled `tenant`/`challenge`/`exp` fields and
ends with a `grantor-sovereign-admin-v1` discriminator — distinct from `user-sig`'s
`grantor-sovereign-user-v1` prefix, so a signature solicited for one mode can never be
replayed as the other. The verifier recovers the signer's address from the signature, and
`sub` is that address, recomputed and compared rather than trusted from the wire — forging it
is structurally impossible, exactly as `user-sig` recomputes `sub` from a public key. Unlike
`user-sig`, `admin-sig` does **not** hide who signed: the whole point is to identify the
address that will next be checked against on-chain `is_admin`. Administration is an
identified context, not an anonymous one — don't describe this mode as anonymous.

**It is not reachable through `verify_deed`.** `agent-zk` and `user-sig` share that call;
`admin-sig` has its own entry point, `grantor_verify::sovereign::verify_admin_deed`, which
takes no chain-gate parameter at all — there is no handle to call even by mistake.
`verify_deed` (and `verify_deed_claims`) reject `mode: "admin-sig"` outright. This is not
merely a policy an integrator could opt out of: a normal relying party calling the normal
entry point structurally cannot accept an admin deed, because the code path that would
accept one doesn't exist there. Do not build a shared "verify any deed" wrapper over both
entry points — that would recreate exactly the shared-surface risk this split exists to
avoid.

**Why it makes no chain call, and must not gain one.** `agent-zk` and `user-sig` both check
tenant billing status before accepting the deed. `admin-sig` deliberately checks nothing
on-chain — not root recency (there is no membership tree to be recent against), and not
tenant status either, even though that would be easy to bolt on. The reason is a lockout: if
`verify_admin_deed` refused a lapsed tenant's admin, that admin would be locked out of the
one page that lets them pay the bill and reactivate the tenant. Access to pay a bill must
never require having paid. If you find yourself adding a billing check here later, stop —
you are about to reintroduce that lockout.

A successful `admin-sig` verify proves control of a wallet address, nothing more — it
**authenticates**, it does not **authorize**. The controlplane still runs its own on-chain
`is_admin(tenant, address)` check before granting anything; the deed only replaced the
authentication step SIWE used to perform, and touches no authorization logic.

### Smart-wallet admin (Safe / EIP-1271)

**This is a library capability an RP can adopt for its own admin surface — it is NOT (yet)
what Grantor's own dashboard/controlplane does.** `crates/grantor-controlplane/src/http/deed.rs`
still calls only the EOA `verify_admin_deed`, which rejects a `signer`-bearing (smart-wallet)
deed as `Malformed`; wiring Grantor's own dashboard to also accept a Safe admin is a
follow-up, not shipped. Everything below describes what the mint/verify pair does for
whoever adopts it, not a claim about Grantor's own deployment.

A tenant's administrator does not have to be an EOA. `mint_admin_deed_smartwallet`
(`grantor_sdk_core::adminsig`) is the counterpart to `mint_admin_deed` above for an admin
whose wallet is a Safe or any other EIP-1271 contract: it takes the wallet CONTRACT's
address and whatever opaque approval bytes that contract's own scheme produced, and mints
the SAME `admin-sig` wire mode — `sub` is `admin_sub(signer)`, the identical `0x`-prefixed
address format `mint_admin_deed` produces for a recovered EOA, so an adopting RP's own
`is_admin(tenant, sub)` authorization check needs no separate code path for a smart-wallet
admin.

Verified by its own entry point, `verify_admin_deed_smartwallet` (exported as
`verifyAdminSmartwalletAt`/`verify_admin_smartwallet_at`/`VerifyAdminSmartwalletAt` on
`DeedVerifier` in TS/Python/Go, `crate::sovereign::verify_admin_deed_smartwallet` in Rust)
— mirroring the EOA path's isolation exactly: **not reachable through `verify_deed`
either**, and it makes the SAME deliberate choice to skip a tenant-status check, for the
same lockout reason (access to pay a lapsed tenant's bill must never itself require having
paid). The one structural difference from the EOA entry point: this one DOES take a chain
reader parameter (`&impl Erc1271Reader`) — there is no recoverable address to check locally,
so asking the wallet contract's own `isValidSignature` is the entire check. Like
`verify_admin_deed`, this **authenticates only**; the calling RP still runs its own
`is_admin(tenant, sub)` check before granting anything — for Grantor's own dashboard, that
would be the controlplane, but see the note above: it does not call this entry point yet.

## What the verifier checks

This section covers the pure `verify_deed_claims` — `agent-zk`, `user-sig` and
`user-passkey`. **`user-1271` is deliberately NOT covered here** — it has no local
cryptography to check at all, so `verify_deed` (the gated wrapper around
`verify_deed_claims`) special-cases it and runs its own, entirely on-chain checklist
BEFORE ever calling this pure function; see [Smart-wallet login § What the verifier
checks](#smart-wallet-login-user-1271) above. `admin-sig` (both the EOA and the
EIP-1271-smart-wallet variant) has its own, shorter checklists in [Dashboard
login](#dashboard-login-admin-sig) above; neither is reachable through `verify_deed` at
all.

`verify_deed(&deed, &policy, &expected_challenge, now, &gate)`
runs, in order. Step 0 is a fact about the verifier's OWN configuration, checked
before the presented deed is inspected at all; steps 1–4 are shared across modes;
step 5 dispatches on mode.

0. **Fail-closed origin policy** — `cfg.origin` must be HTTPS and routable, or
   `cfg.allow_insecure_origin` must be set. Checked FIRST, before the mode check,
   before the deed's own origin comparison (steps 2 and 6 below), and before
   either chain read (steps 7–8) — a misconfigured deployment refuses every
   token the same way, at zero RPC cost, rather than failing unpredictably
   partway through. See [Fail-closed origin policy](#fail-closed-origin-policy).
1. **Token version + mode** — `token.v`/`token.mode` must be one this
   verifier supports (`MODE_AGENT_ZK`, `MODE_USER_SIG` or `MODE_USER_PASSKEY`).
2. **Audience + tenant match** — bound against your `DeedPolicy`, never
   read back from the token.
3. **Challenge match** — the exact challenge you issued (caller-supplied
   `expected_challenge`, never trusted from the token alone).
4. **Expiry / TTL ceiling** — `exp` is in the future and within
   `max_ttl_secs` of now.
5. **Per-mode shape + cryptography.** Each mode requires exactly its own
   fields: `agent-zk` must carry `root`+`proof` and no `pubkey`/`signature`;
   `user-sig` must carry `pubkey`+`signature` and no `root`/`proof`;
   `user-passkey` must carry `credential_pubkey`+`authenticator_data`+
   `client_data_json`+`assertion_sig` and none of the other three modes' fields. Field
   exclusivity is symmetric across all modes — a wrong-shape token is `Malformed`, never
   quietly accepted.
   - For **`user-sig`**: `sub` is **recomputed** from the presented public
     key and must match exactly — so a forged `sub` is structurally
     impossible, not merely rejected — and the ECDSA signature is checked
     over a binding rebuilt from *your* config (including `origin` — see
     [Origin binding](#origin-binding)) and the challenge *you* issued. A
     tampered `aud`/`origin`/`exp`/`tenant` therefore yields a binding the
     signature was never made over. There is no step 7 for this mode: a
     permissionless login has no membership root to check.
   - For **`user-passkey`**: full checklist in [Passkey login § What the verifier
     checks](#passkey-login-user-passkey) above — clientDataJSON type/challenge/origin,
     authenticatorData rpIdHash + UP/UV flags, ES256 assertion signature, then `sub`
     recomputed from `cfg.tenant`/`cfg.origin`/the presented `credential_pubkey`. Also no
     step 7: permissionless, no membership root.
   - For **`agent-zk`**: parse root / sub / proof (malformed fields fail
     before any cryptography), then:
6. **ZK proof validity** (`agent-zk`) — the Semaphore proof cryptographically verifies
   against the token's own claimed `root`/`sub` and the binding hashes
   recomputed from your `DeedPolicy` + `expected_challenge`, at the
   pinned tree depth (`depth_20`). This is purely local math against
   values already in hand — it does NOT touch the tenant's on-chain
   registry; that's steps 7-8, next. Last of the pure claim checks.
7. **On-chain root recency = revocation** — `gate.root_is_recent(tenant,
   root)`. A revoked agent's old root ages out of the registry's bounded
   recent-root window, so a stale proof stops verifying with no issuer
   needed to blocklist anything.
8. **On-chain tenant status = billing** — `gate.tenant_is_active(tenant)`.
   Accepts `Active` OR `Grace` — the exact gate the issuer itself enforces
   before minting (`GrantorRegistry.isActive`), so a tenant coasting through
   its billing grace window keeps its agents authenticating here
   uninterrupted. Only once a tenant falls
   all the way to `Inactive` (no funded/drawn period, or grace expired) do
   its agents' otherwise-valid proofs fail here. This is the tier's entire
   revenue enforcement — no server-side metering required.

Ordering is load-bearing: a misconfigured `cfg.origin` fails at step 0 before
the deed is even looked at; a token that's the wrong version, wrong
audience/tenant, wrong/replayed challenge, expired, unparseable, or
carrying a bad ZK proof fails at step 1-6 and costs **zero** RPC calls,
because both chain reads (7-8) come last. Both chain reads are live RPC
through `AlloyGate`; `CachedGate` only bounds how often you re-hit your
RPC provider, not what gets enforced.

## Minting your session — `session_jwt` (optional)

Once `verify_deed` returns claims, minting your own session however you
already do stays the first-class path — nothing here changes that, and your
OIDC stack carries on unchanged either way. If you would rather not
hand-roll a JWT, the same SDK can mint one: a standard ES256 token, signed
with **your own key** (never one Grantor holds or sees), decodable by any
JOSE library on the checking side. RFC 6979 deterministic ECDSA means the
same inputs mint byte-identical tokens across all four languages, pinned by
a conformance vector plus a tamper test.

It is an RP-side convenience layered on top of verification, not a session
dependency — sugar, not infrastructure. Ships as `sessionJwt` (TypeScript),
`session_jwt` (Python), `SessionJwt` (Go) and `session_jwt` (Rust,
`grantor_verify::session::session_jwt`): the "session-JWT convenience
minting (RP-side)" row in `sdk/capability_matrix.py`, all four languages or
it would not have shipped. See [Verify a deed § Or one
call](guide/verify-tokens.md#or-one-call) for the excerpt, transcribed from
the runnable MCP reference.

## Operating it: rate-limit the challenge endpoint

The one piece of operational hygiene this tier asks of you, because it is the
one thing the library cannot do for itself.

The challenge endpoint is public and unauthenticated by design — an agent must
be able to obtain a challenge *before* it holds any credential. So the RP owns
the flood defence: a per-IP or per-subnet limit in whatever middleware you
already run. `MemoryChallengeStore` sweeps expired entries on every issue, so
it is bounded by challenges issued within one TTL window — but that bound is
your request rate times the TTL, and both are the caller's to move. Shortening
the TTL tightens it proportionally and costs nothing: it only has to outlast
discover → challenge → mint, which is seconds.

**Why the library doesn't just cap the map**, when it does exactly that for the
two `(tenant, root)` caches: eviction there is safe by construction — a cache
is an optimisation over an `eth_call`, so dropping an entry costs a chain read
and never a wrong answer. A challenge is not a cache. It is one half of a live
login, and evicting it fails a legitimate in-flight authentication with
`ChallengeMismatch` — indistinguishable to the holder from an attack, and
triggered by the *attacker's* traffic rather than their own. A cap would trade
a memory-pressure problem for an availability one and hand the attacker a
cheaper lever than the one they started with.

Nothing here is a billing or authentication bypass: a challenge is single-use
and TTL-bound whatever the volume, and every deed still faces the same checks.

## Paying, and unpaying

Billing is prepay-then-draw, and the money is custodial to nobody:

- `topUp(id, amount)` moves USDC from you into the **registry contract** and credits your
  tenant's balance. It does not pay the operator.
- `drawPeriod(id)` debits one period's fee from that balance and transfers it to an
  **immutable** `treasury` address, fixed at deployment and unchangeable afterwards.
- `withdrawBalance(id, amount, to)` returns any **undrawn** balance to an address a tenant
  admin picks. Only periods you actually consumed are non-refundable.

The registry has no owner function that can move tenant balances — no withdraw, sweep or
rescue — and `invariant_usdc_equals_sum_of_balances` asserts the contract's USDC holdings
always equal the sum of tenant balances.

## Why "deed"?

A deed is an **instrument**: you prove entitlement by presenting it, and its force comes
from a public register of record rather than from asking an authority in real time. The
office that keeps such a register is called the Recorder of Deeds. That is this design line
for line — a public on-chain registry, and a credential that proves entitlement against it
with no server in the path. Law also has a precise term for a document carrying its own
proof of execution, so no witness need appear to authenticate it: a **self-proving
instrument**. That is exactly what this is.

### But nobody grants it to you — the holder mints it

True, and worth answering directly. The tenant's on-chain registration **is** the
conveyance; the deed is the instrument proving that record. The holder mints the
instrument, and it is the registry that makes it mean anything. A deed with no
corresponding record is refused, which is what the `NotAMember` and `TenantInactive`
outcomes are.

`deed` is deliberately a generic, lowercase, untrademarked noun — the same choice FIDO made
with *passkey*. **Grantor** is the brand; a deed is the thing.

## Running the suite

```bash
just test-sovereign   # cargo test -p grantor-verify --features sovereign-chain
```

This spins up a local `anvil` node, deploys the registry, and runs the
full happy-path + negative-path (revoked agent, unpaid tenant, replayed
challenge) e2e — no Postgres, no issuer, nothing but the chain.
