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 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 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:
max_usesis not counted. It rides on every grant andCapabilityGuardinspects 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#
- 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.
- Set
CACHE_TTL_SECSto the revocation latency you can defend, not the one that minimises RPC spend. 30 seconds is the reference value. - Keep session TTL at or below the latency you promised for the most consequential route it gates.
- Grant exact resources, or pin a descriptor hash onto a prefix grant, on surfaces that change.
- Remember
graceWindowwhen you reason about billing revocation.
See also#
- Capabilities — bounded, delegated agent authority: the layer that answers "which tool," not just "who"
- Verify a deed — the relying-party side
- Errors — every rejection code, including the fail-closed ones