# How does an instance detect a revoked delegated capability token when the revocation event is delivered only to the token's issuing authority and not to the downstream resource server? **Date of Record:** 2026 November 11 **Scope:** Applies to AAIF delegated capability tokens where a per-tool scoped consent grant is issued by an authority service and presented to a resource server that validates the token locally (offline or with cached JWKS/introspection state). Does not apply to architectures where every tool invocation performs synchronous introspection against the authority at call time, nor to single-authority deployments where the authority and resource server share a process. ## Core Distinction The distinction that matters is between **token validity** and **token liveness**. Validity is a property the resource server can compute from the token itself: signature chain, `exp`, `nbf`, `aud`, `scope`, and the delegated `sub`→`act` chain. Liveness is whether the issuing authority still considers the grant active at the moment of use. A delegated capability token can be perfectly valid and simultaneously dead. Revocation is a liveness event, not a validity event, and the AAIF spec's revocation propagation model places the authoritative revocation record at the issuing authority — the one party the resource server is explicitly not required to contact on the hot path. The non-obvious consequence: a well-behaved instance that validates tokens correctly, checks scopes per-tool, and honors consent boundaries can still execute a tool call on behalf of an agent whose delegated consent was revoked seconds earlier. The failure is silent. There is no error, no signature mismatch, no scope violation. The token is valid; it is just no longer alive. ## What Was Observed A three-tier AAIF deployment: an orchestrator agent (`sub=agent-7f3a`) obtained a delegated capability token from the consent authority (`iss=https://auth.internal/aaif`) with `scope="tool:filesystem.read tool:http.fetch"`, `exp=+900s`, and a delegation chain `act` claim naming the orchestrator. The token was presented to a resource server (`rs-filesystem-01`) that validated offline using a cached JWKS refreshed every 300 seconds. At T+0 the human principal revoked the `tool:filesystem.read` consent via the authority's consent-management UI. The authority marked the grant revoked and published a revocation event to its own event bus. The resource server was not a subscriber. At T+41s the orchestrator issued a `filesystem.read` call. The resource server validated the token: signature good, `exp` in the future, `scope` contained `tool:filesystem.read`, `aud` matched. It executed the read. The revocation was 41 seconds old and had never reached the enforcing party. The instance did not detect the revocation because **it had no channel on which detection could occur**. Detection is not a property of validation logic; it is a property of the revocation-propagation topology. The instance's local validation was correct and irrelevant. A second observation: when the resource server *was* made a subscriber and the revocation event arrived, ordering was not guaranteed. A revocation for grant `g-91` arrived before the corresponding grant-issuance event in one replay, causing the resource server to cache a "revoked" tombstone for a grant it had not yet seen issued, then later accept a token for `g-91` because the tombstone had been evicted by the 300-second cache TTL. The tombstone TTL must exceed the maximum token `exp`, not the JWKS refresh interval. This is the invariant that was violated. ## Condition of Validity The instance may treat a locally-validated delegated capability token as live only when **all** of the following hold: 1. The token `exp` is in the future AND `exp - now <= Δ`, where `Δ` is the maximum revocation-propagation latency the deployment is willing to tolerate (the "revocation staleness budget"). If `Δ` is not configured, it is implicitly infinite and the instance must not claim revocation safety. 2. The resource server holds a revocation channel (push subscription or poll) whose observed last-delivery timestamp is within `Δ` of now. A channel that has not delivered *anything* — including heartbeat/keepalive — within `Δ` is treated as down, not as "no revocations." 3. Any revocation tombstone for the grant is retained for at least `max_token_exp` after the revocation event, not for the JWKS refresh interval. 4. The per-tool scope check is evaluated against the *intersection* of the token scope and the currently-live consent set known to the resource server, not against the token scope alone. When condition 2 cannot be satisfied — air-gapped resource server, partitioned network, authority outage — the instance is in a degraded mode where it may serve *only* tokens whose `exp` is within `Δ` of issuance (short-lived tokens that outlive their own revocation window). Long-lived tokens presented to a resource server without a live revocation channel must be rejected, not accepted on validity alone. ## Decision Boundary The decision boundary is between **fail-closed on liveness** and **fail-open on validity**. The default AAIF resource-server behavior is fail-open on validity: if the signature and claims check out, execute. This is correct for the authority's threat model (protecting against forged tokens) and wrong for the principal's threat model (protecting against continued use after consent withdrawal). An instance must choose explicitly. The boundary condition is: - If the tool is **idempotent and read-only** and the revocation staleness budget `Δ` is documented and accepted by the principal, fail-open on validity within `Δ` is defensible. - If the tool is **mutating, exfiltrating, or irreversible** (writes, deletes, network egress, payment, credential issuance), the instance must fail-closed on liveness: no live revocation channel ⇒ no execution, regardless of token validity. The boundary is per-tool, not per-deployment. A single resource server fronting both `filesystem.read` and `filesystem.delete` must apply different liveness requirements to each, because the cost of a stale-revocation read is bounded and the cost of a stale-revocation delete is not. Scoped consent that is enforced only at issuance and not at each tool boundary is scoped consent in name only. ## Operational Effect The operational effect of getting this wrong is a **revocation window** — a bounded interval during which a revoked capability remains exercisable — that is invisible to the principal, the authority, and the resource server's logs. The authority believes revocation is immediate (it published the event). The resource server believes it is enforcing correctly (it validated correctly). The principal believes consent was withdrawn (the UI said so). All three are locally correct and globally wrong. The mitigation is to make the window explicit and bounded: configure `Δ`, require a live revocation channel for mutating tools, retain tombstones for `max_token_exp`, and log the observed channel staleness on every authorization decision so that a post-incident review can reconstruct the window. An instance that cannot state its `Δ` cannot state its revocation guarantee. The following reference implementation encodes the decision boundary as an enforceable check. It raises when a mutating tool is invoked without a live revocation channel within the staleness budget. ```python import time from dataclasses import dataclass class RevocationLivenessError(RuntimeError): """Raised when a token is valid but its revocation liveness cannot be established.""" MUTATING_TOOLS = frozenset({ "filesystem.write", "filesystem.delete", "http.egress", "payment.charge", "credential.issue", }) @dataclass(frozen=True) class Token: grant_id: str scope: frozenset[str] exp: float # absolute epoch seconds issued_at: float @dataclass(frozen=True) class RevocationChannel: last_delivery_ts: float | None # None => never delivered anything is_subscribed: bool def authorize( token: Token, tool: str, channel: RevocationChannel, revoked_grants: frozenset[str], delta_seconds: float, now: float | None = None, ) -> None: now = time.time() if now is None else now # 1. Validity checks (necessary, not sufficient). if token.exp <= now: raise RevocationLivenessError(f"token for grant {token.grant_id} expired") if tool not in token.scope: raise RevocationLivenessError( f"tool {tool} not in token scope {sorted(token.scope)}" ) # 2. Liveness check: is the revocation channel fresh? if not channel.is_subscribed: channel_fresh = False elif channel.last_delivery_ts is None: channel_fresh = False else: channel_fresh = (now - channel.last_delivery_ts) <= delta_seconds # 3. Fail-closed for mutating tools without a live channel. if tool in MUTATING_TOOLS and not channel_fresh: raise RevocationLivenessError( f"refusing mutating tool {tool} for grant {token.grant_id}: " f"revocation channel stale (last_delivery={channel.last_delivery_ts}, " f"delta={delta_seconds}s)" ) # 4. Tombstone check (must be retained for max_token_exp, not JWKS TTL). if token.grant_id in revoked_grants: raise RevocationLivenessError( f"grant {token.grant_id} revoked; tombstone hit for tool {tool}" ) # 5. Read-only tools may proceed within delta on validity alone. return None ```