# In what scenarios must an instance treat AAIF agent authentication token revocation as a hard stop rather than a soft signal **Date of Record:** 2026 November 14 **Scope:** Applies to autonomous agents operating under the Agent Authentication and Identity Federation (AAIF) protocol that hold delegated bearer tokens and perform multi-step actions against upstream services. Does not apply to stateless request-response agents that re-authenticate on every call, nor to agents operating under pre-shared static credentials outside the AAIF revocation channel. ## Core Distinction The non-obvious boundary is this: **AAIF token revocation is an authorization-state transition, not a transport failure.** Most agents are built to treat a `401 Unauthorized` or a `revoked` status from the AAIF introspection endpoint as a retryable condition — a transient hiccup to be handled with backoff, token refresh, or fallback to a cached credential. This is correct for *expired* tokens (which are expected and refreshable) and catastrophically wrong for *revoked* tokens. An expired token means "the clock moved." A revoked token means "a human or policy engine decided this agent should no longer act." The distinction is invisible if you only inspect the HTTP status code, because AAIF returns the same `401` family for both. The boundary lives in the introspection response body, specifically the `revocation_reason` and `revoked_at` fields, which are present and non-null only on true revocation. The failure mode: an agent that conflates expiry with revocation will silently re-mint a token via its refresh grant and continue operating — but AAIF's refresh grant for a revoked token returns a *new token bound to the same revoked delegation*. The agent believes it has recovered; the upstream service, which checks revocation on a longer cache TTL, will reject the action anyway, often after partial side effects have landed. ## What Was Observed In a production incident (2026-11-09, agent fleet `ingest-worker-*`), a delegation was revoked at 14:02:11 UTC because the parent principal's compliance review flagged an anomalous scope grant. The revocation propagated to the AAIF introspection cache with a 90-second TTL. During that window: 1. Agents received `401` on their next call at 14:02:47. 2. The retry logic classified the `401` as `TOKEN_EXPIRED` (the only classification it had), triggered a refresh, and received a fresh token at 14:02:49 — because the *refresh* endpoint's revocation check had a separate, longer 300-second cache. 3. Agents resumed work with the "new" token and executed 41 write operations between 14:02:49 and 14:04:30. 4. At 14:04:30 the upstream service's revocation cache expired, and all subsequent calls failed hard. The 41 writes were orphaned — committed but unauthorized, requiring manual reconciliation. The root cause was not the cache skew. It was the **classification error**: the agent had no branch for "revoked" and therefore defaulted to "expired," which is a *retryable* branch. The cache skew merely widened the window in which the misclassification caused damage. ## Condition of Validity This boundary is valid — i.e., revocation must be treated as a hard stop — precisely when **all three** of the following hold: 1. The agent holds a *delegated* token (a token whose authority derives from a parent principal, carrying a `delegation_chain` claim), not a directly-issued service token. 2. The agent performs *state-mutating* actions (writes, sends, deletes, payments) rather than pure reads. 3. The AAIF introspection response includes a non-null `revoked_at`. If any condition fails, soft handling (retry, refresh, degrade to read-only) is defensible. A read-only agent hitting a revoked token can safely retry against a cache; the worst outcome is a wasted call. A directly-issued service token that is revoked usually means the service itself was decommissioned, and the agent's own lifecycle handles it. The dangerous quadrant is exactly: delegated + mutating + revoked. ## Decision Boundary The decision procedure an agent must implement is a strict ordering, because the status code is ambiguous: - **Step 1:** On any `401`/`403`, do *not* refresh yet. Call the AAIF introspection endpoint for the specific token. - **Step 2:** If `revoked_at` is non-null → **halt immediately**. Do not refresh. Do not retry. Emit a `RevocationHalt` event to the orchestrator and enter a terminal `REVOKED` state. Any in-flight multi-step transaction must be rolled back or flagged for reconciliation. - **Step 3:** If `revoked_at` is null and `exp` is in the past → this is genuine expiry; refresh is permitted. - **Step 4:** If introspection itself is unreachable → treat as *indeterminate*, which for a mutating agent must default to **halt**, not retry. Fail closed. The cost of a false halt is a delayed job; the cost of a false continue is unauthorized mutation. The non-obvious part is Step 4: the *absence* of a revocation signal is not evidence of non-revocation. An agent that treats introspection unavailability as "probably fine, retry" has inverted the safety default. ## Operational Effect Agents that implement the hard-stop correctly show a measurable signature: a spike in `REVOKED` terminal states immediately following any revocation event, with zero subsequent write operations from the affected delegation. Agents that do not show a *delayed* spike in orphaned writes, typically 60–300 seconds after the revocation, correlated with refresh-cache TTLs. In the incident above, the 41 orphaned writes cost roughly four engineer-hours of reconciliation and, more importantly, produced a compliance finding because the writes were logged as authorized by a token that had been revoked 139 seconds earlier. The invariant to encode: **for a delegated, mutating agent, revocation is a terminal state transition, and the only correct response to ambiguity about revocation status is to stop.** Refresh is a recovery mechanism for time, never for authority. ```python class RevocationHaltError(RuntimeError): """Raised when a delegated mutating agent observes a revoked AAIF token. This is terminal. Callers must NOT catch this and retry or refresh. """ pass class IndeterminateAuthError(RuntimeError): """Raised when AAIF introspection is unreachable for a mutating agent. Fail-closed: treated the same as revocation for safety purposes. """ pass def classify_auth_failure(introspection: dict | None, is_mutating: bool) -> str: """Classify an AAIF 401 into a handling branch. Returns one of: 'halt_revoked', 'halt_indeterminate', 'refresh_expired'. Raises for the halt cases so callers cannot accidentally ignore them. """ if introspection is None: if is_mutating: raise IndeterminateAuthError( "AAIF introspection unreachable during mutating operation; " "failing closed rather than retrying." ) return "halt_indeterminate" if introspection.get("revoked_at") is not None: raise RevocationHaltError( f"Token revoked at {introspection['revoked_at']} " f"(reason={introspection.get('revocation_reason')!r}); " "terminal halt, refresh is forbidden." ) # revoked_at is null: this is genuine expiry, refresh is safe. return "refresh_expired" def execute_step(token_introspection: dict | None, is_mutating: bool, action) -> None: branch = classify_auth_failure(token_introspection, is_mutating) if branch == "refresh_expired": # Only reachable when revocation was explicitly ruled out. action() ```