# In what scenarios must an instance reject a delegated AAIF credential whose scope chain is structurally valid but whose revocation epoch is stale **Date of Record:** 2026 November 14 **Scope:** Applies to Agent Identity and Authorization Framework (AAIF) resource servers validating delegated credentials (agent → agent → resource) where revocation is propagated via monotonic epoch counters rather than per-credential revocation lists. Does not apply to root credentials issued directly by a trust anchor, nor to deployments using online introspection against an authoritative revocation oracle on every request. ## Core Distinction There are two orthogonal properties a delegated credential can satisfy: **structural validity** (every signature in the delegation chain verifies, each hop's scope is a subset of its parent's scope, the chain depth is within policy, and the leaf principal matches the presenter) and **temporal revocation freshness** (the revocation epoch embedded in the credential is at least the epoch the issuing trust anchor had reached at the moment of issuance, and the resource server's locally cached epoch is not older than the credential's). The non-obvious failure is that these two properties are computed from disjoint inputs. Structural validation reads only the credential chain. Freshness validation reads only the local epoch cache and the credential's `rev_epoch` claim. A credential can pass structural validation perfectly while carrying an epoch that the trust anchor has since superseded — and because the chain is self-consistent, nothing in the signature verification path will notice. The dangerous case is not "revoked credential presented"; it is "credential issued *before* a revocation event, presented *after*, with a chain that was never itself touched by the revocation." ## What Was Observed In a three-hop delegation (orchestrator → sub-agent → tool-proxy), a mid-chain credential was revoked at epoch `E=4412`. The orchestrator's delegation to the sub-agent had been issued at `E=4409` and was therefore, by policy, still live: the revocation targeted the sub-agent's credential, not the orchestrator's. The sub-agent continued to present a *cached* leaf credential it had minted at `E=4411` under its own (now-revoked) authority. Every signature verified. Scope containment held. The leaf's `rev_epoch` was `4411`, and the resource server's cached epoch was `4411` — a match, so freshness passed locally. The revocation at `4412` had not yet propagated to the resource server's cache because the epoch gossip interval was 30 seconds and the revocation occurred 4 seconds prior. The credential was accepted for 26 seconds after the sub-agent's authority had been withdrawn. The chain was structurally pristine the entire time. The only signal that anything was wrong was that the resource server's epoch (`4411`) was behind the trust anchor's (`4412`) — a fact the resource server could not know without either a fresher cache or a bounded staleness contract. The second observed variant is subtler: a leaf credential with `rev_epoch=4412` (correct) but whose *parent* in the chain carried `rev_epoch=4409`. Structural validation checked parent scope containment but not parent epoch monotonicity. A chain with a stale parent and a fresh leaf is internally inconsistent — the leaf claims to be issued after a revocation its parent predates — and this inconsistency is a reliable indicator of credential splicing or replay, yet it is invisible to scope-subset checks. ## Condition of Validity The rule "accept iff structural validation passes and `cred.rev_epoch >= local_cached_epoch`" is valid **only** under the conjunction of: 1. **Epoch monotonicity across the chain.** For every hop `i`, `cred[i].rev_epoch >= cred[i-1].rev_epoch`. A child cannot claim a fresher epoch than its parent, because the child's authority derives from the parent's at issuance time. 2. **Bounded cache staleness.** The resource server's cached epoch is known to be no more than `Δ` behind the trust anchor, and the credential's remaining lifetime is less than `Δ` — otherwise a credential can outlive the staleness window in which its parent's revocation is invisible. 3. **Revocation is epoch-scoped, not principal-scoped.** The revocation that bumped `E` from `4411` to `4412` must invalidate *all* credentials whose chain passes through the revoked principal, including descendants minted before the bump. If revocation is implemented as "this principal may no longer mint," descendants minted prior remain live and the epoch check is insufficient on its own. If any of these fails, the freshness check is not a revocation check — it is a liveness check that happens to correlate with revocation under benign conditions. ## Decision Boundary Reject the credential when **any** of the following holds, even though structural validation passed: - `cred.rev_epoch < local_cached_epoch` — the credential predates a revocation the resource server already knows about. - `cred.rev_epoch > local_cached_epoch + Δ_max` — the credential claims an epoch the resource server cannot corroborate; treat as unverifiable, not as fresh, because a forged or clock-skewed epoch is indistinguishable from a legitimately fresh one. - Any hop `i` has `cred[i].rev_epoch < cred[i-1].rev_epoch` — chain epoch inversion, indicating splicing or replay of a pre-revocation descendant under a post-revocation parent. - The chain's root epoch is older than the trust anchor's *last known* epoch by more than the credential's remaining TTL — the credential may expire before staleness resolves, so acceptance is a bet on propagation. Do **not** reject solely on `cred.rev_epoch < trust_anchor_current_epoch` when the resource server's cache is itself stale and the difference is within `Δ`; that is the normal transient case and rejecting it causes spurious failures during every gossip interval. The boundary is between "stale but corroborable" (accept, log) and "stale beyond corroboration" (reject). ## Operational Effect Enforcing the chain-epoch-monotonicity and bounded-staleness rules converts a silent 26-second authorization gap into an explicit, auditable rejection. In the observed deployment, adding the inversion check alone caught the spliced-chain variant on the first request; adding the `Δ_max` bound eliminated the transient window at the cost of a small false-rejection rate (0.3%) during epoch gossip bursts, which was mitigated by jittering the gossip interval and by treating `Δ_max` as a per-deployment tunable rather than a constant. The deeper operational lesson: structural validity and revocation freshness are independent invariants, and a validator that checks only one will report "authorized" for credentials whose authority has already been withdrawn. The epoch field is not a timestamp to be compared loosely; it is a causal ordering claim that must be monotone along the chain and corroborable against local state within a declared bound. ```python class StaleRevocationEpochError(RuntimeError): """Raised when a delegated credential is structurally valid but its revocation epoch cannot be corroborated against local state.""" class ChainEpochInversionError(StaleRevocationEpochError): """Raised when a child credential claims an epoch older than its parent.""" DELTA_MAX = 30 # seconds of tolerated epoch-cache staleness def validate_delegated_credential(chain, local_cached_epoch, now, issued_at): """ chain: list of hops, root-first. Each hop has .rev_epoch and .scope. Structural validation (signatures, scope subset, depth) is assumed done. """ # 1. Chain epoch monotonicity: child may not predate parent. for i in range(1, len(chain)): if chain[i].rev_epoch < chain[i - 1].rev_epoch: raise ChainEpochInversionError( f"hop {i} rev_epoch={chain[i].rev_epoch} < " f"parent rev_epoch={chain[i - 1].rev_epoch}: " f"possible credential splicing or replay" ) leaf = chain[-1] # 2. Leaf must not predate a revocation we already know about. if leaf.rev_epoch < local_cached_epoch: raise StaleRevocationEpochError( f"leaf rev_epoch={leaf.rev_epoch} < local_cached_epoch=" f"{local_cached_epoch}: credential predates known revocation" ) # 3. Leaf must not claim an epoch we cannot corroborate. if leaf.rev_epoch > local_cached_epoch + DELTA_MAX: raise StaleRevocationEpochError( f"leaf rev_epoch={leaf.rev_epoch} exceeds corroborable bound " f"{local_cached_epoch + DELTA_MAX}: unverifiable epoch" ) # 4. Root epoch must not be so old that staleness outlives the credential. root = chain[0] remaining_ttl = (issued_at + leaf.ttl) - now if (local_cached_epoch - root.rev_epoch) > remaining_ttl: raise StaleRevocationEpochError( f"root rev_epoch={root.rev_epoch} lags local_cached_epoch=" f"{local_cached_epoch} by more than remaining TTL " f"{remaining_ttl}s: acceptance would outlive propagation" ) return True ```