# 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 secp256k1/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,
});
```

## Pinning a tool descriptor — `grantor:tool-desc-v1`

A prefix grant like `mcp://acme/tools/*` covers every tool the server
happens to expose at request time — including one that did not exist when
the grant was signed, and one whose `inputSchema` has since grown a
parameter that widens what it can do. `res` alone binds a grant to a name,
not to a tool's identity.

`@grantor/mcp` closes this with a **reserved** `nb` key rather than a new
field on the grant grammar — the mechanism above already provides
everything it needs, so nothing about the signed format changes:

```
grantor:tool-desc-v1
```

The value is a canonical hash of the descriptor:

```
lowercase_hex( SHA-256( JCS({"inputSchema": <schema, or null>, "name": "<name>"}) ) )
```

- **RFC 8785 (JCS)** canonicalization, not ad hoc "sorted keys, no
  whitespace" — that leaves numbers, escapes and Unicode under-specified,
  exactly where two languages would silently disagree.
- **`description` is excluded on purpose.** Rewording documentation must not
  invalidate an outstanding grant; only capability-relevant structure binds.
- **An absent `inputSchema` hashes as JSON `null`** — an explicit value, not
  an omitted member, so "no schema" and "empty schema" stay distinguishable.
- The `v1` suffix versions the *algorithm*: change the canonicalization and
  the key changes with it, so a grant pinned under the old rule fails closed
  rather than silently comparing against a differently-computed hash.
- **The hash is over exact UTF-8 bytes — it does not Unicode-normalize.** If
  the descriptor you hashed at grant time and the one the server later
  serves differ only by NFC/NFD form (the same characters, different
  composition), the hashes won't match and the request is denied. That is
  the safe direction — a false DENIAL, never a false ALLOW — but worth
  knowing when a pin you expect to match doesn't.

Because the pin travels inside `nb`, it inherits everything the grant
grammar already guarantees, for free: it is **signed** (folded into
`delegation_binding` under the same length-prefixed grants JSON as every
other caveat), it is **monotone** (a child link can only add a pin, never
drop one a parent carried), and it **fails closed** (a request missing the
`nb` key a grant requires is denied, not waved through). A prefix grant
carrying a pin therefore reads as "any tool whose descriptor is one of these
N" — which covers a tool *added* under the prefix after signing and a tool
*mutated* since signing, with the same check.

`nb` values already alternate on `|`, so one grant can pin more than one
accepted descriptor version:

```json
{ "nb": { "grantor:tool-desc-v1": "3a7f...01|9c2e...44" } }
```

That is what makes a rolling deploy safe — a grant issued before the
rollout keeps matching once the new descriptor's hash joins the set,
instead of stranding every caller mid-deploy until it's re-issued.

**Where this lives.** Hashing and grant-building for this predicate ship in
`@grantor/mcp`, not in `grantor-sdk-core` — see
[`sdk/mcp/README.md`](../../sdk/mcp/README.md). Concretely, a pin is
authored through any of three entry points, all backed by the same
`buildToolGrants({descriptors})`: the **library** directly, the **CLI**
(`grant --descriptors <file.json>`), or the **MCP `grant` tool**'s
`descriptors` argument. `grantor-mcp tools --json` prints the descriptor
array a running server actually serves, in exactly the shape
`--descriptors` (or the MCP tool's `descriptors` map) expects — so the
usual flow is `grantor-mcp tools --json -- <server-cmd> > descriptors.json`
against the real server, then `grantor-mcp grant --tools <names>
--descriptors descriptors.json`. `wrap` still computes hashes
**automatically**, from the descriptors it observes on `tools/list` — that
half is unrelated to authoring: `check` only consumes a hash (from a caller
in `serve` mode, or from `wrap`'s own cache), it never computes one. The
`nb` evaluation this rides on is already four-language and matrix-covered;
computing *this specific hash* is not yet an SDK capability and so is not
held to the four-language rule.

**What this does not do.** The enforcing server computes the hash and
supplies it at check time — nothing else can, since a tool's descriptor is
not on-chain, not in the deed, and not derivable from anything the verifier
holds. A hostile server can simply lie about it. This binds a grant against
*drift* in a server the grantor already trusts to enforce; it is not a
trust-minimization primitive. It also covers tools only, not resources or
prompts, and it does nothing for a grant already issued before the pin
existed. `@grantor/mcp`'s broker (`delegate`) inherits the covering parent
grant's `nb` predicates onto the rebuilt child **verbatim** — since `nb`
compares by exact equality (never by subset), the only legal child value is
the parent's own, so a delegation cannot narrow a pin to fewer accepted
hashes. When more than one parent grant covers the same child resource
(e.g. an unpinned `*` glob and a pinned exact grant for one tool under it),
the broker prefers a **pinned** covering grant over an unpinned one,
regardless of which is listed first — otherwise a broader unpinned grant
listed earlier would silently win and the child would inherit no pin at
all. Ties among several pinned covering grants still resolve by list
order (first match). A resource with no pinned covering grant at all still
delegates with no `nb` key, unchanged.

## 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.
- [Revocation latency](revocation-latency.md) — what a descriptor pin does
  and does not bound, alongside every other revocation vector.
