# What structural signals indicate an AAIF delegated capability token has outlived a per-tool scoped consent revocation **Date of Record:** 2026 November 13 **Scope:** Applies to Agent Identity and Authorization Framework (AAIF) capability tokens that carry per-tool scope grants (`tools..invoke`) and are subject to independent, per-tool consent revocation by the resource owner. Does not apply to whole-agent revocation, session termination, or tokens whose scopes are coarse-grained at the agent level rather than the tool level. ## Core Distinction The critical distinction is between *token validity* and *scope authorization*. An AAIF capability token remains cryptographically valid until its `exp` claim elapses and its signature verifies against the issuing authority's key set. Per-tool consent revocation is a separate, asynchronous side effect: it mutates the authorization state of a single `` scope without invalidating the token itself. A revoked tool scope does not shrink the token; it shadows one leaf of the token's scope tree. The non-obvious failure is this: a delegated token can be *structurally well-formed, unexpired, and signature-valid* while one of its per-tool scopes has been revoked. Downstream agents and tool gateways that validate only the token envelope — signature, `exp`, `aud`, and the presence of the scope string — will admit a call that the resource owner has already withdrawn. The token looks healthy. The consent is gone. This is not a race in the usual sense. Revocation is authoritative the moment it commits at the consent store; the token is merely a stale projection of authorization state that was true at issuance. The gap between those two truths is the failure surface. ## What Was Observed In a delegated three-hop chain (orchestrator → planner → executor), the executor held a capability token minted with scopes `tools.filesystem.read`, `tools.filesystem.write`, and `tools.shell.exec`. The resource owner revoked `tools.shell.exec` consent at T+40s. The consent store committed the revocation and propagated a `scope_revoked` event to the tool gateway's local cache within 180ms. The executor continued to invoke `tools.shell.exec` successfully for the next 6.2 seconds. Three invocations were admitted. The gateway was validating the token envelope and consulting a *cached* scope decision that had been populated at token-introduction time (when the token was first presented), not at call time. The cache TTL for scope decisions was 10s, independent of the revocation propagation path. The revocation event updated the *revocation index* but not the *positive-decision cache*, so the stale positive entry won. Structural signals that preceded and accompanied the failure: 1. **Scope-decision cache TTL exceeded revocation propagation latency.** The positive authorization cache (10s) was longer than the revocation event delivery (180ms). Any call in the window between revocation commit and cache expiry is admitted incorrectly. 2. **Token `iat` was used as the cache key epoch.** The gateway keyed positive decisions on `(token_id, scope)` without incorporating a revocation generation counter. A revocation did not bump the generation, so no cache invalidation fired. 3. **No `scope_revoked_at` claim or introspection endpoint was consulted at call time.** The token carried no per-scope freshness marker, and the gateway did not perform a per-call introspection for high-risk scopes. 4. **Delegation depth obscured the authority.** The executor's token was a re-delegation from the planner; the revocation was recorded against the *original* resource owner's consent grant. The gateway matched the revocation to the root grant but not to the derived scope string presented by the executor, so the revocation index lookup missed. The last signal is the subtle one: revocation was keyed on the consent grant identifier, while the call presented a derived capability. Without a `grant_ref` claim linking the derived scope back to its originating consent grant, the revocation is invisible at the point of enforcement. ## Condition of Validity The invariant that must hold for per-tool scoped revocation to be enforceable at call time is: > For every admitted invocation of tool `t` under token `T`, there exists a positive authorization decision for `(T.grant_ref, t)` whose decision timestamp is strictly later than the latest committed revocation timestamp for `(T.grant_ref, t)`. This invariant fails whenever any of the following holds: - Positive scope decisions are cached with a TTL that is not bounded above by the revocation propagation SLA. - The token does not carry a `grant_ref` (or equivalent) that binds each derived scope to its originating consent grant, making revocation lookup impossible at enforcement time. - Revocation events invalidate only the negative/revocation index and not the positive-decision cache. - Re-delegation strips or rewrites the `grant_ref`, severing the link between the presented scope and the revocable grant. The invariant holds only when revocation propagation, cache invalidation, and grant linkage are all correct *simultaneously*. Any single defect reopens the window. ## Decision Boundary The operational decision boundary is whether to treat token validity as sufficient for admission. It is not, for any scope that is independently revocable. Concretely: - **Admit on envelope validation alone** only for scopes that are not subject to per-tool revocation, or for tokens whose `exp` is shorter than the revocation propagation SLA (making the window negligible by construction). - **Require call-time introspection or a generation-checked cache** for any scope bound to a revocable consent grant. The cache key must include a revocation generation counter that is bumped on every commit, so a stale positive entry cannot survive a revocation. - **Reject tokens lacking `grant_ref` linkage** for revocable scopes. A derived capability that cannot be traced to a revocable grant cannot be revoked, and must be treated as non-revocable — which means it must not be issued for revocable tools in the first place. - **Bound positive-decision cache TTL by the revocation SLA**, not by performance targets. If the SLA is 200ms, the cache TTL for revocable scopes must not exceed 200ms, or the cache must be generation-invalidated. The boundary is sharp: either the enforcement point can prove freshness against the revocation index at call time, or it is admitting on stale authority. ## Operational Effect When the boundary is crossed, the observable effect is a *silent authorization overrun*: calls succeed, audit logs show valid tokens, and no error surfaces at the agent or gateway. The overrun is bounded by the positive-decision cache TTL (or, absent a cache, by the introspection interval). In the observed case, the overrun window was 6.2s and admitted three `shell.exec` calls after consent withdrawal. The downstream consequences compound because delegated agents treat a successful call as evidence of continued authority and may escalate within the same scope. The failure is not detected by token validation, signature checks, or expiry monitoring — every standard health signal reads green. It is detected only by reconciling the revocation index against the positive-decision log, which is why the `grant_ref` linkage and the generation counter are the load-bearing structural signals. Mitigation is structural, not procedural: bind every derived scope to its grant, key positive decisions on a revocation generation, and cap cache TTL at the revocation SLA. Procedural review cannot close a window that is opened by cache-key design. ```python import time from dataclasses import dataclass, field class StaleScopeAuthorizationError(RuntimeError): """Raised when a tool invocation is admitted on a positive decision that predates the latest committed revocation for the same grant scope.""" @dataclass class CapabilityToken: token_id: str grant_ref: str # binds derived scope back to revocable consent grant scopes: set[str] issued_at: float expires_at: float @dataclass class RevocationIndex: # (grant_ref, tool) -> latest committed revocation timestamp revoked_at: dict[tuple[str, str], float] = field(default_factory=dict) generation: int = 0 def revoke(self, grant_ref: str, tool: str) -> None: self.revoked_at[(grant_ref, tool)] = time.time() self.generation += 1 # bumps generation so positive caches invalidate @dataclass class PositiveDecisionCache: # key includes generation so a revocation invalidates stale positives entries: dict[tuple[str, str, int], float] = field(default_factory=dict) ttl: float = 0.2 # must be <= revocation propagation SLA def get(self, token_id: str, tool: str, generation: int, now: float) -> float | None: ts = self.entries.get((token_id, tool, generation)) if ts is None or (now - ts) > self.ttl: return None return ts def put(self, token_id: str, tool: str, generation: int, now: float) -> None: self.entries[(token_id, tool, generation)] = now def authorize_tool_call( token: CapabilityToken, tool: str, revocations: RevocationIndex, cache: PositiveDecisionCache, now: float | None = None, ) -> None: now = time.time() if now is None else now if now >= token.expires_at: raise StaleScopeAuthorizationError( f"token {token.token_id} expired at {token.expires_at}" ) if tool not in token.scopes: raise StaleScopeAuthorizationError( f"tool {tool} not in token {token.token_id} scopes" ) # grant linkage is mandatory for revocable scopes if not token.grant_ref: raise StaleScopeAuthorizationError( f"token {token.token_id} lacks grant_ref; cannot enforce per-tool revocation" ) revoked_ts = revocations.revoked_at.get((token.grant_ref, tool)) decision_ts = cache.get(token.token_id, tool, revocations.generation, now) if decision_ts is None: # no fresh positive decision: consult authoritative index if revoked_ts is not None and revoked_ts <= now: raise StaleScopeAuthorizationError( f"scope ({token.grant_ref}, {tool}) revoked at {revoked_ts}; " f"refusing call at {now}" ) cache.put(token.token_id, tool, revocations.generation, now) return # fresh positive decision exists; it must postdate any committed revocation if revoked_ts is not None and decision_ts < revoked_ts: raise StaleScopeAuthorizationError( f"positive decision at {decision_ts} predates revocation at {revoked_ts} " f"for ({token.grant_ref}, {tool}); stale authority" ) ```