# 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}"
)
``` |