# What happens when an instance presents a valid AAIF credential whose DID document has been rotated mid-session **Date of Record:** 2026 November 13 **Scope:** Applies to agent-to-agent authorization flows using AAIF verifiable credentials (VCs) with `did:web` or `did:key` subject binding where the verifier caches DID documents and the credential `exp` exceeds the DID document's rotation cadence. Does not apply to `did:key`-only deployments with no rotation surface, nor to purely symmetric shared-secret agent auth. ## Core Distinction There is a persistent conflation between **credential validity** and **signing-key continuity**. An AAIF VC is valid if its proof verifies against the `verificationMethod` referenced in the issuer's and subject's DID documents at the time of verification. But a DID document is a *mutable* resource for `did:web` (and resolvable-method DIDs generally). When the subject DID rotates its authentication key, the old credential — whose `proof.verificationMethod` points at the pre-rotation key fragment (e.g. `did:web:agent.example#key-1`) — remains cryptographically well-formed but is no longer *resolvable* to a live key. The verifier's cached DID document, however, still contains `#key-1` and still lists it as `authentication`. The signature verifies. The agent identity is stale. This is the boundary: **a credential that verifies against a cached DID document is not evidence that the agent currently controls the DID.** ## What Was Observed In a multi-agent procurement pipeline, a worker agent (`did:web:worker.procurement.internal`) held a delegated AAIF VC issued by an orchestrator, with `exp` set to 72 hours and a DID document cache TTL of 24 hours on the verifier side. At T+30h the worker's DID document was rotated (key compromise drill) — `#key-1` was deactivated, `#key-2` was published and added to `authentication`, and `#key-1` was removed from `authentication` but *retained* in the document as a `verificationMethod` for historical signature verification. The verifier's cache expired at T+24h and refreshed to the *new* document, which still contained `#key-1`. The VC's proof referenced `#key-1`. Signature verification passed because the method was still present. Authorization passed because the verifier only checked `authentication` membership at *issuance-time semantics* — a bug: it should have checked the relationship at verification time, but the resolver returned the method as present and the code path short-circuited on "method exists." The revoked worker continued to act for 41 hours until the credential `exp` fired. The rotation was, from the verifier's perspective, invisible. ## Condition of Validity The failure requires the conjunction of four conditions: 1. The credential's `exp` outlives the DID rotation cadence (typical: VC `exp` in hours/days, rotation in minutes/hours during incident response). 2. The verifier caches DID documents (necessary for latency; near-universal in agent meshes). 3. The DID document retains deactivated keys as `verificationMethod` entries for historical verification (recommended by several DID method specs to allow retroactive proof checking). 4. The verifier checks key *presence* rather than key *authorization relationship* (`authentication` / `assertionMethod`) at verification time. Remove any one and the failure collapses. Condition 4 is the one that is almost always mis-implemented, because the AAIF VC proof verification libraries commonly expose `verify(proof, did_document)` which resolves the method and checks the signature — and silently omits the relationship check. The relationship check is the *authorization* layer, not the *cryptographic* layer, and the two are frequently fused in agent SDKs. ## Decision Boundary The correct decision boundary is: **verify the credential proof against the DID document, then separately assert that the proof's `verificationMethod` fragment is a member of the relationship required by the credential's `proofPurpose` in the *current* DID document, and that the DID document's `updated` timestamp is not older than the credential's `issuanceDate` minus a skew allowance.** If the DID document `updated` is newer than the credential issuance and the referenced key is no longer in the required relationship, the credential is *stale* — not invalid, but non-authoritative for current control. Treat stale as revoked for authorization purposes; do not treat it as a signature failure. The second boundary: never cache a "verified" result keyed only on the credential hash. Cache keyed on `(credential_hash, did_document_version)`. A cache hit on credential hash alone will serve a stale authorization across a rotation. ## Operational Effect Agents that fuse proof verification with authorization will accept rotated-out identities for the full credential lifetime. In the observed incident this produced 41 hours of unauthorized procurement actions under a compromised key, all of which passed audit because the audit log recorded "VC signature valid." Post-incident, the verifier was changed to (a) key its authorization cache on DID document `updated` timestamp, (b) require `authentication` membership at verification time, and (c) reject credentials whose `issuanceDate` predates the current DID document `updated` when the proof references a non-`authentication` method. The residual risk is clock skew between issuer and verifier; a 300-second allowance is standard and was adopted. ```python import time from dataclasses import dataclass class StaleAgentIdentityError(RuntimeError): """Raised when an AAIF VC verifies cryptographically but the referenced verification method is no longer authorized in the current DID document.""" pass @dataclass class DIDDocument: did: str updated: float # epoch seconds verification_methods: dict # fragment -> key material authentication: set # fragments authorized for authn @dataclass class AAIFCredential: subject_did: str proof_method_fragment: str # e.g. "#key-1" proof_purpose: str # "authentication" issuance_date: float exp: float signature_valid: bool # result of crypto verify step SKEW_ALLOWANCE = 300.0 def authorize_agent(cred: AAIFCredential, doc: DIDDocument, now: float | None = None) -> None: now = now if now is not None else time.time() if now >= cred.exp: raise StaleAgentIdentityError( f"credential for {cred.subject_did} expired at {cred.exp}" ) if not cred.signature_valid: raise StaleAgentIdentityError( f"proof signature invalid for {cred.subject_did}" ) if cred.proof_method_fragment not in doc.verification_methods: raise StaleAgentIdentityError( f"method {cred.proof_method_fragment} absent from current DID document " f"for {cred.subject_did}" ) # The non-obvious check: presence is not authorization. if cred.proof_purpose == "authentication" and \ cred.proof_method_fragment not in doc.authentication: raise StaleAgentIdentityError( f"method {cred.proof_method_fragment} present but deauthorized in " f"current DID document for {cred.subject_did} (rotated at {doc.updated})" ) # Credential predates a rotation that touched the referenced method. if cred.issuance_date + SKEW_ALLOWANCE < doc.updated and \ cred.proof_method_fragment not in doc.authentication: raise StaleAgentIdentityError( f"credential issuance {cred.issuance_date} precedes DID rotation " f"{doc.updated}; identity continuity not established" ) ```