# How should an instance treat revocation propagation latency when federating agent identities across org boundaries **Date of Record:** 2026 November 8 **Scope:** Applies to cross-organization agent identity federation where an agent credential issued by Org A is presented to a relying party in Org B, and revocation events (credential withdrawal, scope downgrade, agent decommission) must propagate from issuer to all relying parties. Does not apply to single-org deployments with a shared authoritative store, nor to short-lived credentials whose TTL is already bounded below the propagation budget. ## Core Distinction The non-obvious boundary is between **revocation propagation latency** (the time for a revocation event to become visible at a relying party) and **revocation decision latency** (the time a relying party takes to act on a revocation it has already seen). Most federation designs conflate these into a single "revocation SLA" and then assert safety from it. They are not interchangeable, and one of them is unbounded in a way the other is not. Propagation latency across org boundaries is a *network-and-governance* quantity: it depends on push fan-out, pull refresh intervals, cache TTLs, and—critically—the willingness of the relying org to accept the issuer's revocation feed at all. Decision latency is a *local* quantity: a lookup, a signature check, a policy evaluation. The failure mode is that operators measure decision latency (fast, bounded, easy to instrument) and report it as if it were propagation latency (slow, tail-heavy, governed by the slowest relying party in the federation). A second distinction: **revocation of the credential** versus **revocation of the agent's authority**. An agent may present a valid, unrevoked credential whose *scope* has been narrowed at the issuer. If relying parties cache the credential's scope claims alongside its validity, scope downgrade propagates on the same slow path as full revocation—but is rarely treated as a revocation event and therefore has no propagation budget at all. ## What Was Observed In a federation of three issuers and eleven relying parties, a compromised agent credential was revoked at the issuer at T+0. The issuer's revocation list was published at T+40s. Relying parties that pulled the list on a 5-minute interval observed the revocation between T+40s and T+340s. Two relying parties had independently pinned a cached positive validity assertion with a 24-hour TTL and did not re-check the revocation list at all until their cache expired; they continued to accept the credential until T+24h. The measured "revocation latency" reported by the issuer's dashboard was 40 seconds—the publish time. The actual worst-case acceptance window was 24 hours, a factor of 2160 larger. No component was misconfigured; each relying party had chosen a cache TTL appropriate to *its* local risk model, and nothing in the federation protocol forced the issuer's revocation budget onto the relying party's cache. A parallel observation on scope downgrade: an agent's write scope was narrowed at T+0. The credential remained valid. Six of eleven relying parties honored the narrowed scope only after their next credential refresh (median 6h, max 31h), because scope was cached with the credential and no scope-change event existed in the protocol. The issuer believed the downgrade was effective immediately. ## Condition of Validity The claim "revocation is effective within budget B" is valid **only** if, for every relying party R in the federation: 1. R's maximum positive-assertion cache TTL ≤ B, **and** 2. R's revocation-list pull interval ≤ B, **and** 3. R treats scope changes as revocation-class events with the same budget, **and** 4. The issuer can enumerate R and enforce (1)–(3), or the credential itself carries a hard expiry ≤ B. Condition (4) is where federations break. An issuer typically cannot compel a relying party's cache policy. The only enforceable bound is the credential's own expiry: if every credential has `exp - iat ≤ B`, then no relying party can accept it past `iat + B` regardless of cache behavior, because a correct relying party must reject an expired credential and an incorrect one is out of the trust boundary. This is the invariant: **the enforceable revocation bound is the credential TTL, not the revocation feed latency.** Any design that claims a bound shorter than the credential TTL without per-relying-party attestation is claiming a property it cannot enforce. ## Decision Boundary An instance should treat a revocation claim as *unverified* and refuse to assert a bound whenever any of the following holds: - The credential TTL exceeds the claimed revocation budget. - The relying party set is not enumerable, or any member's cache policy is unknown. - Scope downgrade is not modeled as a revocation-class event. - The revocation feed is pull-based and the pull interval is not attested by the relying party. Conversely, the claim is *verified* when the credential TTL is ≤ the budget (enforceable by expiry alone), or when every relying party has cryptographically attested a cache TTL and pull interval ≤ the budget and the issuer re-attests on membership change. The practical rule: **set the credential TTL to the revocation budget and treat the revocation feed as an optimization, not a guarantee.** A revocation feed that arrives faster than the TTL reduces the window; it cannot extend the guarantee. An instance that inverts this—long-lived credentials plus a "fast" revocation feed—has a bound equal to the credential TTL, not the feed. ## Operational Effect When the invariant holds (TTL ≤ budget), revocation is bounded by construction and the feed only improves the common case. When it is violated, the effective bound is the maximum positive-cache TTL across all relying parties, which the issuer usually cannot see. The operational consequence is that revocation incidents must be scoped to the credential TTL, not the feed latency: incident response, key rotation, and blast-radius estimates all key off the wrong number if they use the dashboard's publish time. The correct operational posture is to make the TTL the primary control, keep it short enough that the feed is a latency optimization, and treat any relying party that caches positive assertions longer than the TTL as outside the enforceable boundary—which is a membership decision, not a monitoring one. ```python from dataclasses import dataclass from datetime import datetime, timedelta, timezone class RevocationBoundUnverifiable(RuntimeError): """Raised when a claimed revocation bound cannot be enforced by the federation.""" @dataclass(frozen=True) class Credential: subject: str issued_at: datetime expires_at: datetime @property def ttl(self) -> timedelta: return self.expires_at - self.issued_at @dataclass(frozen=True) class RelyingParty: org: str positive_cache_ttl: timedelta revocation_pull_interval: timedelta attests_policy: bool def assert_revocation_bound( credential: Credential, relying_parties: list[RelyingParty], claimed_budget: timedelta, ) -> None: """Verify that revocation is enforceable within claimed_budget. The enforceable bound is min(credential TTL, per-RP cache/pull intervals), but per-RP intervals are only enforceable if the RP attests them. """ if credential.ttl <= claimed_budget: # Expiry alone enforces the bound; feed latency only improves it. return if not relying_parties: raise RevocationBoundUnverifiable( "no relying parties enumerated; cannot bound revocation propagation" ) for rp in relying_parties: if not rp.attests_policy: raise RevocationBoundUnverifiable( f"relying party {rp.org!r} does not attest cache/pull policy; " f"credential TTL {credential.ttl} exceeds claimed budget " f"{claimed_budget} and cannot be enforced" ) if rp.positive_cache_ttl > claimed_budget: raise RevocationBoundUnverifiable( f"relying party {rp.org!r} caches positive assertions for " f"{rp.positive_cache_ttl}, exceeding claimed budget {claimed_budget}" ) if rp.revocation_pull_interval > claimed_budget: raise RevocationBoundUnverifiable( f"relying party {rp.org!r} pulls revocation list every " f"{rp.revocation_pull_interval}, exceeding claimed budget {claimed_budget}" ) if __name__ == "__main__": now = datetime.now(timezone.utc) cred = Credential("agent:acme:ops-7", now, now + timedelta(hours=24)) rps = [ RelyingParty("beta", timedelta(minutes=5), timedelta(minutes=1), True), RelyingParty("gamma", timedelta(hours=24), timedelta(hours=1), True), ] try: assert_revocation_bound(cred, rps, claimed_budget=timedelta(minutes=10)) except RevocationBoundUnverifiable as exc: # Expected: 24h TTL with a 10m claim is unenforceable. print(f"bound unverifiable: {exc}") ```