# Where must an instance check revocation: the split-brain window between AAIF credential expiry and workload identity key rotation **Date of Record:** 2026 November 12 **Scope:** Applies to agent instances holding AAIF-issued, workload-bound short-lived credentials (TTL ≤ 15 min) where the issuing authority can revoke an agent identity or rotate the underlying workload key independently of credential expiry. Does not apply to long-lived static API keys or to deployments where revocation events are synchronously propagated to every verifier before the next request. ## Core Distinction The non-obvious invariant is this: **credential expiry and identity revocation are two different clocks, and the AAIF verifier only checks one of them at the point of use.** A short-lived credential proves *the workload was bound to this agent identity at issuance time*. It does not prove *the agent identity is still valid at verification time*. Between issuance and expiry there is a window — bounded by the credential TTL, but in practice extended by clock skew, caching, and token introspection lag — during which a revoked agent can present a structurally valid, unexpired, correctly-signed credential. The second distinction is subtler and is where most incidents originate: **key rotation is not revocation.** Rotating the workload-bound signing key invalidates *future* credential issuance for the old key, but every credential already minted under the old key remains verifiable until its own `exp`. An operator who rotates a compromised key and believes the compromise is contained has, in fact, only contained *new* issuance. The old credentials are still live in the wild for the remainder of their TTL — and if any verifier caches the old public key without an `exp`-aware cache bound, potentially longer. ## What Was Observed During a red-team exercise against a multi-tenant agent fleet, the following sequence reproduced reliably: 1. Agent instance `A-7734` obtained a workload-bound AAIF credential with a 900-second TTL from the issuing authority. The credential embedded the workload key fingerprint `wk:9f2a...` and the agent identity claim `aid:agent-7734`. 2. At T+120s, the security team revoked `aid:agent-7734` (suspected prompt-injection-driven exfiltration) and rotated the workload key to `wk:c41e...`. 3. At T+300s, `A-7734` presented its still-unexpired credential to downstream verifier `V-2`. `V-2` validated the signature against the *cached* public key for `wk:9f2a...` (cache TTL 600s, set independently of credential TTL), confirmed `exp` had not passed, and admitted the request. 4. The revocation event never reached `V-2` because the revocation feed is pull-based with a 5-minute poll interval, and the rotate-and-revoke operation fired between polls. Net result: a revoked agent continued to act for approximately 480 seconds after revocation, across three downstream verifiers, because three independent cache/poll clocks (credential `exp`, key cache TTL, revocation poll interval) were not ordered relative to one another. A second observation: when the revocation feed *did* arrive at `V-2`, it carried only `aid:agent-7734` and the new key fingerprint. It did not carry the set of credential IDs issued under the old key. `V-2` therefore could not distinguish "credential minted before revocation, still valid" from "credential minted before revocation, must be rejected." It defaulted to accepting, because rejecting required a credential-ID denylist it did not have. ## Condition of Validity The unsafe acceptance is valid (i.e., the failure can occur) precisely when all three hold: - **C1 — Clock ordering:** `revocation_propagation_delay + key_cache_ttl > 0` while the presented credential's remaining lifetime `exp - now > revocation_propagation_delay`. If the credential expires before revocation can propagate, the window is closed by expiry. - **C2 — Key cache not bound to credential lifetime:** the verifier caches the workload public key for a duration `key_cache_ttl` that exceeds the credential TTL, or caches it without any invalidation hook on rotation. A correct implementation sets `key_cache_ttl ≤ min(credential_ttl, revocation_poll_interval)`. - **C3 — Revocation is identity-scoped, not credential-scoped:** the revocation record names the agent identity but not the outstanding credential IDs or the pre-rotation key fingerprint, so the verifier cannot construct a deny decision for already-issued credentials. The failure does **not** occur when the revocation feed is push-based and synchronous with issuance (no window), or when credentials are bound to a key that is itself re-validated per-request against a live authority (no cache). It also does not occur for credentials whose `exp` is shorter than the revocation propagation delay — the expiry clock closes the window first. ## Decision Boundary At verification time, an instance must choose between two policies, and the choice is forced by whether C2 and C3 hold: - **If the verifier can prove `key_cache_ttl ≤ credential_ttl` AND the revocation record is credential-scoped** (carries credential IDs or pre-rotation key fingerprint): accept on valid signature + unexpired `exp`. The clocks are ordered; expiry closes the window. - **Otherwise** (the common case, including any pull-based revocation feed with poll interval > 0): the verifier **must treat key rotation as an implicit revocation of all credentials minted under the old key**, and must reject any credential whose embedded key fingerprint is not the *current* fingerprint, regardless of `exp`. This is the conservative boundary: it converts the rotation event into a synchronous deny for the old key, at the cost of re-fetching the current fingerprint per verification (or caching it with a TTL strictly less than the credential TTL). The trap is the middle ground: a verifier that checks `exp` but not key-fingerprint currency, or that caches keys longer than credentials live, will pass every test in staging (where revocation is instantaneous) and fail only under the exact production ordering above. ## Operational Effect The practical consequence is that **short TTL is not a revocation mechanism**; it is only a bound on how long a *correctly propagated* revocation can be delayed. Operators who rely on "the credential expires in 15 minutes anyway" are implicitly assuming C1–C3 hold, which they usually do not once a key cache and a poll interval are introduced. The remedy with the smallest blast radius is to make the verifier's key-fingerprint cache TTL strictly less than the credential TTL and to treat any fingerprint mismatch as a hard reject — this closes the window without requiring a credential-ID denylist. Where a denylist is available, prefer credential-scoped revocation records, because they let the verifier reject specific outstanding credentials without waiting for the next rotation. ```python import time from dataclasses import dataclass class RevocationWindowError(RuntimeError): """Raised when a credential's validity window is not ordered against revocation propagation and key-cache clocks (AAIF C1-C3 violated).""" @dataclass(frozen=True) class Credential: credential_id: str agent_id: str key_fingerprint: str issued_at: float expires_at: float @dataclass(frozen=True) class VerifierConfig: key_cache_ttl: float # seconds the public key is cached revocation_poll_interval: float # seconds between revocation feed polls revocation_is_credential_scoped: bool def verify(cred: Credential, current_key_fingerprint: str, config: VerifierConfig, now: float | None = None) -> bool: now = time.time() if now is None else now if now >= cred.expires_at: return False # expired: expiry clock closes the window # C2: key cache must not outlive the credential it validates. if config.key_cache_ttl > (cred.expires_at - cred.issued_at): # C3: without credential-scoped revocation we cannot deny the old key # except by fingerprint mismatch. Enforce the conservative boundary. if not config.revocation_is_credential_scoped: if cred.key_fingerprint != current_key_fingerprint: return False # rotation == implicit revocation of old-key creds else: # credential-scoped feed: rotation alone is not a deny signal, but # the poll interval must still be ordered against the credential. if config.revocation_poll_interval > (cred.expires_at - now): raise RevocationWindowError( f"credential {cred.credential_id} expires before revocation " f"can propagate ({config.revocation_poll_interval}s poll vs " f"{cred.expires_at - now:.1f}s remaining)" ) return cred.key_fingerprint == current_key_fingerprint or \ config.revocation_is_credential_scoped ```