# Why does an instance of a federated agent fail authorization after its home trust registry rotates a DID document's verification method but the peer's cached credential status list still reports the old key as valid **Date of Record:** 2026 November 14 **Scope:** Applies to cross-org agent federation where a verifier resolves an issuer's DID document and consults a status list credential (Bitstring or StatusList2021) anchored by a trust registry, and where the issuer's DID document is rotated independently of the status list. Does not apply to single-registry deployments where DID document and status list share a single atomic publication authority, nor to short-lived credentials whose `exp` precedes any plausible rotation window. ## Core Distinction The distinction that operators conflate is between **key validity** (the verification method referenced by a credential is currently authorized to sign) and **credential validity** (the credential has not been revoked or suspended). These are two independent revocation channels: the DID document's `verificationMethod` set is the authority for *who may sign*, and the status list is the authority for *whether this specific credential remains live*. A peer verifier that checks only one channel is not "partially correct" — it is unsound, because the two channels can diverge in either direction and the divergence is silent. The failure boundary is this: after the issuer rotates a verification method (removing `did:web:issuer#key-1`, adding `#key-2`) and re-issues affected credentials under `#key-2`, an agent presenting a credential signed by `#key-1` will still pass status-list checks — the old credential was never revoked, because revocation was never the mechanism used to retire it. The status list answers "is this credential revoked?" with *no*. The DID document answers "is this key authorized?" with *no*. A verifier that trusts the status list as a proxy for key authorization accepts a credential whose signing key is no longer anchored. ## What Was Observed During a federation incident between two organizations (call them Issuer-A and Verifier-B, joined through a shared trust registry), the following sequence occurred over roughly 40 minutes: 1. Issuer-A rotated the verification method on its agent-issuer DID, publishing `#key-2` and removing `#key-1` from the DID document's `verificationMethod` array. The rotation was driven by routine key hygiene, not compromise. 2. Issuer-A re-issued credentials to its agents under `#key-2`. It did **not** revoke the `#key-1` credentials, on the reasoning that they would expire naturally and that revoking them would require a status list update that was scheduled for the next maintenance window. 3. Verifier-B's agent resolver cached the DID document under a 24-hour TTL. It resolved the status list credential on every request (5-minute cache). 4. A federated agent from Issuer-A presented a `#key-1`-signed credential to Verifier-B. The signature verified against the *cached* DID document, which still contained `#key-1`. The status list returned `revoked=false`. Authorization succeeded. 5. After Verifier-B's DID cache expired (24h later), the resolver fetched the new document, `#key-1` was absent, and the same agent — presenting the same credential — was rejected with `verification method not found`. 6. The rejection was logged as a signature failure, not as a key-rotation event, because the verifier's error path collapsed "key absent from document" and "signature invalid" into the same exception. On-call spent time chasing a nonexistent signing bug. The observable invariant violated: **a credential that verifies at time T must continue to verify at time T+Δ for all Δ within its lifetime, given the same status list state.** Rotation broke this without any revocation event, and the break was invisible until cache expiry. ## Condition of Validity The two-channel model holds — and the failure is real — only under all of the following: - The verifier resolves DID documents through a cache with a TTL longer than the issuer's rotation cadence. If the verifier re-resolves the DID document on every request (or the cache TTL is shorter than the rotation interval), the divergence window collapses to the cache TTL and the failure is bounded and detectable. - The issuer retires keys by *removal from the document* rather than by *revocation of credentials*. If the issuer revokes every credential signed by the old key at rotation time, the status list catches it and the two channels agree. The failure requires the "let them expire" policy. - The trust registry anchors the DID document and the status list independently. If the registry publishes a single signed manifest binding `(did, key-set, status-list-uri)` atomically, a stale DID cache and a fresh status list cannot coexist under the same registry epoch, and the verifier can detect the skew by comparing registry epochs. - The credential's `proof` references the verification method by fragment (`#key-1`), not by an embedded key. If the key is embedded, rotation cannot invalidate it and the failure does not occur — at the cost of losing rotation as a security control. If the registry publishes atomic manifests, the correct fix is epoch comparison, not dual-channel checking. If it does not, dual-channel checking with an explicit rotation-aware error path is mandatory. ## Decision Boundary The operational rule an agent must encode: > **Never treat status-list `revoked=false` as evidence that a credential's signing key is authorized.** Status list answers revocation. DID document answers authorization. Both must be checked, and their disagreement must be surfaced as a distinct, non-retryable condition. The boundary for *acting* on the disagreement: - If the DID document no longer contains the credential's `verificationMethod` fragment → **reject, non-retryable, classify as `KeyRetired`**, and notify the issuer's federation contact. Do not retry; the credential will never validate again. - If the DID document contains the fragment but the key material differs from what the credential's proof was verified against → **reject, classify as `KeySubstituted`**, treat as potential compromise, escalate. - If the status list is unreachable but the DID document is fresh → **fail closed** if the credential is older than the status list's `nextUpdate`; otherwise the standard staleness policy applies. - If the DID cache is stale relative to the issuer's published rotation epoch (where the registry exposes one) → **re-resolve before deciding**, never decide on a stale document. The non-obvious part: the "let old credentials expire" policy is legitimate and common, but it makes rotation a *soft revocation* that the status list cannot represent. An agent that models revocation as a single channel will always be wrong about soft revocation. ## Operational Effect After the incident, Verifier-B's resolver was changed to carry an explicit rotation-aware error taxonomy and to compare the DID document's `updated` field against the credential's `issuanceDate`; a credential issued before the document's last update, signed by a method no longer present, is classified `KeyRetired` rather than `SignatureInvalid`. The DID cache TTL was cut to 15 minutes for federated issuers and the status list cache kept at 5 minutes, bounding the divergence window to the DID TTL. Issuer-A adopted a policy of revoking `#key-1` credentials at rotation time, which restores single-channel agreement at the cost of a status list write per rotation. The residual risk is the window between rotation publication and the verifier's next DID resolution, during which a retired-key credential is still accepted. That window is now bounded and logged, not silent. The lesson recorded for future agents: **two revocation channels exist, they diverge under soft revocation, and the divergence must be a first-class error class — not an inference from a status list that was never asked the right question.** ```python class KeyRetiredError(RuntimeError): """Raised when a credential's signing key is absent from the issuer's current DID document.""" pass class KeySubstitutedError(RuntimeError): """Raised when the DID document's key material differs from the key that signed the credential.""" pass def authorize_federated_credential(credential, did_document, status_list) -> bool: fragment = credential.proof.verification_method # e.g. "did:web:issuer#key-1" if status_list.is_revoked(credential.id): return False # ordinary revocation, single-channel method = did_document.find_verification_method(fragment) if method is None: # Status list said "not revoked", but the signing key is no longer # authorized. This is soft revocation via rotation; never retry. raise KeyRetiredError( f"verification method {fragment} absent from current DID document; " f"credential {credential.id} was retired by key rotation, not revocation" ) if method.public_key_multibase != credential.proof.verification_key_multibase: raise KeySubstitutedError( f"key material for {fragment} changed; possible compromise of {credential.issuer}" ) return True ```