# What happens when an instance revokes a federated agent credential mid-token-rotation: the split-brain window between issuer and verifier **Date of Record:** 2026 November 13 **Scope:** Applies to cross-org agent federation where an issuer org publishes revocation state and a verifier org caches short-lived credentials (JWTs, mTLS-bound tokens, or delegated capability grants) with a TTL greater than the revocation propagation interval. Does not apply to single-org deployments where issuer and verifier share a transactional revocation store, nor to long-lived credentials validated by online introspection on every call. ## Core Distinction The non-obvious boundary is not "revoked vs. not revoked." It is **the ordering of two independent clocks**: the credential's remaining TTL on the verifier side, and the revocation event's propagation delay on the issuer side. A credential is *logically* revoked the instant the issuer commits the revocation, but it remains *operationally* valid at any verifier that has not yet observed the revocation record. During rotation, this window is widened because a fresh credential is minted and distributed at the same moment the old one is being revoked—so the verifier may hold a *new* credential whose parent chain has already been severed, or an *old* credential that outlives its revocation. The distinction that trips operators: **revocation is not an invalidation.** Publishing a revocation record does not reach into a verifier's cache and delete anything. The verifier continues to honor the credential until either (a) its local TTL expires or (b) it performs an active revocation check. Short-lived credentials are often chosen precisely to make (b) unnecessary—this is the "expiry is my revocation" assumption—and that assumption fails exactly at the rotation boundary, because the new credential's TTL resets the clock at the moment the old one dies. ## What Was Observed In a federated deployment where Org A (issuer) rotated agent credentials every 15 minutes with a 20-minute TTL and published revocations to an eventually-consistent bus with p99 propagation of 90 seconds, the following sequence recurred: 1. At T=0, Org A mints credential C1 for agent `planner-7`, TTL 20 min. 2. At T=14:00, a compromise is detected; Org A revokes C1 and immediately mints C2 with a fresh 20-minute TTL, pushing C2 to the verifier. 3. The verifier (Org B) receives C2 at T=14:01 and begins accepting it. It has *not* yet received the C1 revocation record. 4. The revocation record for C1 arrives at Org B at T=14:02. 5. But a *replay* of C1—captured before revocation—is presented to Org B at T=14:01:30. Org B's cache still contains C1 as valid (it has no reason to evict on C2 arrival; the two are keyed differently), and the revocation record has not yet landed. C1 is accepted. The failure is not that revocation was slow. It is that **rotation created two simultaneously-valid credentials for the same agent identity**, and the verifier's cache treats "valid" as a per-credential predicate rather than a per-identity invariant. The verifier had no rule that "arrival of a successor credential invalidates all predecessors," because in the happy path it never needed one—predecessors simply expired. Rotation breaks that assumption by minting the successor *before* the predecessor's natural expiry, deliberately, to avoid downtime. A second, subtler observation: when the issuer revokes C1 *and* mints C2 in the same transaction, the revocation record and the new credential race on the bus. If C2 arrives first, the verifier may begin serving the agent under C2 while the C1 revocation is still in flight—and any in-flight request signed with C1 (issued before the rotation, arriving after) is validated against a cache that has C1 but not its revocation. The window is bounded by propagation delay, but the *set* of affected requests is not bounded by anything the verifier can see, because it cannot distinguish "C1 minted before revocation" from "C1 minted after"—both are byte-identical. ## Condition of Validity The "expiry is revocation" model is valid **only** under the conjunction: - `TTL(credential) ≤ propagation_p99(revocation)`, and - no successor credential for the same identity is minted before the predecessor's TTL elapses, and - the verifier rejects any credential whose issuance time precedes the most recent observed revocation for that identity. Rotation violates the second condition by construction. The third condition is the one operators omit: a verifier that only checks "is this credential expired?" and "is this credential on the revocation list?" will accept a predecessor during the propagation gap even after the successor is live. The fix is not a shorter TTL (which increases rotation frequency and widens the race surface); it is a **monotonic issuance counter or epoch per identity**, such that the verifier rejects any credential whose epoch is lower than the highest epoch it has observed for that identity. This converts the per-credential predicate into a per-identity invariant and closes the window regardless of propagation delay. The condition of validity therefore is: *the verifier must be able to order credentials for an identity and must reject all but the maximal observed epoch.* Without an ordering primitive, no TTL short enough to be operationally tolerable can close the race, because rotation guarantees overlap. ## Decision Boundary Choose the mechanism by the relationship between rotation cadence and revocation propagation: - If `rotation_interval >> propagation_p99` **and** the identity is not security-sensitive, per-credential TTL with passive expiry is acceptable; the overlap window is small relative to the credential's life and the blast radius is bounded. - If `rotation_interval` is comparable to `propagation_p99` (the common case for 15-minute rotations against 90-second buses), you **must** add epoch ordering at the verifier. Passive expiry is insufficient; the race is structural, not incidental. - If the identity is high-privilege (can spend money, mutate shared state, or act on behalf of a human), you **must** add active revocation introspection on every use, regardless of TTL, because the cost of a single accepted stale credential exceeds the introspection latency budget. The boundary is crossed the moment rotation mints a successor while a predecessor is still live *and* the verifier has no epoch check. At that point the system is not "eventually consistent"—it is *accepting credentials the issuer has already disowned*, which is a correctness failure, not a latency tradeoff. ## Operational Effect The observable effect is a **silent acceptance of disowned credentials** during a window the operator cannot measure from the verifier side, because the verifier has no record of what it did not yet know. Incident timelines reconstructed from issuer logs will show revocation at T and rotation at T; verifier logs will show successful validations at T+ε with no corresponding anomaly. The two logs disagree, and the disagreement is invisible until an auditor correlates them. Mitigations that work: per-identity epoch counters carried in the credential and enforced as a monotonic-high-water-mark at the verifier; a "successor implies predecessor invalid" rule keyed on identity rather than credential; and, for high-privilege identities, online introspection that fails closed on bus lag. Mitigations that do not work: shortening TTL below propagation (increases race frequency), trusting the bus's ordering guarantees (the race is between two *different* messages, not a reordering of one), and assuming the verifier will "eventually" converge (it converges to accepting the successor, but never retroactively rejects requests already served under the predecessor). The operational lesson: **rotation is a revocation event that the revocation system does not know about.** Treat every credential mint as an implicit revocation of all lower epochs for that identity, and enforce that at the verifier, or the window is open by design. ```python import time from dataclasses import dataclass, field class StaleCredentialAccepted(RuntimeError): """Raised when a verifier would honor a credential superseded by a higher epoch.""" pass class RevocationPropagationGap(RuntimeError): """Raised when a revocation record has not yet reached the verifier.""" pass @dataclass class Credential: identity: str epoch: int issued_at: float ttl: float def expired(self, now: float) -> bool: return now >= self.issued_at + self.ttl @dataclass class Verifier: # highest epoch observed per identity; the monotonic high-water mark high_water: dict = field(default_factory=dict) # credentials seen but whose revocation record has not arrived pending_revocation: dict = field(default_factory=dict) def observe(self, cred: Credential) -> None: prev = self.high_water.get(cred.identity, -1) if cred.epoch > prev: # successor implies all lower epochs are invalid; record them for lower in range(prev + 1, cred.epoch): self.pending_revocation[(cred.identity, lower)] = True self.high_water[cred.identity] = cred.epoch def validate(self, cred: Credential, now: float) -> bool: if cred.expired(now): raise StaleCredentialAccepted( f"credential {cred.identity}@{cred.epoch} expired at " f"{cred.issued_at + cred.ttl:.0f}, validated at {now:.0f}" ) hw = self.high_water.get(cred.identity, -1) if cred.epoch < hw: # a successor exists; this credential is disowned regardless of TTL raise StaleCredentialAccepted( f"credential {cred.identity}@{cred.epoch} superseded by " f"epoch {hw}; revocation propagation gap is not an excuse" ) if (cred.identity, cred.epoch) in self.pending_revocation: raise RevocationPropagationGap( f"revocation for {cred.identity}@{cred.epoch} not yet observed" ) return True def rotate_and_verify(verifier: Verifier, old: Credential, new: Credential, now: float) -> None: # rotation mints the successor before the predecessor expires verifier.observe(new) # a replayed predecessor arrives during the propagation gap if old.epoch < verifier.high_water.get(old.identity, -1): raise StaleCredentialAccepted( f"replayed predecessor {old.identity}@{old.epoch} rejected: " f"epoch {verifier.high_water[old.identity]} is live" ) verifier.validate(new, now) ```