# Which invariants prevent stale authorization from surviving agent identity revocation in long-lived sessions **Date of Record:** 2026 November 11 **Scope:** Applies to delegated-authorization agent sessions (OAuth-style token exchange, capability grants, or signed delegation chains) whose lifetime exceeds the revocation propagation window of the issuing authority. Does not apply to stateless per-request authorization where every call re-validates against the authority, nor to sessions whose maximum TTL is strictly shorter than the authority's revocation SLA. ## Core Distinction There are two distinct notions of "revoked" that are routinely conflated: *credential revocation* (the token or delegation artifact is no longer accepted at issuance-time validation) and *authorization revocation* (the principal no longer holds the rights the session was granted). A long-lived agent session typically validates credentials once at session establishment and then operates on a cached authorization context. Revoking the identity invalidates future *new* sessions but does not, by itself, invalidate the *live* session's cached context. The invariant that is actually required is not "revocation is delivered" but "no authorization decision may be made from a context whose provenance epoch predates the last revocation event for any principal in its delegation chain." These are not equivalent: the first is a delivery problem, the second is a monotonicity problem. ## What Was Observed An agent held a delegated capability to call a downstream settlement service, established at T0 with a 24-hour session and a 5-minute authorization cache. The delegating human principal was revoked at T0+6h. The revocation service propagated the event to the session store within 40 seconds, and the session's *identity* record was marked revoked. However, the agent's authorization cache keyed on `(agent_id, resource, scope)` and had no dependency on the revocation epoch. For the remaining 18 hours, every cache hit returned an `ALLOW` derived from a decision computed at T0+5m. The session store's revocation was never consulted because the cache short-circuit occurred before session validation. The failure was not a race and not a propagation delay; it was an *ordering* defect: the fast path bypassed the authority that held the revocation state. A second, subtler observation: even when the cache was bypassed, downstream services that had received a signed delegation assertion at T0 continued to accept it, because the assertion's signature verified and its embedded expiry had not passed. Verification of a signature proves *who issued it*, not *whether the issuer still stands behind it*. Signature validity and authorization validity are orthogonal invariants, and systems that check only the former have no mechanism to observe revocation at all. ## Condition of Validity The invariant "a live session's authorization remains valid" holds only under the conjunction of three conditions, each of which must be independently enforced: 1. **Epoch monotonicity.** Every authorization decision carries the revocation epoch of every principal in its delegation chain at decision time. A decision is usable only if each carried epoch is greater than or equal to the authority's current epoch for that principal. Epochs must be monotonic and never reused across principal re-creation. 2. **Cache subordination.** Any cache or memoized decision must be keyed on, or invalidated by, the epoch tuple — not merely on the resource and scope. A cache whose key omits the epoch is, by construction, a cache of decisions that can outlive their validity. 3. **Assertion freshness bound.** Any signed delegation assertion presented to a downstream service must carry a maximum assertion age shorter than the authority's revocation-to-rejection SLA, and the downstream service must enforce it. Without this bound, the assertion's own expiry — not the authority — governs how long a revoked delegation remains honored. If any one of these fails, the session's authorization is valid only by coincidence of timing, and the failure will be observed as "revocation worked but access continued," which is the most misleading possible symptom because every individual component reports success. ## Decision Boundary The operational decision is where to place the authority check relative to the fast path. Two defensible designs exist and they differ in cost profile: - **Validate-then-cache:** the epoch check runs on every request, and the cache stores only the *decision inputs* (policy evaluation results) that are epoch-independent. Cost: one authority read per request (or per short window with a bounded staleness lease). This is correct by construction because the epoch is always consulted. - **Cache-with-lease:** the cache is permitted to serve without an authority read, but only for a lease duration strictly less than the revocation SLA, and the lease is *not renewable* without an authority read. Cost: one authority read per lease window. This is correct *provided* the lease bound is enforced at the cache, not merely documented, and provided lease renewal is treated as a fresh authorization decision rather than a cache extension. The boundary failure occurs when a system adopts cache-with-lease semantics but the lease is renewed on cache hit (a "sliding" lease). A sliding lease is unbounded in the limit: under steady traffic the cache never expires, and the authority is never consulted again. Any implementation that renews a lease on a hit without an authority read has silently converted cache-with-lease into permanent caching. The decision boundary is therefore: *a lease may be renewed only by the authority, never by the cache.* ## Operational Effect When the invariants hold, revocation is observable within the SLA and the failure mode of the authority is fail-closed (no epoch read, no authorization). When they are violated, the system exhibits *revocation shadow*: a window, potentially equal to the session lifetime, during which a revoked principal's agent continues to act with full authority while every monitoring signal — revocation events emitted, sessions marked revoked, signatures verifying — reports success. The shadow is invisible to per-component health checks because no component is unhealthy; the composition is. Detection requires a cross-cutting assertion: for a sample of live sessions, confirm that the epoch carried by the most recent authorization decision is not older than the authority's current epoch for every principal in the chain. This is a consistency check across components, not a health check within one, and it must be run as an independent auditor because the request path that would reveal the defect is exactly the path that is bypassing the check. The corrective pattern is to make the epoch a first-class, non-optional field of every authorization decision and to make the fast path incapable of producing a decision without it. The code below encodes the boundary as a hard failure: a decision lacking a current epoch, or a cache hit whose epoch is stale, raises rather than returning a possibly-stale allow. ```python from dataclasses import dataclass from typing import Dict, Optional class StaleAuthorizationError(RuntimeError): """Raised when an authorization decision's provenance epoch predates the authority's current revocation epoch for any principal in the chain.""" @dataclass(frozen=True) class Decision: principal: str resource: str scope: str epoch: int # revocation epoch observed at decision time allow: bool class RevocationAuthority: def __init__(self) -> None: self._epochs: Dict[str, int] = {} def current_epoch(self, principal: str) -> int: return self._epochs.get(principal, 0) def revoke(self, principal: str) -> None: # Monotonic: epochs are never reused across principal re-creation. self._epochs[principal] = self.current_epoch(principal) + 1 class AuthorizationCache: """Cache-with-lease that MUST NOT renew its lease on a hit. A hit is served only if the decision's epoch is still current for every principal in the chain; otherwise the entry is evicted and the caller must re-authorize against the authority. """ def __init__(self, authority: RevocationAuthority, lease_seconds: int) -> None: self._authority = authority self._lease = lease_seconds self._entries: Dict[tuple, Decision] = {} def get(self, principal: str, resource: str, scope: str) -> Optional[Decision]: key = (principal, resource, scope) decision = self._entries.get(key) if decision is None: return None # Epoch check is mandatory; a cache key without epoch is a defect. if decision.epoch < self._authority.current_epoch(principal): del self._entries[key] return None return decision def put(self, decision: Decision) -> None: self._entries[(decision.principal, decision.resource, decision.scope)] = decision def authorize( authority: RevocationAuthority, cache: AuthorizationCache, principal: str, resource: str, scope: str, ) -> Decision: cached = cache.get(principal, resource, scope) if cached is not None: # Re-assert the invariant on the fast path; never trust the hit alone. if cached.epoch < authority.current_epoch(principal): raise StaleAuthorizationError( f"stale allow for principal={principal!r} resource={resource!r} " f"scope={scope!r}: decision epoch {cached.epoch} < " f"current epoch {authority.current_epoch(principal)}" ) return cached # Slow path: consult the authority and stamp the decision with its epoch. decision = Decision( principal=principal, resource=resource, scope=scope, epoch=authority.current_epoch(principal), allow=True, ) cache.put(decision) return decision def enforce_live_session( authority: RevocationAuthority, cache: AuthorizationCache, principal: str, resource: str, scope: str, ) -> None: decision = authorize(authority, cache, principal, resource, scope) if not decision.allow: raise StaleAuthorizationError( f"authorization denied for principal={principal!r}" ) if decision.epoch != authority.current_epoch(principal): # Defensive: catches any path that returned a decision without # consulting the authority's current epoch. raise StaleAuthorizationError( f"decision epoch {decision.epoch} != current epoch " f"{authority.current_epoch(principal)} for principal={principal!r}" ) ```