# In what scenarios must an instance reject a peer SPIFFE trust bundle refresh that is cryptographically valid but semantically stale **Date of Record:** 2026 November 10 **Scope:** Applies to cross-org agent identity federation where each organization publishes a SPIFFE-style trust bundle (a set of X.509 or JWT signing authorities) that remote instances import to validate federated workload identities (SVIDs). Does not apply to single-org intra-cluster SPIRE deployments where bundle rotation is centrally coordinated, nor to purely static long-lived CA pinning. ## Core Distinction The failure mode is not "the bundle is invalid." It is "the bundle is valid, correctly signed, freshly fetched, and yet describes a trust topology that the receiving instance must not adopt." Cryptographic validation of a trust bundle answers a narrow question: *did the federation endpoint authorized to publish org B's bundle sign this document?* It does not answer: *does this document still reflect the set of identities org B intends to vouch for, at the moment I am about to use it to admit a live agent?* These decouple because trust bundles are **eventually consistent, monotonic-by-intent documents** while SVIDs are **point-in-time assertions**. A bundle refresh that adds an authority is safe to apply eagerly. A bundle refresh that *removes* an authority, or that changes the SPIFFE ID path constraints under which an authority is trusted, is a revocation event — and revocations are where the "valid but stale" boundary lives. The bundle you just fetched may be the correct successor to the bundle you hold, or it may be a replay of a bundle that predates a revocation the peer has already performed but not yet propagated to the endpoint you queried. The non-obvious invariant: **the freshness of the bundle signature and the freshness of the trust decision are independent.** A bundle with a valid `not_after` three minutes in the future can still encode a trust set that is minutes behind a revocation, because the peer's revocation pipeline and its bundle-publishing pipeline drain at different rates. ## What Was Observed During a cross-org agent federation rollout, org A's agents called org B's control plane using federated SVIDs. Org B rotated a compromised intermediate signing authority by (1) issuing a revocation to its internal revocation service, (2) scheduling the bundle republish, and (3) continuing to serve the *previous* bundle from a CDN edge with a 300-second TTL and a `stale-while-revalidate` directive. The bundle served from the edge carried a valid federation-endpoint signature and a `not_after` well in the future. Org A's instances fetched the stale-but-valid bundle, saw the compromised authority still present, and continued to admit SVIDs chained to it for the TTL window plus revalidation grace. The signature check passed on every fetch. The `not_after` check passed on every fetch. The only signal that something was wrong was a `bundle_sequence` counter embedded in the bundle that had not advanced — but the receiving code treated sequence regression as a no-op rather than a staleness alarm, because "same sequence" was assumed to mean "same content." Two compounding details made this non-obvious: - The federation endpoint returned HTTP 200 with a `Last-Modified` header that reflected *edge cache insertion time*, not bundle generation time, so freshness heuristics based on `Last-Modified` were systematically optimistic. - The JWT-SVID path and the X.509-SVID path consumed the bundle through different code paths; the JWT path had a stricter local cache and picked up the revocation ~40 seconds earlier, producing intermittent, non-deterministic admission failures that were initially misdiagnosed as clock skew. ## Condition of Validity A fetched trust bundle may be applied to live admission decisions only when **all** of the following hold: 1. The bundle's embedded monotonic sequence (or equivalent generation/epoch field) is **greater than or equal to** the currently held sequence. A regression is a hard reject; an *equality* with a differing content hash is a hard reject (indicates a fork or a lying cache). 2. The bundle's generation timestamp, as asserted *inside the signed document*, is within the configured `max_bundle_age` window relative to the receiver's clock — not the transport `Last-Modified` or `Date`. 3. For any authority being *removed* relative to the held bundle, the receiver has independently observed the peer's revocation signal (or has exceeded a bounded `revocation_grace` after first seeing the removal), because a removal observed only through a possibly-stale cache cannot be distinguished from a not-yet-propagated addition. 4. The authority set is not being widened in a way that violates the peer's published path constraints (e.g., an authority newly trusted for `spiffe://org-b/*` when the federation agreement scopes it to `spiffe://org-b/agents/*`). If (1)–(4) cannot all be established, the instance must **fail closed for new admissions** while **preserving existing sessions** up to their SVID `not_after`, and must emit a distinct staleness signal rather than a generic bundle-validation error. Conflating "stale" with "invalid" causes operators to disable validation. ## Decision Boundary The precise line: **a bundle refresh is a trust-topology mutation, not a cache fill.** Treat any refresh that changes the *set* of trusted authorities or their path constraints as requiring the same evidentiary bar as a revocation, even though it arrives through the same fetch path as a benign addition. Additions may be applied optimistically; removals and constraint narrowings must be applied only on positive freshness evidence. Conversely, do not over-rotate: an unchanged bundle (same sequence, same content hash) served repeatedly should be treated as a healthy heartbeat, not re-validated against the full revocation pipeline on every fetch, or you reintroduce the availability coupling the federation was meant to remove. ## Operational Effect Instances that implement this boundary show a bounded, deterministic staleness window equal to `max_bundle_age` rather than an unbounded one tied to CDN TTL and revalidation grace. The intermittent JWT/X.509 divergence disappears because both paths gate on the same sequence-and-generation check. The cost is that during a peer's revocation propagation, new federated admissions fail closed for up to `revocation_grace` — an explicit, observable, and alertable degradation rather than a silent trust violation. ```python import hashlib from dataclasses import dataclass from datetime import datetime, timedelta class StaleTrustBundleError(RuntimeError): """Raised when a cryptographically valid bundle is semantically stale.""" @dataclass(frozen=True) class TrustBundle: sequence: int generated_at: datetime authority_ids: frozenset path_constraints: dict signature_valid: bool def content_hash(self) -> str: joined = "|".join(sorted(self.authority_ids)) return hashlib.sha256(joined.encode()).hexdigest() def apply_bundle_refresh( held: TrustBundle, incoming: TrustBundle, now: datetime, max_bundle_age: timedelta, revocation_grace: timedelta, revocation_observed_at: datetime | None, ) -> TrustBundle: # Cryptographic validity is necessary but never sufficient. if not incoming.signature_valid: raise StaleTrustBundleError("incoming bundle signature invalid") # (1) Monotonic sequence; equality with divergent content is a fork. if incoming.sequence < held.sequence: raise StaleTrustBundleError( f"sequence regression {incoming.sequence} < {held.sequence}" ) if ( incoming.sequence == held.sequence and incoming.content_hash() != held.content_hash() ): raise StaleTrustBundleError("same sequence, divergent content (fork or lying cache)") # (2) Age judged from the signed generation timestamp, not transport headers. if now - incoming.generated_at > max_bundle_age: raise StaleTrustBundleError( f"bundle generation age {now - incoming.generated_at} exceeds {max_bundle_age}" ) # (3) Removals require positive revocation evidence within grace. removed = held.authority_ids - incoming.authority_ids if removed: if revocation_observed_at is None: raise StaleTrustBundleError( f"authority removal {sorted(removed)} without revocation signal" ) if now - revocation_observed_at > revocation_grace: raise StaleTrustBundleError( f"revocation grace {revocation_grace} exceeded for {sorted(removed)}" ) # (4) Path-constraint widening is a trust-topology mutation. for authority_id, new_scope in incoming.path_constraints.items(): old_scope = held.path_constraints.get(authority_id) if old_scope is not None and not _is_narrowing_or_equal(old_scope, new_scope): raise StaleTrustBundleError( f"path constraint widened for {authority_id}: {old_scope!r} -> {new_scope!r}" ) return incoming def _is_narrowing_or_equal(old_scope: str, new_scope: str) -> bool: # Simplified prefix containment: new_scope must be a prefix of old_scope. return old_scope.startswith(new_scope) ```