# 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 **three modes**. `agent-zk` and `user-sig` are the two your app
verifies, and one verifier call (`verify_deed`) handles both. `admin-sig` is a third 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).

| 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-sig` | a human with a wallet | control of a wallet-derived, app-scoped key | tenant status (**billing**) only |
| `admin-sig` | Grantor's own dashboard admin | control of a wallet address | **none, deliberately** |

**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 | ✅ | ✅ | ✅ | ✅ |
| `agent-zk` holder (ZK proving) | ✅ | ✅ | ✅ | ✅ |
| `admin-sig` holder | ✅ | ✅ | ✅ | ✅ |
| Deed verification | ✅ | ✅ | ✅ | ✅ |
| Deed guard (challenge store, single-use burn) | ✅ | ✅ | ✅ | ✅ |

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.

## 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`
   — SP7 tree-sync; 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` feature (non-default — it pulls in
`semaphore-rs` + chain RPC, so light builds that only verify standard OIDC
JWTs stay lean):

```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"] }
```

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)`
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.

**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 `chainRegistry` (the on-chain registry address YOU trust, pinned
out-of-band) — 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";

const deed = await authenticate("https://api.example.com", signMessage, {
  chainReader,
  chainRegistry: REGISTRY,
});
```

```python
from grantor_agent.authenticate import authenticate

deed = authenticate(
    "https://api.example.com", sign_message,
    chain_reader=chain_reader,
    chain_registry=REGISTRY,
)
```

```go
deed, err := authenticate.Authenticate("https://api.example.com", sign, &authenticate.Options{
	ChainReader:   chainReader,
	ChainRegistry: REGISTRY,
})
```

`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). `chainRegistry`/`chain_registry`/`ChainRegistry` — the registry
address `chainReader` is queried against — is REQUIRED alongside it: the
document's own `chain.registry` is compared against this 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`). Get `REGISTRY` 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.

**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;
    const deed = await agent.mintDeed(
      rpcUrl, registryAddress, d.tenant, d.audience, origin, challenge, exp,
      v.signature, v.epoch, v.exp, 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.

It shipped **without** that check, in every language at once, which was finding
**S2**: 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 was no `authenticate()`-style wrapper
for `agent-zk` to hide the gap in — `mintDeed` is the only `agent-zk` mint API
there has ever been.

**⚠️ 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.

## 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`
   left that scope with the S1 fix; 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 was finding **S1**, and it is fixed
   separately, by origin-scoping the SEED — see "User login" below.

3. **`agent-zk` cross-origin linkability: CLOSED by prevention (S2 fixed
   2026-07-29).**
   `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 SP7 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 now 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. It shipped without one — that
   was finding **S2** — and the gap was in every language at once, because
   `mintDeed` is the only `agent-zk` mint API and no `authenticate()`-style
   wrapper exists for it. 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 (S2 fixed
   2026-07-29).** Left un-narrowed, 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 now covers both the
`user-sig` holder path** (`authenticate()` and `signInWithDeed()`) **and
the `agent-zk` mint path** (`ZkAgent.mintDeed`), in all four languages. See
[Origin provenance](#origin-provenance) below for what this closes today,
what it still does not, and its cost.

> ⚠️ **CORRECTED 2026-07-30 — this paragraph used to say the `agent-zk` mint
> path had no provenance check and both items above remained open; that was
> true when written and is now stale.** S2 closed `ZkAgent.mintDeed`'s
> provenance gap on 2026-07-29, 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). Since the 2026-07-30
> architecture audit, 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` now marshals into rather than
> duplicating the check per shim. Nothing about what is or is not enforced
> changed: the check is still holder-side, not RP-enforceable — see "Only
> as good as a holder that runs the check" below, which stands unmodified.

**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`) — closed as finding **S2**, 2026-07-29; 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). Since the 2026-07-30 architecture audit, 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
— the earlier per-shim duplication is exactly the shape of gap that let S2
happen in the first place.

> ⚠️ **CORRECTED 2026-07-30** — the two paragraphs above used to say
> `ZkAgent.mintDeed` had "no provenance check at all" and that both
> `agent-zk` items "remain open in every language today." That was true
> when written and is now stale; it is fixed as of the S2 close referenced
> above. This correction does not change the caveats below — 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) stands exactly as written.

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

> ⚠️ **CORRECTED 2026-07-29 — Part A is NOT an independent backstop, and the
> claim that used to sit here was false.** It described Part A's origin-scoped
> key derivation as a backstop for exactly this case, "a property the holder
> gets *automatically*, from deriving correctly, with no separate check to
> skip". That does not hold against a hostile origin, which is the only threat
> it was invoked against: Part A scopes the per-app KEY, but the origin is an
> argument the deriving party supplies, and in a browser the attacker's page is
> the deriving party. "The holder derives correctly" protects only a holder that
> is doing the deriving.
>
> This was finding **S1** (full impersonation), reproduced end to end and now
> **fixed** — the root seed is origin-scoped and the signed message is prose
> naming the origin. See "What S1 cost, and what it did not buy" under
> [User login](#user-login-user-sig) for what the fix does and does not buy, and
> `crates/grantor-verify/tests/usersig_root_seed_scope.rs` for the regression
> pin.
>
> **The correction to this section stands regardless of that fix:** there is no
> automatic, unskippable holder-side backstop behind the origin provenance
> check. A holder that skips the check 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.

> ⚠️ **Both halves were Rust-only until 2026-07-29** — finding **S3**. The
> ceiling and the warning lived in `verify_own_origin_vouch`, which had no FFI
> export, so TS/Python/Go reached the vouch only through the document builder and
> that validated **shape** alone. An operator in three of four languages booted
> clean while serving a vouch every holder rejects, and got 100% login failure
> with no diagnostic — precisely what a self-check exists to prevent. The
> evidence it was not theoretical: the TypeScript adapter's own test fixture
> shipped a `vouchExp` about **317 years** in the future, and one Rust e2e
> fixture passed `u64::MAX`. Neither is a value any deployment could produce, and
> a fixture like that stops testing the real path.

**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, which is what this used to advertise. That was finding S1: a
// single fixed message meant any site holding one `personal_sign` derived
// the holder's key at every other relying party. See "What S1 cost" 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.

### What S1 cost, and what it did not buy

S1 was a full-impersonation finding: `derive_root_seed` signed one fixed
opaque constant identical at every origin, so any site that obtained that
single `personal_sign` derived the holder's key at every relying party. The
fix scopes the seed per origin and makes the signed message human-readable
prose naming that origin, mirroring `admin-sig`'s binding.

**Do not describe this 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 the fix does buy, unconditionally: a seed captured at one origin is
useless at any other, so compromise is contained to the origin the user
actually signed at.

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

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

## What the verifier checks

This section covers `verify_deed` — `agent-zk` and `user-sig` only. `admin-sig` has its own,
shorter checklist in [Dashboard login](#dashboard-login-admin-sig) above.

`verify_deed(&deed, &policy, &expected_challenge, now, &gate)`
runs, in order. Steps 1–4 are shared; step 5 dispatches on mode.

1. **Token version + mode** — `token.v`/`token.mode` must be one this
   verifier supports (`MODE_AGENT_ZK` or `MODE_USER_SIG`).
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`. 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 **`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
   tree depth SP7 pins (`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 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.

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

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.
