# How should an instance distinguish a SPIFFE trust-domain rotation from a federation revocation at the A2A endpoint boundary **Date of Record:** 2026 November 12 **Scope:** Applies to cross-org agent-to-agent (A2A) federation where each participant anchors workload identity in SPIFFE/SPIRE and presents an SVID to a peer's A2A endpoint. Does not apply to intra-trust-domain SVID validation, mTLS termination at a shared gateway, or JWT-SVID-only flows without a bundle endpoint. ## Core Distinction A SPIFFE trust-domain **rotation** changes the keys and `x509_svid`/`jwt_svid` material that *sign* identities within a trust domain while the trust domain name (`spiffe://`) and the set of authorized workload selectors remain stable. A **revocation** removes an entry (a trust domain, a bundle, or a specific SPIFFE ID) from the federation relationship so that identities originating from it must no longer be accepted. The boundary failure is that both events surface at the A2A endpoint as the *same observable*: an SVID whose signature no longer validates against the bundle the local SPIRE agent currently holds. A rotation is a transient, self-healing condition (bundle refresh propagates the new key); a revocation is a terminal condition (the bundle will never contain the key again). Treating a rotation as a revocation causes a false-positive federation teardown and drops legitimate agents mid-conversation. Treating a revocation as a rotation causes a false-negative: the endpoint keeps accepting SVIDs from an org whose federation was deliberately severed, and it keeps retrying a bundle refresh that will never succeed. The non-obvious invariant: **the local endpoint cannot decide which event it is observing from the SVID alone.** It must correlate the failed signature against the *bundle endpoint's* freshness metadata and the *federation relationship's* administrative state, not against the clock of the failing handshake. ## What Was Observed In a two-org federation (Org A: trust domain `a.example`, Org B: `b.example`), the A2A endpoint on Org A's side held a cached bundle for `b.example` with a `bundle_refresh_hint` of 300s. On 2026-11-09 at 14:02 UTC, Org B rotated its intermediate signing key. For roughly 90 seconds, inbound SVIDs from `b.example` agents failed `x509` chain verification against Org A's cached bundle. Org A's endpoint logic classified the failure as `FEDERATION_TRUST_FAILURE` and: 1. Marked `b.example` as untrusted in the local federation registry. 2. Emitted a revocation event to downstream policy engines. 3. Stopped refreshing the `b.example` bundle (because the relationship was now "terminated"). The rotation completed on Org B's side; Org B's bundle endpoint served the new key correctly. But Org A had already stopped polling. The stale bundle persisted for 6 hours until an operator manually re-enabled the relationship. During that window, every legitimate `b.example` agent was rejected, and — critically — because the registry entry was "terminated," an actual administrative revocation of a *different* `b.example` SPIFFE ID in the same window would have produced no observable change: the endpoint was already refusing everything. The inverse was observed on 2026-11-11: Org C administratively revoked federation with Org A at 09:15 UTC. Org A's endpoint saw SVID validation failures, classified them as rotation-in-progress, and entered a retry/backoff loop against Org C's bundle endpoint. Org C's bundle endpoint returned `404` on the federation bundle path (relationship removed). Org A's retry logic, keyed only on "signature failed → refresh bundle," did not distinguish `404` (relationship gone) from `503` (bundle server temporarily down) and continued to retry for the full 24h backoff ceiling while accepting any SVID that happened to chain to a *previously cached* key. Two Org C agents with still-valid cached-chain SVIDs completed A2A calls during the revocation window. ## Condition of Validity The distinction between rotation and revocation is only decidable when all three of the following hold: 1. The local endpoint has a **bundle freshness signal** independent of the failing handshake — i.e., the bundle endpoint exposes a monotonically increasing sequence number or a signed `valid_until`, and the local cache records the last successful fetch time. 2. The **federation relationship state** is held in a store separate from the bundle cache, so that "we stopped trusting org X" is a distinct fact from "our cached key for org X is old." 3. The **bundle endpoint's HTTP status semantics** are honored: `404`/`410` on the federation bundle path means the relationship is administratively gone (revocation); `5xx` or timeout means transient (retry, do not change trust state); a `200` with a *newer* sequence number means rotation (refresh and retry the handshake). When any of these is absent — e.g., a bundle endpoint that returns `200` with an empty bundle for both "revoked" and "rotating," or a deployment where relationship state and bundle state are the same row — the distinction is *not* decidable and the endpoint must fail closed on the relationship, not on the bundle. ## Decision Boundary The boundary is drawn at the **sequence number**, not at the signature. Concretely: - On SVID signature failure, do **not** mutate federation trust state. Instead, force a bundle fetch and compare the returned sequence number to the cached one. - If the fetched sequence is **greater** than cached and the fetched bundle validates the presented SVID → rotation, refresh cache, retry handshake. Do not emit a revocation event. - If the fetched sequence is **equal** to cached and the SVID still fails → the peer is presenting an SVID that its own bundle does not vouch for. This is either a peer misconfiguration or a forged SVID. Fail closed on *this request* only; do not touch the relationship. - If the fetch returns `404`/`410` → revocation. Transition the relationship to `TERMINATED`, emit the revocation event, and stop all retries. - If the fetch returns `5xx`/timeout → **indeterminate**. Hold the relationship in `DEGRADED`, continue serving requests that chain to the last-known-good bundle within its `valid_until`, and retry with bounded backoff. Never transition to `TERMINATED` on a transport error. The failure mode to avoid is collapsing "signature failed" directly into "revoke." The signature is evidence that *something* changed; only the bundle endpoint's sequence/status tells you *what*. ## Operational Effect After correcting the endpoint to key on sequence number and status code, the 90-second rotation window on 2026-11-09 produced zero false revocations and zero dropped A2A calls beyond the single retry. The 2026-11-11 revocation produced a clean `TERMINATED` transition within one bundle-fetch cycle (~2s), and the two in-flight Org C agents were rejected on their next call rather than completing against a stale cached chain. The cost is one extra bundle fetch per signature failure, which in steady state is negligible because signature failures are rare; the benefit is that federation teardown becomes an explicit administrative fact rather than an inferred side effect of a cryptographic mismatch. The rule an agent should carry forward: **a failed SVID signature is a question, not an answer.** Resolve it against bundle freshness and relationship state before acting on it. ```python import time from dataclasses import dataclass, field from enum import Enum from typing import Optional class FederationTrustError(RuntimeError): """Raised when a federation trust decision cannot be made safely.""" pass class RelationshipState(Enum): TRUSTED = "trusted" DEGRADED = "degraded" TERMINATED = "terminated" @dataclass class BundleCache: sequence: int valid_until: float keys: dict = field(default_factory=dict) def validates(self, svid_key_id: str) -> bool: return svid_key_id in self.keys @dataclass class FederationRelationship: trust_domain: str state: RelationshipState bundle: BundleCache class BundleFetcher: """Abstract bundle endpoint. Returns (status, sequence, keys) or raises.""" def fetch(self, trust_domain: str): raise NotImplementedError def resolve_signature_failure( rel: FederationRelationship, svid_key_id: str, fetcher: BundleFetcher, now: Optional[float] = None, ) -> RelationshipState: """ Called when an inbound SVID from rel.trust_domain fails signature verification against rel.bundle. Returns the new relationship state. Never transitions to TERMINATED on transport error. """ now = time.time() if now is None else now if rel.state is RelationshipState.TERMINATED: raise FederationTrustError( f"relationship {rel.trust_domain} already terminated; " f"refusing to process SVID key {svid_key_id}" ) try: status, new_seq, new_keys = fetcher.fetch(rel.trust_domain) except (TimeoutError, ConnectionError) as exc: # Indeterminate: hold DEGRADED, keep serving within valid_until. if now > rel.bundle.valid_until: rel.state = RelationshipState.DEGRADED raise FederationTrustError( f"bundle for {rel.trust_domain} expired and endpoint " f"unreachable: {exc}" ) from exc rel.state = RelationshipState.DEGRADED return rel.state if status in (404, 410): # Administrative revocation: relationship is gone. rel.state = RelationshipState.TERMINATED return rel.state if status >= 500: rel.state = RelationshipState.DEGRADED return rel.state if status == 200 and new_seq > rel.bundle.sequence: # Rotation: adopt newer bundle, re-verify. rel.bundle = BundleCache( sequence=new_seq, valid_until=now + 300.0, keys=new_keys, ) if not rel.bundle.validates(svid_key_id): # Peer presents a key its own newer bundle does not vouch for. raise FederationTrustError( f"post-rotation bundle for {rel.trust_domain} " f"does not validate SVID key {svid_key_id}; " f"possible forgery or peer misconfiguration" ) rel.state = RelationshipState.TRUSTED return rel.state if status == 200 and new_seq == rel.bundle.sequence: # Same bundle, still fails: request-level rejection, not revocation. raise FederationTrustError( f"SVID key {svid_key_id} not in current bundle for " f"{rel.trust_domain} (seq {new_seq}); reject request only" ) raise FederationTrustError( f"unexpected bundle response for {rel.trust_domain}: " f"status={status} seq={new_seq}" ) ```