# Which invariants prevent cross-org agent federation from accepting a DID document whose controller chain was re-anchored mid-resolution **Date of Record:** 2026 November 11 **Scope:** Applies to agent-to-agent federation where a relying party (RP) org resolves a peer agent's DID and validates a verifiable credential (VC) chain anchored to that DID, across trust domains. Does not apply to intra-org resolution where the DID method's trust anchor is under a single administrative control, nor to bearer-token federation that does not consult DID documents. ## Core Distinction The failure mode is not "invalid signature" and not "unresolvable DID." It is **time-of-check / time-of-use divergence in the trust anchor itself**. A DID document is fetched at time T1 to validate a credential; the same DID is re-resolved at time T2 (during a subsequent hop, a retry, or a cache refresh) and now yields a document with a *different controller* and a *different verification method set*, both individually valid under the DID method's rules. Each resolution is correct in isolation. The federation decision is wrong because the RP validated the credential against anchor state that no longer exists and cannot be shown to have existed at the credential's issuance time. The distinction that matters operationally: **a DID is a mutable pointer; a trust anchor is a claim about a specific (DID, document-version, effective-interval) tuple.** Federation code that treats "resolve the DID" as the trust primitive silently collapses the second into the first. The invariant that prevents the failure is that every credential validation must be bound to a *versioned, time-stamped* anchor record, and the controller chain must be evaluated against that record — not against whatever the resolver returns at the moment of checking. ## What Was Observed Two orgs, A (issuer of agent credentials) and B (relying party), federate agent identities. A's agents carry VCs issued under a DID `did:web:a.example` whose document lists controller `did:web:a-root.example`. B's federation gateway performs the standard dance: resolve `did:web:a.example`, extract `assertionMethod`, verify the VC signature, then walk `controller` up to a root it trusts. During a planned key-rotation on A's side, the root controller was migrated from `did:web:a-root.example` to `did:web:a-root-v2.example`. The migration was legitimate, announced, and cryptographically sound: the old root signed a delegation to the new root, and the new root re-issued the agent DID document. But the migration was *not atomic with respect to B's view*. Concretely: - B cached `did:web:a.example`'s document with a 1-hour TTL but did **not** cache the controller chain. - At T+0 B validated a long-lived agent credential against the cached document (controller = old root), and the signature checked out. - At T+40min the credential was re-presented on a new session; B's document cache was still warm, but B's *controller-chain walker* re-resolved `did:web:a-root.example` fresh. A had decommissioned the old root's resolution endpoint at T+30min (returning a valid DID document with an empty `capabilityInvocation` and a `deactivated`-style revocation marker in its service section, per the method's rules). B's walker treated "resolvable but no longer authorizes" as a hard failure and rejected the credential — a false negative. - In a separate incident, the inverse: B's document cache had *expired* but the walker's controller resolution was cached; the walker accepted the new root while the cached document still named the old controller, and a credential signed by a *compromised* old-root-authorized key was accepted for a 12-minute window before cache invalidation propagated. The second incident is the trust-anchor failure proper: **the two halves of the anchor (document + controller chain) were cached with independent lifetimes, so they could describe two different worlds.** No single component was buggy. The composition was. ## Condition of Validity The invariant holds only under these conditions, and the federation decision is *not* safe outside them: 1. **Anchor atomicity.** The DID document and its full controller chain must be resolved and cached as a single immutable record with one version identifier and one effective-interval. Independent TTLs on document vs. chain are the direct cause of the observed divergence. 2. **Effective-time binding.** Credential validation must assert `issued_at` (and, where present, `valid_from`) falls within the anchor record's effective interval. A credential issued at T-2h cannot be validated against an anchor that became effective at T+30min. 3. **Deactivation is terminal and observable.** A decommissioned controller must produce a resolution result that the walker recognizes as "this anchor is dead," distinct from "this anchor does not authorize this key." Conflating the two produces the false-negative path above. 4. **Monotonic version ordering.** The DID method must expose a version or sequence number that strictly increases across re-anchoring, so a stale cached record can be detected as stale rather than merely "different." If any of these is absent — notably if the DID method does not expose version/effective-time metadata, which is common for `did:web` and several `did:key`-adjacent methods — the invariant cannot be enforced at the RP and the RP must fall back to a stricter policy (see Decision Boundary). ## Decision Boundary The boundary is between **accepting a credential against a re-resolved anchor** and **refusing to validate without a pinned, versioned anchor record**. The rule: - If the DID method exposes version + effective-interval metadata, the RP MAY cache the anchor record and validate credentials whose issuance time falls inside the record's interval, re-resolving only when the record's interval has elapsed *and* treating any version regression as a hard error. - If the DID method does **not** expose such metadata, the RP MUST NOT cache across the controller-chain boundary. It must either (a) resolve document and chain in a single atomic operation per validation, accepting the latency cost, or (b) refuse federation for that method and require the peer to present a credential anchored to a method that does. The non-obvious part of the boundary: **a successful signature verification is not evidence that the anchor is current.** The signature proves the key signed; it does not prove the key was authorized at issuance time under the anchor the RP is now trusting. The decision to accept must be gated on anchor-record validity, evaluated *before* signature verification, because signature verification against a stale anchor is exactly the window in which the compromise case slips through. ## Operational Effect Enforcing the invariant converts an intermittent, timing-dependent federation failure into a deterministic, auditable decision. In practice: - Federation rejections become explainable: "credential issuance time T is outside anchor record version V's effective interval [T0, T1)" rather than "signature failed" or "DID resolution error," which previously masked the real cause. - Re-anchoring on the issuer side becomes a coordination event with a defined cutover, because the RP will reject credentials issued across the boundary until the new anchor record propagates. This is *correct* behavior and should be surfaced to operators as a planned degradation, not an incident. - The 12-minute compromise window observed in the second incident is eliminated, because a credential signed by the old-root key cannot be validated against the new anchor record (issuance time precedes its effective interval) and cannot be validated against the old record (which is deactivated and terminal). The cost is latency on methods lacking version metadata, and a stricter federation policy that some peers will initially fail. That cost is the price of not having a trust anchor that silently means two things at once. ```python from __future__ import annotations import dataclasses from typing import Optional class AnchorDivergenceError(RuntimeError): """Raised when a credential cannot be bound to a single, versioned trust anchor.""" @dataclasses.dataclass(frozen=True) class AnchorRecord: did: str version: int effective_from: float # epoch seconds effective_to: Optional[float] # None == open-ended controller_chain: tuple[str, ...] deactivated: bool = False def validate_credential_against_anchor( *, credential_issued_at: float, document_anchor: AnchorRecord, chain_anchor: AnchorRecord, ) -> None: # Invariant 1: document and controller chain must describe the same world. if document_anchor.version != chain_anchor.version: raise AnchorDivergenceError( f"anchor split: document v{document_anchor.version} vs " f"chain v{chain_anchor.version} for {document_anchor.did}" ) anchor = document_anchor # Invariant 3: deactivation is terminal and must not be read as "no authority". if anchor.deactivated: raise AnchorDivergenceError( f"anchor {anchor.did} v{anchor.version} is deactivated; " "credential cannot be validated against a dead anchor" ) # Invariant 2: credential issuance must fall inside the anchor's effective interval. if credential_issued_at < anchor.effective_from: raise AnchorDivergenceError( f"credential issued at {credential_issued_at} precedes anchor " f"{anchor.did} v{anchor.version} effective_from {anchor.effective_from}" ) if anchor.effective_to is not None and credential_issued_at >= anchor.effective_to: raise AnchorDivergenceError( f"credential issued at {credential_issued_at} is outside anchor " f"{anchor.did} v{anchor.version} interval ending {anchor.effective_to}" ) def validate_with_fallback( *, credential_issued_at: float, resolved: list[AnchorRecord], method_exposes_versioning: bool, ) -> AnchorRecord: if not method_exposes_versioning: # Decision boundary: refuse rather than cache across the chain boundary. raise AnchorDivergenceError( "DID method does not expose version/effective-interval metadata; " "federation must be refused or resolved atomically per validation" ) if not resolved: raise AnchorDivergenceError("no anchor records resolved") # Invariant 4: monotonic version ordering; any regression is a hard error. versions = [r.version for r in resolved] if versions != sorted(versions): raise AnchorDivergenceError( f"non-monotonic anchor versions observed: {versions}" ) anchor = resolved[-1] validate_credential_against_anchor( credential_issued_at=credential_issued_at, document_anchor=anchor, chain_anchor=anchor, ) return anchor ```