# Revocation latency

A deed is verified against a public chain, not against a token-introspection
service. That buys you a system with no authorization server in it — and it
raises a fair question that this page answers precisely: **when something
changes on-chain, how long does the old answer keep working?**

Read [Concepts](concepts.md) first if you haven't. The short version of this
page:

> Every chain-backed check is bounded by **your cache TTL plus chain
> observation lag**. A session JWT you minted is bounded by **nothing but its
> own TTL** — it is an ordinary bearer token, and the deed layer's revocation
> semantics do not reach inside it.

That second sentence is the one that decides your design, so it comes first.

## The session boundary

`sessionJwt` / `session_jwt` mints *your* ES256 token from already-verified
claims. It deliberately ignores the deed's own `exp`: the session's expiry is
`now + ttl_secs` and nothing else. A tenant that lapses, an origin epoch that
is bumped, a delegation epoch that is revoked, an agent key that is
revoked — none of them shorten a session that has already been issued.

This is not a gap to be patched; it is what "your OIDC stack carries on
unchanged" costs. A session is a bearer token, and bearer tokens expire.

What it does **not** cost you is a central introspection service. Because the
authority source is a public chain your resource server already reads
directly, re-checking is local crypto plus one `eth_call` — the same call the
first verification made. So the two-tier shape is:

| Call shape | What gates it | Revocation latency |
|---|---|---|
| Cheap, idempotent reads | session JWT | the session TTL |
| Consequential tools (writes, spend, deletion) | re-verify per call — `verify_deed_with_capability` + `CapabilityGuard::authorize` | cache TTL + chain lag |

The reference MCP server uses `SESSION_JWT_TTL_SECS = 300`. If you gate a
consequential tool on a 300-second session, you have accepted up to five
minutes of stale authority on that tool. Either shorten the TTL or re-verify
on that path. Pick deliberately; the default is not a recommendation for
every route.

## What each vector costs

| Change on-chain | Mechanism | Cached? | Latency bound |
|---|---|---|---|
| Replayed deed | challenge burned single-use, before parsing | n/a | **immediate** — the second use fails |
| Tenant lapses (billing) | `status()` → `tenant_is_active` | yes | cache TTL + chain lag |
| Agent removed from the fleet | `root_is_recent` (root changes on revoke) | yes | cache TTL + chain lag |
| Delegated scope revoked | `bumpEpoch(label)` → epoch check | yes | cache TTL + chain lag |
| Root agent key revoked | `is_agent_key` | yes | cache TTL + chain lag |
| Origin vouch revoked | `bumpOriginEpoch` → `isOriginVoucher` | **no** | next read |
| Smart-wallet policy changed | ERC-1271 `isValidSignature` | **no** | next read |
| Anything, once a session exists | — | — | **session TTL** (see above) |

`CachedGate`'s `ttl_secs` is that knob, and its doc comment names it exactly:
"the revocation/billing latency bound." The reference server sets
`CACHE_TTL_SECS = 30`. The cache is bounded at 4096 entries per map, and it
**never caches an error and never serves a stale value in place of one** — a
failed read propagates.

Two reads are deliberately never cached. A cached `true` for an origin vouch
would keep a `bumpOriginEpoch`-revoked origin passing for a whole TTL window,
and the vouch is checked once at boot rather than per request, so there is
nothing to amortise. A cached `true` for a smart wallet would outlive a Safe
changing its owner set or threshold.

## Chain observation lag

"Cache TTL + chain lag" has a second term, and it is not zero:

- **Finality.** A revocation transaction is not observable until it is
  included and your RPC provider will serve it.
- **Replica lag.** A load-balanced RPC endpoint can serve a read from a
  replica that is behind the tip. Two consecutive reads can disagree. This is
  a real, observed failure mode, not a theoretical one — it is why the
  onboarding tooling retries reads after a write instead of asserting once.

If you need a hard bound rather than a typical one, treat chain lag as
provider-dependent and measure it against your own endpoint.

## Time-derived, not transaction-driven

One property surprises people watching for events: **a billing lapse happens
with no transaction at all.** `status()` is derived from `periodEnd`,
`graceWindow` and `block.timestamp` — never stored. A tenant goes
Active → Grace → Inactive because time passed, so there is no
`TenantDeactivated` event to subscribe to. The next uncached read observes it;
nothing announces it.

`graceWindow` is fixed at deployment (the shipped deploy scripts default to
3 days) and is readable on-chain. A lapsed tenant keeps authenticating through
the whole grace window by design — `isActive` is true for `Active || Grace` —
so your effective billing-revocation latency is `graceWindow + cache TTL +
chain lag`, not just the cache TTL.

## Ceilings the SDK enforces for you

| Value | Ceiling | Where |
|---|---|---|
| Deed TTL | 86 400 s (24 h) | `MAX_TTL_SECS_CEILING`, refused at discovery-document build |
| Origin vouch TTL | 90 days, warning inside 14 days | `ORIGIN_VOUCH_MAX_TTL_SECS` |
| Challenge lifetime | 300 s default, single-use | `MemoryChallengeStore` |

The vouch expiry and ceiling are checked in the **shared** document builder,
so every language refuses to advertise an unusable vouch whether or not the
operator runs a self-check.

## What is proven, and what is not

These are live tests, not intentions:

| Property | Test |
|---|---|
| Replayed deed rejected | `replayed_challenge_is_rejected` |
| Scope downgrade mid-session | `bumped_epoch_after_signing_is_epoch_revoked` |
| One tier revoked, another survives | `pro_tier_epoch_bump_revokes_the_tier_while_the_basic_license_still_holds` |
| Revoked agent rejected | `revoked_agent_is_rejected`, `revoked_root_agent_key_is_rejected` |
| Billing lapse rejected | `inactive_tenant_is_rejected_proving_billing_without_infra` |
| Chain outage fails **closed** | fail-closed legs in the TypeScript, Python and Go e2e suites |
| Forged subject rejected | `forged_sub_does_not_survive_since_it_is_recomputed` |
| Origin revocation by epoch bump | `origin_vouch_authenticates_then_revoked_by_epoch_bump` |

**A changed tool descriptor — now covered.** `res` matching is a
one-trailing-`*` prefix glob, so a bare `mcp:tool/*` grant covers a tool
that did not exist when it was signed, and a tool whose `inputSchema` has
since grown a parameter that widens what it can do. `@grantor/mcp` closes
this with a reserved `nb` predicate, `grantor:tool-desc-v1` — a canonical
hash of the tool's `{name, inputSchema}` (RFC 8785/JCS, `description`
excluded) pinned into the signed grant. Pins are authored through the
library — `buildToolGrants({descriptors})` computes the hash and folds it
into the grant's `nb`; the `grant` verb does not yet accept descriptors, so
a pin is not yet authorable through it. `wrap` computes the hash
automatically, itself, from the descriptors it observes passing through
`tools/list`; `CapabilityGuard` denies, fail-closed, when
the requested tool's hash isn't in the pinned set — covering an added tool
and a mutated tool with the same check. Multiple accepted hashes can be
`|`-joined in one grant so a rolling deploy doesn't strand a caller
mid-rollout. See [Capabilities § Pinning a tool
descriptor](capabilities.md#pinning-a-tool-descriptor-grantortool-desc-v1)
for the full mechanism. **The caveat still holds:** the enforcing server
computes the hash, so it can lie about it — this binds a grant against
*drift* in a server you already trust to enforce, not against a hostile
one, and it covers tools only, not resources or prompts.

One thing is still **not** covered, stated plainly rather than left for you
to discover:

1. **`max_uses` is not counted.** It rides on every grant and
   `CapabilityGuard` inspects it, but the guard holds no counter and never
   will — durable counting is per-deployment state, like the challenge store.
   The matched grant exposes it so you can enforce it in your own database or
   Redis.

## Choosing your numbers

1. Start from the worst thing a caller can do with a stale answer. If that is
   "read a public document," a long session is fine. If it is "spend money,"
   re-verify per call.
2. Set `CACHE_TTL_SECS` to the revocation latency you can defend, not the one
   that minimises RPC spend. 30 seconds is the reference value.
3. Keep session TTL at or below the latency you promised for the most
   consequential route it gates.
4. Grant exact resources, or pin a descriptor hash onto a prefix grant, on
   surfaces that change.
5. Remember `graceWindow` when you reason about billing revocation.

## See also

- [Capabilities](capabilities.md) — bounded, delegated agent authority: the
  layer that answers "which tool," not just "who"
- [Verify a deed](verify-tokens.md) — the relying-party side
- [Errors](errors.md) — every rejection code, including the fail-closed ones
