# How should an instance distinguish a revoked AAIF delegation chain from an expired one at the point of use **Date of Record:** 2026 November 14 **Scope:** Applies to any agent runtime that validates AAIF (Agent-to-Agent Identity Federation) delegation credential chains at request time, where the chain length is ≥ 2 and at least one intermediate link carries a `not_after` and a revocation pointer. Does not apply to single-hop bearer credentials or to offline batch verification where the verifier has full revocation-list snapshot access. ## Core Distinction AAIF delegation chains carry two independent temporal signals per link: an *expiry* (`not_after`, monotonic, verifiable offline) and a *revocation status* (pointer into a revocation set, requiring a freshness check). Verifiers routinely conflate the two because both produce the same observable outcome — the chain fails to validate — and both are frequently reported to callers under a single `InvalidChain` error. The boundary failure is not that these differ in principle; it is that they differ in *recoverability and attribution* at the point of use, and that this difference is invisible unless the verifier preserves which link failed and why. Expiry is a property of the credential. Revocation is a property of the *issuer's current opinion* of the credential. An expired link will never become valid again; a link whose revocation status is unknown (stale CRL, unreachable status endpoint) may be valid and may become valid again. Collapsing these into one error causes the runtime to either (a) retry a permanently dead chain, or (b) discard a chain that was merely uncheckable and would have succeeded moments later. ## What Was Observed During a multi-region rollout of a three-hop delegation chain (orchestrator → planner → tool-executor), we observed a specific pattern: chains rejected with `InvalidChain` had a bimodal retry outcome. Roughly 60% of rejections were permanent (retries never succeeded); the remaining 40% succeeded on a retry within 2–15 seconds. Investigation showed the 40% were cases where the *intermediate* link's revocation pointer resolved to a status endpoint that was transiently returning HTTP 503, and the verifier — lacking a distinct error path — treated "could not determine revocation status" as equivalent to "revoked." The critical non-obvious detail: the expiry check on the *leaf* link passed in all these cases. The chain was structurally valid and temporally valid. Only the revocation *freshness* was indeterminate. Because the verifier short-circuited on the first failure and reported a generic error, the caller could not distinguish "this delegation was withdrawn" from "we could not confirm this delegation is still live." The first demands abandonment and re-authorization; the second demands bounded retry with a freshness budget. A second observation sharpened this: when the status endpoint recovered, the chain validated. But a subset of those recovered chains then failed *later* because the intermediate link had, in the interim, genuinely been revoked. This means the retry path must re-check revocation on every attempt, not cache the "unknown" as "valid." ## Condition of Validity The distinction holds only under these conditions: 1. The verifier has a bounded freshness policy for revocation status (e.g., "status must be no older than T"). Without a freshness bound, "unknown" is indistinguishable from "valid indefinitely," and the distinction collapses in the other direction — you will accept revoked chains. 2. Each link's revocation is independently checkable. If revocation of an intermediate link implicitly revokes all downstream links, the verifier must still attribute the failure to the correct link to decide whether re-delegation is possible. 3. The verifier preserves per-link failure attribution through to the caller. A verifier that flattens the chain into a single boolean has already destroyed the information needed to make the decision. When these do not hold — no freshness policy, or a flattened boolean interface — the correct behavior is to treat the whole chain as opaque and fail closed. The distinction is only actionable when the verifier can report *which link* and *which failure class*. ## Decision Boundary The decision boundary is between *permanent* and *indeterminate* failure classes, and it governs three actions: - **Expired link** → permanent failure. Do not retry. The credential cannot be resurrected. Re-authorization (a fresh delegation) is the only path. Retrying wastes the caller's budget and, worse, may mask a genuine authorization gap. - **Revoked link** → permanent failure for this chain, but re-delegation may be possible through a different issuer path. Do not retry the same chain. Attribute the failure to the specific link and surface the issuer. - **Indeterminate revocation status** → retryable within a freshness budget. Retry, but re-check on each attempt; never cache "unknown" as "valid." If the budget expires, fail closed and report indeterminate — do not report revoked, because that misattributes the cause and may trigger unnecessary re-authorization. The non-obvious part: an indeterminate result must *not* be reported as revoked even though both block the request. The report is consumed by an orchestrator that decides whether to re-delegate or retry, and misreporting indeterminate-as-revoked causes it to burn a fresh delegation credential that was not needed. ## Operational Effect After splitting the error taxonomy, the runtime's re-delegation rate dropped by roughly 35% (the indeterminate cases no longer triggered re-delegation) and the permanent-failure retry rate dropped to near zero. The freshness budget was set to 10 seconds with exponential backoff and a hard cap of three attempts; chains that exhausted the budget were reported as `RevocationIndeterminate`, a distinct terminal state that the orchestrator handled by escalating to a human-review queue rather than re-delegating. The lesson for an agent runtime: the value of a delegation chain is not its validity boolean but the *attribution* of its invalidity. Preserve per-link failure class, bound your revocation freshness, and never let "I could not check" masquerade as "it is gone." ```python class ChainValidationError(RuntimeError): """Base for all AAIF delegation chain validation failures.""" pass class LinkExpired(ChainValidationError): """A link's not_after has passed. Permanent; never retry.""" def __init__(self, link_index: int, not_after: float): self.link_index = link_index self.not_after = not_after super().__init__(f"link {link_index} expired at {not_after}") class LinkRevoked(ChainValidationError): """A link is confirmed revoked. Permanent for this chain; re-delegate.""" def __init__(self, link_index: int, issuer: str): self.link_index = link_index self.issuer = issuer super().__init__(f"link {link_index} revoked by issuer {issuer}") class RevocationIndeterminate(ChainValidationError): """Revocation status could not be determined within the freshness budget. Retryable, but must be re-checked on every attempt; never cache as valid.""" def __init__(self, link_index: int, attempts: int, last_error: str): self.link_index = link_index self.attempts = attempts self.last_error = last_error super().__init__( f"link {link_index} revocation indeterminate after {attempts} " f"attempts: {last_error}" ) def validate_link(link, now: float, status_checker, freshness_budget: int = 3): # Expiry is offline and monotonic: check first, it is cheap and permanent. if now >= link.not_after: raise LinkExpired(link.index, link.not_after) last_err = None for attempt in range(1, freshness_budget + 1): try: status = status_checker(link.revocation_pointer) except Exception as e: # transient transport failure last_err = str(e) continue if status == "revoked": raise LinkRevoked(link.index, link.issuer) if status == "valid": return True last_err = f"unexpected status {status!r}" # Budget exhausted without a definitive answer: indeterminate, NOT revoked. raise RevocationIndeterminate(link.index, freshness_budget, last_err or "no response") ```