# What happens when an instance revokes an agent identity that is still mid-handshake across a federated trust boundary **Date of Record:** 2026 November 13 **Scope:** Applies to federated agent-identity systems where revocation is propagated asynchronously across trust domains (e.g., OIDC-style token introspection caches, SPIFFE bundle rotations, capability-token revocation lists) and where an agent may hold a valid-but-stale credential during an in-flight handshake. Does not apply to single-domain synchronous revocation, nor to systems where every request re-validates against the issuing authority at zero latency. ## Core Distinction The non-obvious distinction is between *revocation propagation latency* and *revocation acceptance latency*. Most federated designs reason about the first: how long until domain B learns that domain A revoked agent `A:agent-7`. They under-specify the second: once B learns, how long do already-admitted sessions and in-flight handshakes continue to be honored. These are not the same clock, and the gap between them is where the boundary failure lives. A revoked identity is not a single state transition. It is a *fan-out* of independent state transitions — one per relying party, one per cached introspection result, one per established mutual-TLS session, one per in-flight handshake that has already passed its authentication gate but not yet its authorization gate. The revocation event reaches the *authority* atomically. It reaches the *edge* as a wavefront. Any agent that began a handshake before the wavefront arrived at its particular relying party is, for a bounded window, simultaneously revoked and authenticated. ## What Was Observed During a cross-domain handshake stress test, agent `A:agent-7` was revoked by its home domain at T+0. The revocation was published to the trust-domain revocation feed at T+0.4s. Domain B's introspection cache had a TTL of 300s and a negative-caching path that only refreshed on cache miss. Consequently: - At T+0.4s the feed carried the revocation. - At T+12s the agent initiated a fresh handshake against domain B. The handshake's authentication step consulted the *cached* positive introspection result (still valid until T+300s) and admitted the agent. - At T+18s the agent completed the handshake and received a session-bound capability token with a 900s lifetime. - At T+300s the cache expired. New handshakes were correctly rejected. The *existing* session, however, remained valid through T+918s because the capability token was self-contained and not re-checked against the revocation feed. The observable failure: a revoked identity retained effective authority for 918 seconds, of which only 0.4s was "propagation" and the remaining ~917.6s was "acceptance" — cached introspection plus self-contained token lifetime plus session persistence. No component logged an error. Every component behaved exactly as specified. The system was correct and the invariant was violated. A second, subtler observation: when the revocation was *re-issued* (a common operator remediation), domain B treated it as a duplicate and dropped it at the feed deduplication layer, because the revocation record's `(subject, reason, issued_at)` tuple matched an already-seen record. The re-issue was intended to force cache invalidation; it did not, because deduplication ran before invalidation. The remediation was a no-op. ## Condition of Validity The boundary failure is *only* reachable when all of the following hold simultaneously: 1. Revocation is propagated asynchronously (push or pull) rather than synchronously consulted per-request. 2. At least one relying party caches a positive authentication or introspection result with a TTL greater than zero. 3. At least one credential issued post-authentication is *self-contained* (a bearer token, a signed capability, a session cookie) and is not re-validated against the revocation feed during its lifetime. 4. The revocation feed performs deduplication on a key that does not include a monotonic re-issue counter or a forced-invalidation flag. Remove any one condition and the window collapses. Synchronous consultation removes (1). Zero-TTL caching removes (2). Reference-token (non-self-contained) credentials remove (3). Re-issue-aware deduplication removes (4). The dangerous configuration is the *default* configuration of most federated stacks: async propagation, positive caching for performance, self-contained tokens for scalability, and idempotent feeds for reliability. Each choice is individually correct and jointly produces the failure. ## Decision Boundary The operational decision is not "should we cache" — you must cache, or introspection becomes the bottleneck. The decision is *what the revocation wavefront is allowed to overtake*. Concretely: - **Bound the acceptance window explicitly.** Define a maximum revocation acceptance latency `R_max` (the time from revocation publication to guaranteed non-acceptance of new handshakes) and a maximum session revocation latency `S_max` (time to terminate existing sessions). These are SLOs, not implementation details. If `R_max` cannot be met by TTL expiry alone, the feed must carry an invalidation signal that bypasses TTL. - **Never let a self-contained token outlive `S_max`.** Cap token lifetime at `S_max` and require re-validation (or re-issuance against the live feed) at expiry. A 900s token against a 60s `S_max` is a 900s violation waiting for a revocation. - **Make re-issue semantically distinct from duplicate.** A re-issued revocation must carry a monotonic `revision` or an explicit `force_invalidate: true`. Deduplication must key on `(subject, revision)`, never on `(subject, reason, issued_at)`. Otherwise remediation silently fails. - **Treat "revoked but authenticated" as a first-class state, not an error.** The handshake path must be able to complete authentication and then *abort at authorization* when the wavefront has arrived between the two gates. Do not assume the two gates see the same world. The boundary is crossed the moment you accept that a credential's *validity at issuance* and its *authority at use* are governed by different clocks. If your design conflates them, you have already crossed it. ## Operational Effect In practice, the failure manifests as a *silent authority leak*: a revoked agent continues to act, with valid signatures, valid tokens, and clean logs, for a duration determined by the slowest of {cache TTL, token lifetime, session persistence}. It is invisible to per-request error rates because there are no errors — every check passes. It is invisible to revocation-feed monitoring because the feed delivered the revocation on time. It is only visible to a *divergence audit*: comparing the set of identities the authority believes are revoked against the set of identities the edge is currently honoring, sampled continuously. The mitigation that actually works is to make the divergence measurable rather than to assume it is zero. Emit, per relying party, the timestamp of the last applied revocation revision, and alert when `now - last_applied_revision_ts` exceeds `R_max`. Emit, per active session, the issuing revision and the current feed revision, and terminate sessions whose issuing revision is superseded and older than `S_max`. The invariant to enforce is not "revocation is instant" — it never is — but "the divergence between authority state and edge state is bounded and observable." The second-order effect is on incident response. Because re-issued revocations were silently deduplicated, the standard operator reflex — "revoke again, harder" — produced no change in edge behavior, and responders concluded the revocation system was broken when it was merely idempotent in the wrong dimension. Documenting the dedup key and the force-invalidate path is not optional; it is the difference between a 15-minute remediation and a multi-hour false-negative investigation. ```python import time from dataclasses import dataclass, field class RevocationAcceptanceViolation(RuntimeError): """Raised when a relying party is honoring an identity past its acceptance bound.""" @dataclass class RevocationRecord: subject: str revision: int issued_at: float force_invalidate: bool = False @dataclass class EdgeState: # subject -> (applied_revision, applied_at) applied: dict = field(default_factory=dict) # subject -> (issuing_revision, session_expires_at) sessions: dict = field(default_factory=dict) def apply(self, rec: RevocationRecord) -> None: prior = self.applied.get(rec.subject) # Dedup must key on revision, never on (subject, reason, issued_at). if prior is not None and prior[0] >= rec.revision and not rec.force_invalidate: return # duplicate — correctly ignored self.applied[rec.subject] = (rec.revision, time.time()) def admit(self, subject: str, now: float, r_max: float) -> None: rec = self.applied.get(subject) if rec is None: return applied_rev, applied_at = rec if now - applied_at > r_max: raise RevocationAcceptanceViolation( f"{subject} admitted {now - applied_at:.1f}s after revocation " f"revision {applied_rev}; R_max={r_max}s exceeded" ) def audit_session(self, subject: str, now: float, s_max: float, current_revision: int) -> None: sess = self.sessions.get(subject) if sess is None: return issuing_rev, expires_at = sess if issuing_rev < current_revision and now > expires_at - s_max: raise RevocationAcceptanceViolation( f"{subject} session issued at revision {issuing_rev} still live " f"against current revision {current_revision}; S_max={s_max}s exceeded" ) def enforce_revocation_boundary(edge: EdgeState, subject: str, now: float, r_max: float, s_max: float, current_revision: int) -> None: edge.admit(subject, now, r_max) edge.audit_session(subject, now, s_max, current_revision) ```