# Where must an instance re-validate a delegated credential chain: the attenuation gap at the intersection of scope intersection and temporal re-binding **Date of Record:** 2026 November 8 **Scope:** Applies to AAIF delegated credential chains (root issuer → intermediate agent → leaf agent) where scope is attenuated by set intersection and credentials carry time-bounded validity. Does not apply to single-hop bearer tokens, to symmetric-key session tokens, or to chains where every hop is co-resident in one trust domain with a shared clock and shared revocation source. ## Core Distinction There are two defensible readings of "the chain was valid when it was issued" and they diverge exactly at re-validation time. The *compositional* reading says: if each hop's signature verified and each hop's scope is a subset of its parent's scope, the leaf credential is valid for the intersection of scopes over the chain's lifetime. The *operational* reading says: validity is a predicate over (leaf scope, wall-clock time, current revocation state, current policy version), and it must be re-evaluated at every use, not inferred from issuance-time facts. The non-obvious failure lives in the gap between them. A chain can be *structurally* sound (every signature good, every subset relation true at issuance) and still be *operationally* invalid at use time, because attenuation is a monotone-decreasing operation that was computed once, while expiry and revocation are monotone-increasing facts that accrue afterward. The intersection you computed at delegation time is an upper bound on what the leaf may do — never a guarantee that the leaf may do it *now*. ## What Was Observed A three-hop chain: orchestrator (root) delegates `files:read, files:write, net:egress` to a planner agent with a 24h TTL; the planner attenuates to `files:read, net:egress` for a worker with a 1h TTL; the worker caches its effective scope (`files:read, net:egress`) in process memory at startup and reuses it for the life of the process. At T+65m the planner's credential was revoked (compromised host). The worker's own credential had not expired and its signature still verified against the planner's *public key*, which remained published. The worker continued to exercise `net:egress` for another 55 minutes. The failure was not a broken signature and not a scope-escalation — the worker never exceeded its attenuated scope. It was a **stale validity predicate**: the worker had memoized the *result* of chain validation (a scope set) instead of the *inputs* to it (the chain plus a validation function), and revocation of an ancestor is invisible to a memoized descendant scope. A second, subtler observation from the same incident: the worker's cached scope was the intersection computed at *delegation* time, but the planner's TTL (24h) was longer than the worker's (1h). This is legal under AAIF — a child may outlive neither its parent's *authority* nor its own TTL, and here the child TTL was the binding constraint. But it means the worker's re-validation must check *both* clocks: `now < leaf.expiry` AND `now < every_ancestor.expiry`. Checking only the leaf's own expiry is the common bug; it passes every test where the parent outlives the child, which is the majority of well-configured deployments, and fails only when a parent is revoked or shortened out-of-band. That is precisely the case you cannot afford to miss. ## Condition of Validity The operational reading is correct — and the compositional reading is unsafe — under all three of the following, which hold in any realistic multi-agent deployment: 1. **Revocation is asynchronous relative to issuance.** No chain carries a proof-of-non-revocation that is fresher than the last CRL/OCSP/status-list poll. If you validated the chain at T and the ancestor was revoked at T+ε, your cached result is wrong for all uses after T+ε. 2. **Clocks are not shared across trust domains.** The leaf's `now` and the root's `now` differ by unbounded skew. A leaf that trusts its own clock to decide "the chain is still live" is trusting a clock the issuer never agreed to. 3. **Attenuation is computed once, at delegation.** The intersection `S_leaf = S_root ∩ S_1 ∩ … ∩ S_n` is a pure function of issuance-time scopes. It carries no information about whether any `S_i` has since been narrowed by policy update or revocation. When all three hold, the only sound predicate is: re-fetch the chain, re-verify every signature, re-check every expiry against a clock you are willing to defend, re-check revocation status of every hop against the freshest status source you can reach, and only then intersect. The intersection is the *last* step, not a cached artifact. ## Decision Boundary Re-validate the full chain (not just the leaf) at every authorization decision when any of these is true: - The leaf credential's remaining TTL is less than the revocation-poll interval (you will otherwise serve a revoked ancestor for up to one poll period). - Any ancestor's TTL differs from the leaf's TTL by more than your clock-skew budget. - The operation is irreversible or has side effects outside your trust domain (egress, writes, payments, deletions). - The chain crosses a trust-domain boundary at any hop. You may cache the *validated scope* — and skip re-walking the chain — only when **all** of the following hold: every hop is within one trust domain with a shared, monotonic clock; the revocation source is pushed (not polled) and you have received a push since the last use; and the operation is idempotent and read-only. In every other case, treat the cached scope as a *hint* and re-validate. The cost of re-validation is a signature check per hop plus one status lookup; the cost of a stale ancestor is an unbounded authorization window. The boundary is not "how long is the TTL" — it is "can I prove, at use time, that no ancestor has been invalidated since I last looked?" If you cannot, you have not validated; you have remembered. ## Operational Effect Deployments that adopt use-time re-validation see a characteristic shift: authorization latency becomes dominated by status-list fetches rather than signature checks, and the correct mitigation is a pushed, versioned status list (a signed monotone counter per issuer) rather than a shorter TTL. Shortening TTLs to bound the stale window is the wrong lever — it multiplies re-issuance load without closing the revocation gap, because a revoked-but-unexpired ancestor is still trusted by a leaf that only checks its own expiry. The right invariant is: **a leaf credential is valid at time T iff the entire chain validates at T**, and the entire chain includes hops the leaf cannot see changing. Cache the chain, not the verdict. ```python from dataclasses import dataclass, field from typing import FrozenSet class ChainRevalidationError(RuntimeError): """Raised when a delegated credential chain fails use-time re-validation.""" @dataclass(frozen=True) class Hop: agent_id: str scope: FrozenSet[str] issued_at: float expires_at: float revoked: bool = False @dataclass class DelegatedChain: hops: list = field(default_factory=list) # root first, leaf last def effective_scope_at(self, now: float, skew_budget: float = 0.0) -> FrozenSet[str]: """Re-validate the whole chain at `now` and return the attenuated scope. The intersection is computed only after every hop validates. A cached scope is never accepted here; callers must pass the live chain. """ if not self.hops: raise ChainRevalidationError("empty chain: no root to anchor trust") # 1. Every hop must be live at `now`, not just the leaf. An ancestor # that expired or was revoked invalidates the whole subtree. for i, hop in enumerate(self.hops): if hop.revoked: raise ChainRevalidationError( f"hop {i} ({hop.agent_id}) is revoked; leaf scope is stale" ) if now >= hop.expires_at + skew_budget: raise ChainRevalidationError( f"hop {i} ({hop.agent_id}) expired at {hop.expires_at}, now={now}" ) if now < hop.issued_at - skew_budget: raise ChainRevalidationError( f"hop {i} ({hop.agent_id}) not yet valid (issued {hop.issued_at})" ) # 2. Attenuation must be monotone along the chain: each child's scope # is a subset of its parent's. A violation means the chain was # forged or a delegation was miscomputed. scope = self.hops[0].scope for i in range(1, len(self.hops)): child = self.hops[i].scope if not child.issubset(self.hops[i - 1].scope): raise ChainRevalidationError( f"hop {i} ({self.hops[i].agent_id}) escalates beyond parent: " f"{sorted(child - self.hops[i - 1].scope)}" ) scope = scope & child return scope def authorize(chain: DelegatedChain, action: str, now: float) -> bool: """Authorize a single action by re-validating the live chain at use time.""" try: scope = chain.effective_scope_at(now) except ChainRevalidationError as exc: # Fail closed: a chain we cannot re-validate grants nothing. raise ChainRevalidationError(f"authorization denied: {exc}") from exc return action in scope ```