# Capabilities (delegation)

A **capability deed** carries a *bounded grant* on top of the identity every
deed already proves — not just "who is calling" but "what this specific
caller is allowed to do, and until when." One principal delegates a narrowed
slice of its own authority to another identity by signing a `Delegation`; the
receiving identity attaches that link to its own deed and presents it. Your
app verifies the whole chain in one call and gets back the caller's identity
plus the grants that actually apply — then enforces each request against
them with `CapabilityGuard`. If you haven't already, read
[Concepts](concepts.md) for the deed mental model this builds on.

The runnable reference this guide describes lives at `examples/mcp-server/`
(`capability-principal.mjs`, `capability-principal-zk.mjs`,
`capability-worker.mjs`, `capability-chain.mjs` (★ preview — see
["Structure-hiding delegation"](#structure-hiding-delegation-preview) below),
and the capability-gated routes in `server.mjs`) — every excerpt below is
transcribed from those files.

## The grant grammar

```json
{
  "res": "mcp://acme/tools/*",
  "act": ["call"],
  "cav": { "exp": 1786015180, "max_uses": 100, "nb": { "tool": "search|fetch" } }
}
```

A `Grant` names a **resource pattern** (`res`), the **actions** allowed on it
(`act`), and **caveats** (`cav`) bounding it:

- `res` is an exact string, or a prefix ending in one trailing `*`
  (`mcp://acme/tools/*` matches `mcp://acme/tools/search`, not
  `mcp://acme/other`).
- `act` is the set of actions this grant covers on that resource
  (`"call"`, `"write"`, whatever your app's own vocabulary is).
- `cav.exp` bounds when the grant stops being usable; `cav.max_uses` is
  carried on every grant your guard inspects but is **not enforced by the
  guard itself** — counting real uses needs durable state (a DB row, a Redis
  counter) that only your app can own; `cav.nb` (**n**arrow-**b**y) is a set
  of named predicates — `{"tool": "search|fetch"}` means the request's
  `tool` argument must be exactly `search` or `fetch`.

A delegated grant can only ever get **narrower**, never wider: a child link
in a chain must name a resource pattern, an action set, and caveats that are
each a subset of its parent's. A grant with no caveat at all is the widest
possible caveat, so it may only appear at the root of a chain.

## Delegating — `delegate`

A principal signs a `Delegation` handing another identity a narrowed grant,
valid until `exp`, tagged with the revocation cohort it was signed against
(`epoch_label`/`epoch` — see [Revocation](#revocation) below):

```js
import { delegationBinding, parentRefHashRoot, delegate } from "@grantor/sdk";

const grant = {
  res: "mcp://acme/tools/*", act: ["call"],
  cav: { exp, max_uses: 100, nb: { tool: "search|fetch" } },
};
const grantsJson = JSON.stringify([grant]);
const parentRefHash = parentRefHashRoot(iss); // the first link in a chain
const bindingHex = delegationBinding(tenantId, iss, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash);
const signatureHex = await sign(bindingHex); // your own secp160k1/EIP-191 signer

const link = delegate(
  tenantId, iss, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash,
  undefined, // pubkey — omitted for a recoverable EIP-191/secp256k1 signer
  signatureHex,
);
```

**A delegation chain's root must currently be a registered agent key** —
`GrantorRegistry.registerAgentKey(tenantId, commitment)`, where `commitment`
is `keccak256("0x{lowercase 20-byte address}")`, the delegator's own address
string. This is the SAME on-chain mapping `admin-sig` deeds check, under a
DIFFERENT preimage than `agent-zk`'s CAIP-10 commitment — a key registered
for one does not automatically root a chain for the other. There is no
permissionless-wallet-root path yet; a chain rooted at an unregistered or
revoked key fails verification (`BadDelegation`).

A chain can have more than one link — an agent that received a grant can
delegate a narrower slice of it onward, the same way — but every link after
the root must narrow the immediately preceding link's grants, and the last
link's `to` must equal the presenting identity's own proven `sub`.

## Anonymous delegation — `delegateZk`

```js
import { ZkAgent } from "@grantor/agent";
import { parentRefHashRoot } from "@grantor/sdk";

const agent = ZkAgent.fromAgentKeySignature(agentKeySignatureBytes);
const grantsJson = JSON.stringify([grant]);

// A delegation's `iss` is a Semaphore nullifier fixed by (identity, tenant)
// alone — computed before the proof, but only ever returned FROM one. Mint
// once (any parent hash — even a placeholder `zeroHash` — works to learn
// `iss`; that proof is discarded), then mint again with the correct parent
// hash now that `iss` is known.
const probe = JSON.parse(await agent.delegateZk(
  rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, zeroHash,
));
const parentRefHash = parentRefHashRoot(probe.iss);
const link = JSON.parse(await agent.delegateZk(
  rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash,
));
```

A member of tenant T can grant another identity a bounded slice of authority
**without revealing which member of the tenant it is.** Instead of a
recoverable EIP-191 signature, `delegateZk` authenticates the link with
a real Semaphore membership proof over the SAME on-chain ZK tree
`ZkAgent.mintDeed` proves against — the proof is the authentication, so a
zk-kind `Delegation` carries no `sig`/`pubkey` at all. Its `iss` is a
delegation nullifier, not an address or a commitment: a per-tenant
pseudonym, deterministic for a given identity but unlinkable to the
commitment the tenant admin registered.

Your app verifies a zk-rooted chain through the exact SAME entry point as a
signed one — `verifyWithCapabilityAt`/`verify_deed_with_capability` — no
separate code path, no separate route. The claims it gets back name the
*delegate's* proven identity as usual; the only thing it ever learns about
the anonymous delegator is that nullifier, printed here as the delegator's
pseudonym, never a member identity:

```
delegator pseudonym (nullifier) iss=4629071830519134272348858930177220791871450050742923343585703717248052541528
```

Only the chain's **root** is checked against the registry — the same
"root-only" gating a signed chain uses. An intermediate zk link's `root` is
authenticated by its own nullifier and proof, not separately checked
on-chain; recency is enforced once, where the chain's authority actually
originates.

**Three independent bounds**, all live against the real registry:

- **`exp` bounds issuance**, exactly as it does for a signed link — a link
  past its own `exp` stops verifying, no on-chain action needed.
- **`bumpEpoch` is the instant, bulk kill** — identical mechanism to the
  signed leg's [Revocation](#revocation) section: one transaction on the
  link's `epoch_label` invalidates it, and every other link in that cohort,
  immediately.
- **Revoking the member (`GrantorRegistry.revokeZkAgent`) invalidates every
  already-signed delegation rooted at it, immediately** — it overwrites the
  on-chain root history for the WHOLE tenant tree, not just that one member,
  so the next root-recency check on an already-minted deed fails closed with
  a `BadDelegation` worded distinctly as a stale root (`"not a
  currently-recent registry member"`), independent of whatever epoch state
  that link was signed against.

## Unlinkable across chains — `delegateZk(..., salt)`

```js
const grantsJson = JSON.stringify([grant]);

// Same two-call pattern as above, with one addition: a per-chain `salt` as
// the trailing argument to `delegateZk`.
const probe = JSON.parse(await agent.delegateZk(
  rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, zeroHash, salt,
));
const parentRefHash = parentRefHashRoot(probe.iss);
const link = JSON.parse(await agent.delegateZk(
  rpcUrl, registry, tenantId, to, grantsJson, exp, epochLabelHex, epoch, parentRefHash, salt,
));
```

`salt` is an optional trailing string on `delegateZk`, in every language. It
scopes the delegation nullifier further, so the SAME anonymous member
delegating twice with two different salts produces two pseudonyms that share
nothing — no field on either deed, or anywhere on the wire, ties them back to
one delegator:

```
chain A (salt="chain-a"): delegator pseudonym iss=826493158991174290343971509743582095105037072152399465317093330796638885617
chain B (salt="chain-b"): delegator pseudonym iss=14936235482157490080433353871006983321048803913765619935150511037695437371846
```

Omitting `salt` (or passing `undefined`/`null`, depending on the language)
reproduces the one stable, recognizable pseudonym this member already uses
for the tenant — the same `iss` `delegateZk` returns with no salt today.
Which one you want is a per-chain choice, not a global setting: mint a fresh
salt for a chain you want unlinkable from the member's other chains, or leave
`salt` off for a chain where staying recognizable — the same delegator every
time — is the point.

Verification does not change: `verifyWithCapabilityAt` recomputes the
nullifier scope from whatever `salt` the deed itself carries, so a salted and
an unsalted chain from the same member both verify through the exact same
call. A `salt` tampered after minting fails the same way a tampered grant
does — the proof was built over the original value, so a verifier
recomputing against a different one lands on the wrong nullifier scope and
rejects with `BadDelegation`.

## Presenting — `mintCapabilityDeed`

The identity receiving a delegation attaches it to its own already-minted
deed (a `user-sig` login, an `agent-zk` proof, whatever mode it authenticates
with) and presents the result instead of the bare identity deed:

```js
import { mintCapabilityDeed } from "@grantor/sdk";

const baseDeed = await authenticate(origin, signMessage, { chainReader, registry, rpcUrl });
const capabilityDeed = mintCapabilityDeed(baseDeed, grantsJson, JSON.stringify([link]));
```

`grantsJson` here is the presenter's own **asserted** grants — normally the
same grant it received, sometimes a narrower slice of it. `mintCapabilityDeed`
does not itself check that the assertion is a legal narrowing; that check is
what verification does next, so a forged or widened assertion is caught at
the one place that matters, not trusted at mint time.

## Verifying — `verifyWithCapabilityAt`

Your app verifies a capability deed with one call, alongside the ordinary
identity check — it walks the chain root to leaf, recovers each link's
signer cryptographically (never trusts the claimed `iss`), checks every
narrowing step, and confirms the root is still an enrolled, non-revoked agent
key:

```js
const { claims, grants } = await verifier.verifyWithCapabilityAt(deedJson, challenge, now);
```

`claims` is the same identity `verify_deed` already gives you; `grants` is
the chain's **effective** authority — `[]` for an identity-only deed with no
delegation attached, so this call is a strict superset of the plain identity
check and safe to use everywhere, not just on capability-gated routes.

## Enforcing — `CapabilityGuard`

`grantorExpress` ships a matching pair of middleware: `protectCapability`
verifies the presented deed and attaches `req.deed`/`req.grants`;
`requireCapability` checks one route's own resource/action against those
grants, denying with `CapabilityGuard.authorize`'s rules if nothing covers
the request:

```js
app.post(
  "/mcp/tools/:tool",
  g.protectCapability,
  g.requireCapability("mcp://acme/tools/*", "call", {
    argsFrom: (req) => ({ tool: req.params.tool }),
  }),
  (req, res) => res.json({ tool: req.params.tool, sub: req.deed.sub }),
);
```

A request to `/mcp/tools/search` or `/mcp/tools/fetch` passes — the grant's
`nb: {"tool": "search|fetch"}` predicate matches. A request to
`/mcp/tools/write` is refused: the deed itself is genuine (verification
already succeeded), it just carries no grant that covers `tool=write` —
`CapabilityDenied`, HTTP **403**, deliberately distinct from every other code
in this table, which means "this deed did not check out" and maps to 401/503
instead. See [Errors](errors.md) for the full table.

Calling `CapabilityGuard.authorize` directly (outside Express, or from a
non-HTTP call site) looks the same, against grants you already verified:

```js
import { CapabilityGuard } from "@grantor/verify";

CapabilityGuard.authorize(grants, {
  res: "mcp://acme/tools/search", act: "call", args: { tool: "search" }, now,
});
```

## Revocation

Three independent mechanisms bound how much a delegation can do and for how
long, at three different scopes:

- **Caveats bound the blast radius of one grant.** `res`/`act`/`nb` narrow
  exactly what a delegated grant covers — a grant scoped to
  `{tool: "search|fetch"}` never covers a `write` call no matter how it is
  presented.
- **Each link's own `exp` bounds its window.** A delegation link — and the
  leaf deed it is attached to — simply stops verifying once its own `exp`
  passes, no on-chain action needed.
- **`bumpEpoch` is the instant, bulk kill.** Every `Delegation` is signed
  against a revocation cohort (`epoch_label`, defaulting to the tenant-wide
  cohort) at a specific `epoch`. The tenant admin calls
  `GrantorRegistry.bumpEpoch(tenantId, label)` to increment that counter —
  one transaction invalidates *every* outstanding link signed against the
  old value for that cohort, immediately, with no per-link bookkeeping:

```js
RESULT: {"search":{"status":401,"body":{"error":"EpochRevoked", …}}, …}
```

Your verifier's own chain-read cache (if you run one) bounds how quickly a
`bumpEpoch` is *observed* — a cached `delegation_epoch` read serves the old
value until its TTL expires, the same tradeoff the tenant-active and
root-recency caches already make. The revocation itself is instant on-chain;
your app's cache TTL is the only thing standing between the transaction and
your next verification seeing it.

## Structure-hiding delegation (preview)

> ⚠️ **PREVIEW.** This mode uses a **single-contributor, deterministic
> trusted setup** — not a multi-party ceremony. The toxic waste is not
> assumed discarded, and the circuit is **pending external cryptographic
> review**. Treat every `zk-chain` proof as unauthenticated for anything
> beyond a demo, and **do not use this mode for production authority.**
> Every other mode on this page (`delegate`, `delegateZk`) is unaffected.

Every mode above still reveals *something* about the chain's shape: a
`Delegation` array on the wire, one link per hop, each carrying a real
signature or proof. **Structure-hiding delegation collapses an entire chain —
however many hops, however it narrows — into ONE Groth16 proof and one opaque
effective-grant commitment (`g_eff`).** The deed that reaches your server
carries no `delegations` array and no way to recover a hop count from the
wire at all.

```js
// Holder side (a script that plays every role in the chain — see
// examples/mcp-server/capability-chain.mjs): authorize N hops, fetch the
// root's real on-chain membership witness, and prove.
import { ChainHopAuthorizer, proveDelegationChain } from "@grantor/agent";

const auth = new ChainHopAuthorizer(tenantId, epoch);
const hop0 = auth.authorizeHop(member, agentA.publicHex(), grantJson);
const hop1 = auth.authorizeHop(agentA, agentB.publicHex(), grantJson);
const hop2 = auth.authorizeHop(agentB, worker.publicHex(), grantJson);

const deedJson = proveDelegationChain(
  `[${hop0},${hop1},${hop2}]`, siblingsHex, isLeft, worker,
  rootHex, challenge, tenantId, audience, deedExp, epoch,
);

// RP side: verify the proof for real, then OPEN and enforce g_eff.
const claims = await verifier.verifyDelegationChainAt(deedJson, challenge, now);
const gEff = arithGrantCommit(JSON.stringify(claimedGrantValues));
if (gEff !== claims.sub) throw new Error("does not open the proven commitment");
CapabilityGuard.authorize([openedGrant], { res, act, args, now });
```

`verifyDelegationChainAt` is a **dedicated** entry point — it never touches
`verify_deed`/`verify_deed_with_capability`, and a non-`zk-chain` deed is
refused there either way, the same isolation `admin-sig` gets for a different
reason. It does real Groth16 verification against a **committed** verifying
key, plus the exact same tenant/root/epoch chain gates every other mode uses
— no new contract, no new gate method.

### Opening `g_eff` is your job, not the verifier's

The in-circuit narrowing checks each hop's caveat-predicate commitment
(`nb_commit`) by **equality**, never by opening it — so a Groth16-verified
`zk-chain` deed has proven *what* authority exists (an opaque commitment),
not that *this specific request* is covered by it. `verifyDelegationChainAt`
resolves `sub` to that commitment and stops there on purpose. Your app must:

1. build the `GrantValues` (`{res_id, act_mask, exp, max_uses, nb_commit}`)
   it believes the chain committed to — from its **own** interning tables,
   never from anything an untrusted caller asserts (see the next section);
2. recompute `arithGrantCommit` over those values and require it **equal**
   `claims.sub` — only a value that opens the real commitment is ever
   enforced;
3. run the opened grant through `CapabilityGuard.authorize` exactly like a
   P0/P1 effective grant — same `res`/`act`/`nb` rules, same 403
   `CapabilityDenied` on a miss.

`crates/grantor-verify/src/capguard.rs`'s `CapabilityGuard::authorize_zk_chain`
is the Rust-native version of this same two-step split; `arithGrantCommit`
(`@grantor/verify`) is the wasm export that lets TypeScript perform the same
Poseidon-hash check without reimplementing it.

### The res-interning contract is yours to keep

A circuit sees five field elements, not a resource glob or a JSON caveat map.
**Your app assigns the meaning** — which `res_id` is which resource pattern,
which bit of `act_mask` is which action, which `nb_commit` is which predicate
set — the same way `examples/mcp-server/server.mjs`'s zk-chain route keeps a
small fixed table (`res_id: 7` ⇒ `"mcp://acme/tools/*"`, bit 0 ⇒ `"call"`, one
`nb_commit` id ⇒ `{tool: "search|fetch"}`) and refuses anything outside it.
This module cannot validate that mapping for you — a mis-interning is an
integration bug in your app, not a circuit bug.

### The `WireGrant` precision ceiling

`exp`/`max_uses` cross the wire as plain JSON numbers, not hex — so they must
stay under `2^53 - 1` (`Number.MAX_SAFE_INTEGER`). Both are naturally
small (a unix timestamp, a usage cap), so this is not a practical limit, but
`WireGrant`'s parser refuses a larger value outright rather than proving over
one a JS caller's own runtime already silently corrupted.

### Cost: real proving, every time

Structure hiding costs a real Groth16 proof, not a signature check —
**~18-22 seconds measured in Node (wasm), ~0.8 seconds native.** This is
server-side/agent-side work, not something you'd ask a browser tab to do on
every click; the reference exchanges one proof for a short-lived session
bearer (`POST /auth/token/zk-chain`) rather than re-proving per tool call, the
same "deed is the login, bearer is the session" shape every other mode uses.

## Run the proof

```sh
just verify-ts agent-ts sdk-ts   # build the wasm packages the example imports (once)
just caps-e2e                     # delegate -> present -> enforce -> revoke, all live
```

`just caps-e2e` runs `examples/mcp-server/caps-e2e.sh`, which deploys
`GrantorRegistry`, creates and funds a tenant, and starts the reference
server. A worker learns its own stable `sub`; a principal registers itself as
an on-chain agent key and delegates it "call tools search|fetch, up to 100
uses, expiring in about an hour"; the worker presents the resulting
capability deed and calls all three routes — `search` and `fetch` succeed,
`write` is refused with `CapabilityDenied`. The tenant admin then calls
`bumpEpoch` on the delegation cohort, and the SAME already-signed delegation
is presented again: every call now fails with `EpochRevoked`, proving the
bulk revocation without touching a single link.

The same script then runs the anonymous leg: a member registers into the
tenant's ZK tree, delegates the identical grant via `delegateZk`
instead, and the worker presents the resulting deed to the same three
routes with the same outcome — the transcript prints the delegator's
pseudonym, never its commitment or address. The tenant admin then proves
both zk revocation channels live, on that SAME already-signed deed: bumping
its cohort's epoch (`EpochRevoked`), and separately revoking the member
itself (`BadDelegation`, worded as a stale root) — two independent reasons
the same deed stops verifying.

Finally, the SAME member delegates to two workers on two salted chains and a
third with no salt at all: the two salted deeds print two different
pseudonyms — proven to differ, both verifying and enforcing exactly like
every other leg above — and the no-salt deed reproduces the one stable
pseudonym the anonymous leg already printed earlier in the same run.

The same script then proves structure-hiding delegation (★ preview) live: a
member registers into the SAME on-chain ZK tree, `capability-chain.mjs`
authorizes a 3-hop chain (member → agent A → agent B → worker) entirely in
its own process and produces ONE Groth16 proof, and the server verifies it,
opens `g_eff` against its own interning table, and enforces the identical
`search`/`fetch` allowed, `write` denied outcome through the SAME
`CapabilityGuard` — proving is real, ~18-22 seconds measured, so this leg is
the slowest in the script. Two revocation legs then each prove a FRESH deed
(a `zk-chain` deed's challenge is bound into the proof, so an
already-presented one cannot demonstrate a second failure): `bumpEpoch` on
the chain's own cohort (`EpochRevoked`) and `revokeZkAgent` on the root
member (`StaleRoot`).

## See also

- `examples/mcp-server/README.md` — the full MCP reference, including the
  identity modes capability deeds build on top of.
- [Errors](errors.md) — `BadDelegation`, `EpochRevoked`, `CapabilityDenied`.
- [Verify a deed](verify-tokens.md) — the plain identity check this extends.
- [Concepts](concepts.md) — the deed mental model.
