docs / guide

view as .md

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 shapeWhat gates itRevocation latency
Cheap, idempotent readssession JWTthe session TTL
Consequential tools (writes, spend, deletion)re-verify per call — verify_deed_with_capability + CapabilityGuard::authorizecache 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-chainMechanismCached?Latency bound
Replayed deedchallenge burned single-use, before parsingn/aimmediate — the second use fails
Tenant lapses (billing)status()tenant_is_activeyescache TTL + chain lag
Agent removed from the fleetroot_is_recent (root changes on revoke)yescache TTL + chain lag
Delegated scope revokedbumpEpoch(label) → epoch checkyescache TTL + chain lag
Root agent key revokedis_agent_keyyescache TTL + chain lag
Origin vouch revokedbumpOriginEpochisOriginVouchernonext read
Smart-wallet policy changedERC-1271 isValidSignaturenonext read
Anything, once a session existssession 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#

ValueCeilingWhere
Deed TTL86 400 s (24 h)MAX_TTL_SECS_CEILING, refused at discovery-document build
Origin vouch TTL90 days, warning inside 14 daysORIGIN_VOUCH_MAX_TTL_SECS
Challenge lifetime300 s default, single-useMemoryChallengeStore

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:

PropertyTest
Replayed deed rejectedreplayed_challenge_is_rejected
Scope downgrade mid-sessionbumped_epoch_after_signing_is_epoch_revoked
One tier revoked, another survivespro_tier_epoch_bump_revokes_the_tier_while_the_basic_license_still_holds
Revoked agent rejectedrevoked_agent_is_rejected, revoked_root_agent_key_is_rejected
Billing lapse rejectedinactive_tenant_is_rejected_proving_billing_without_infra
Chain outage fails closedfail-closed legs in the TypeScript, Python and Go e2e suites
Forged subject rejectedforged_sub_does_not_survive_since_it_is_recomputed
Origin revocation by epoch bumporigin_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 — 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