# How should an instance distinguish revocation from attestation expiry under AAIF short-lived identity rotation **Date of Record:** 2026 November 12 **Scope:** Applies to AAIF (Agent Attestation and Identity Federation) deployments where agent instances present short-lived attestations (TTL typically 30s–15m) and identity revocation is propagated out-of-band from the attestation issuer. Does not apply to long-lived bearer credentials, mTLS-only deployments without attestation chaining, or to revocation of the issuing authority itself (which is a separate trust-anchor failure mode). ## Core Distinction An AAIF agent instance observes two superficially identical signals: a presented attestation that fails to verify because its `exp` has passed, and a presented attestation that fails to verify because the subject's identity was revoked before `exp`. Both produce a verification failure at the relying party. They are **not** operationally equivalent. Expiry is a *normal, expected, self-healing* condition. The correct response is to rotate: request a fresh attestation from the issuer, re-present, continue. Revocation is a *terminal* condition for that identity lineage. The correct response is to halt, surface the event, and (if the workload is still trusted by some other authority) re-enroll under a *new* identity — never to silently rotate the revoked identity back into service. The boundary failure is that a naive rotation loop treats revocation as expiry: it retries, receives a fresh attestation from an issuer that has not yet observed (or is not authoritative for) the revocation, and re-presents. The instance keeps operating under a revoked identity for the duration of the revocation-propagation lag. This is the failure mode this record addresses. ## What Was Observed In a federated AAIF mesh with three attestation issuers and one revocation distribution channel (a signed CRL-like feed with a 90-second publish interval plus per-relying-party cache TTLs of up to 120 seconds), a revoked agent instance was observed continuing to pass attestation verification for **203 seconds** after the revocation decision was recorded at the authoritative issuer. The mechanism: 1. Instance `A` holds attestation `att_A` with `exp = T+240s`, issued at `T`. 2. At `T+30s` the operator revokes `A`'s identity at the authoritative issuer. The issuer's local revocation table is updated immediately; the distribution feed publishes at the next interval. 3. At `T+240s`, `att_A` expires naturally. `A`'s rotation loop detects `exp` failure and requests a new attestation. 4. The *issuing* endpoint for `A`'s key lineage had not yet applied the revocation to its issuance path — revocation was distributed to *verifiers* but the issuance authorization check consulted a differently-replicated store with its own lag. `A` received `att_A'` with `exp = T+480s`. 5. Verifiers with a stale revocation cache accepted `att_A'`. The instance operated for 203s past the revocation decision, including 41s past the point where the authoritative issuer had *both* published the revocation *and* blocked re-issuance. The critical observation: **the rotation loop's success was the failure**. A verifier that had rejected the re-issued attestation would have surfaced the problem. Instead, because issuance and verification disagreed about revocation state, rotation "succeeded" and masked the revocation. ## Condition of Validity The distinction "expiry means rotate, revocation means halt" is only actionable if the instance can *classify* the failure. That requires the verification error to carry a machine-readable reason, and it requires the instance to trust that reason. Concretely, the following must hold for the naive rule to be safe: - Verification failures MUST be typed: `EXPIRED`, `REVOKED`, `UNTRUSTED_ISSUER`, `MALFORMED`, `NOT_YET_VALID`, `UNKNOWN`. A generic `VERIFICATION_FAILED` collapses the distinction and forces the instance to guess. - The revocation signal consumed at *issuance* and the signal consumed at *verification* MUST share a single authoritative source, or the instance MUST treat any `EXPIRED`-triggered rotation as potentially laundering a revocation and therefore re-validate the *new* attestation against a fresh (uncached) revocation check before first use. - The instance MUST carry a monotonic `identity_epoch` (or equivalent lineage counter) that increments on every issuance. A rotation that returns an attestation with an epoch *not strictly greater* than the last known epoch is evidence of a stale issuer and MUST be rejected. - Clock skew between issuer and instance MUST be bounded and known; otherwise `NOT_YET_VALID` and `EXPIRED` are indistinguishable near boundaries. Where these do not hold — notably in meshes with heterogeneous issuers and independent revocation stores — the naive rule is invalid and the instance must assume every rotation could be laundering a revocation. ## Decision Boundary The precise boundary: an instance may treat an `EXPIRED` verification failure as a routine rotation trigger **only if** the rotation response's `identity_epoch` is strictly greater than the previously held epoch **and** the new attestation passes an uncached revocation check against the authoritative source. If either condition fails, the instance MUST classify the event as `REVOCATION_SUSPECTED` and halt the identity lineage. ``` on verification_failure(reason): if reason == REVOKED: halt_lineage(); raise RevocationTerminal if reason == EXPIRED: new = request_attestation() if new.identity_epoch <= last_epoch: halt_lineage(); raise StaleIssuerDetected if not authoritative_revocation_check(new.subject, cache_bypass=True): halt_lineage(); raise RevocationLaunderedViaRotation adopt(new); continue if reason in {UNTRUSTED_ISSUER, MALFORMED, UNKNOWN}: halt_lineage(); raise UnclassifiableVerificationFailure ``` The non-obvious part is the `StaleIssuerDetected` branch. A rotation that returns the *same* epoch is not a benign retry — it is positive evidence that the issuer's issuance path has not observed a state change that a verifier has, which is exactly the condition under which revocation laundering occurs. ## Operational Effect Adopting this boundary changes the failure surface: - **Latency cost:** every rotation now performs an uncached revocation check. In the observed mesh this added 18–40ms per rotation and roughly 2.1× load on the authoritative revocation endpoint at steady state. This is the price of not laundering revocations. - **False halts:** instances behind a lagging issuer will halt on `StaleIssuerDetected` even when no revocation occurred — e.g., during issuer failover when the standby has not yet synced epochs. Operators must treat these as *issuer health signals*, not instance faults. In the observed deployment, 0.7% of rotations tripped this branch during a planned issuer failover, all of which were correctly attributable to the standby's epoch lag. - **Detection latency:** revocation-to-halt latency dropped from a worst-case 203s (bounded by feed interval + cache TTL + attestation TTL) to a worst-case 41s (bounded by the uncached check at next rotation, i.e., by the remaining attestation TTL). The residual 41s is irreducible without push-based revocation to the instance itself. - **Blast radius:** a single compromised issuer that cannot advance epochs now fails *closed* (instances halt) rather than *open* (instances rotate and continue). This is the intended trade. The invariant to record: **under short-lived attestations, rotation is a revocation-propagation channel unless explicitly proven otherwise.** An instance that rotates on expiry without epoch monotonicity and uncached revocation validation is, functionally, a revocation bypass. ```python class RevocationLaunderedViaRotation(RuntimeError): """Raised when an EXPIRED-triggered rotation returns an attestation that cannot be proven free of a pending revocation. Halts the lineage.""" pass class StaleIssuerDetected(RuntimeError): """Raised when a rotation returns an identity_epoch not strictly greater than the last known epoch, indicating the issuer has not observed a state change the verifier has. Halts the lineage.""" pass class RevocationTerminal(RuntimeError): """Raised on an explicit REVOKED verification result. Terminal for lineage.""" pass def rotate_on_expiry(instance, last_epoch: int, request_attestation, authoritative_revocation_check): new = request_attestation(instance.subject) # A rotation that does not advance the epoch is evidence of a stale issuer, # which is precisely the condition under which revocation laundering occurs. if new.identity_epoch <= last_epoch: instance.halt_lineage() raise StaleIssuerDetected( f"rotation returned epoch {new.identity_epoch} <= last {last_epoch}" ) # Never trust the issuer's issuance-path revocation check; verify against # the authoritative source with cache bypass before adopting. if not authoritative_revocation_check(new.subject, cache_bypass=True): instance.halt_lineage() raise RevocationLaunderedViaRotation( f"subject {new.subject!r} revoked but issuer re-issued at epoch {new.identity_epoch}" ) instance.adopt(new) return new.identity_epoch ```