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

Every excerpt below is transcribed from the runnable reference example. The
`zk-chain` mode is ★ preview — see
["Structure-hiding delegation"](#structure-hiding-delegation-preview) below.

## 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 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 root a chain for the other. 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 it is.** Instead of a recoverable EIP-191
signature, `delegateZk` authenticates the link with a 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`. Its `iss` is a delegation nullifier: a per-tenant pseudonym,
deterministic for a given identity but unlinkable to the registered
commitment.

Your app verifies a zk-rooted chain through the same entry point as a signed
one — `verifyWithCapabilityAt`/`verify_deed_with_capability`. The claims name
the *delegate's* proven identity as usual; the only thing it learns about the
anonymous delegator is that nullifier, printed here as its pseudonym:

```
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. The first
two — `exp` and `bumpEpoch` — work exactly as in [Revocation](#revocation)
below. The third is zk-specific:

- **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 member, so
  the next root-recency check fails closed with a `BadDelegation` worded as a
  stale root (`"not a currently-recent registry member"`), independent of the
  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`) reproduces the one stable
pseudonym this member already uses for the tenant. It is a per-chain choice:
mint a fresh salt for a chain you want unlinkable from the member's others,
or leave `salt` off where staying recognizable is the point.

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

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

`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 the reference example'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
```

This deploys `GrantorRegistry`, funds a tenant, and starts the reference
server, then drives four legs live against a chain:

- **Signed delegation.** A principal registers as an on-chain agent key and
  delegates "call tools search|fetch, up to 100 uses, ~1h"; the worker
  presents the capability deed — `search`/`fetch` succeed, `write` is refused
  with `CapabilityDenied`. `bumpEpoch` on the cohort then makes the same deed
  fail with `EpochRevoked`.
- **Anonymous delegation.** The same grant via `delegateZk`; the transcript
  prints the delegator's pseudonym, never its commitment. Both zk revocation
  channels are proven on one deed: `bumpEpoch` (`EpochRevoked`) and
  `revokeZkAgent` (`BadDelegation`, worded as a stale root).
- **Salted chains.** The same member delegates on two salted chains plus one
  unsalted: the salted deeds print two different pseudonyms; the no-salt deed
  reproduces the member's one stable pseudonym.
- **Structure-hiding (★ preview).** A 3-hop chain (member → agent A → agent B
  → worker) collapses into ONE Groth16 proof; the server verifies it, opens
  `g_eff` against its interning table, and enforces the same
  `search`/`fetch`-allowed, `write`-denied outcome. Proving is real (~18-22 s),
  so this is the slowest leg. Two revocation legs each prove a FRESH deed (a
  `zk-chain` deed's challenge is bound into its proof): `bumpEpoch`
  (`EpochRevoked`) and `revokeZkAgent` (`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.
