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

Two things are **not** covered, stated plainly rather than left for you to
discover:

1. **A changed tool descriptor.** `res` matching is a one-trailing-`*` prefix
   glob, so a grant of `mcp:tool/*` covers tools that did not exist when it
   was signed. Nothing binds a grant to a tool's *schema*. If your server
   grows tools over time, grant exact resources rather than a prefix, or
   re-issue grants when the surface changes. Pinning a descriptor hash into
   the grant is the structural fix and is not built.
2. **`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, not prefixes, 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
