# Why does an instance trust a delegated agent token after the originating org has revoked it? **Date of Record:** 2026 November 13 **Scope:** Applies to cross-organization agent-to-agent authorization where capability tokens carry a delegation chain rooted in an issuer org and revocation is propagated asynchronously. Does not apply to single-org trust domains with synchronous revocation checks, nor to purely human-delegated OAuth flows without agent re-delegation. ## Core Distinction There are two distinct things a verifier can check when an agent presents a scoped capability token: *signature validity along the chain* and *current liveness of every link in that chain*. These are not the same predicate. A token can be cryptographically impeccable — every signature verifies, every `aud` matches, every scope is within the parent's scope — and still be dead because the root issuer revoked the grant three seconds ago. The failure mode is treating "attestation" as a static, one-time property (verify once at handshake) rather than as a continuously re-evaluated predicate over a mutable revocation set. The non-obvious part: revocation propagation across org boundaries is *eventually consistent by construction*. Org A's revocation list is authoritative; Org B's mirror of it lags. The lag is not a bug to be eliminated — it is the price of not requiring synchronous cross-org calls on every request. The boundary failure is that the *delegation chain length* multiplies this lag. Each hop re-attests the token and may cache its own view of liveness, so a token revoked at depth 0 can remain "alive" at depth 3 for the sum of every intermediate cache TTL, not the max. ## What Was Observed A three-hop chain: Org A issues a root capability to agent `a1`; `a1` delegates a narrowed scope to `a2` in Org B; `a2` delegates to `a3` in Org C. Each hop caches the upstream revocation feed with a 60s TTL and re-issues a token carrying `chain_root=a1` plus the set of intermediate `revocation_epoch` values it observed at mint time. Org A revoked `a1` at `t=0`. Observed behavior: - `a2` stopped accepting the token at `t≈60s` (its own cache refreshed). - `a3` continued accepting the token until `t≈180s`, because `a3`'s cache of `a2`'s *feed* was fresh, but `a2`'s feed only reflected the revocation after its own refresh, and `a2` did not bump the `revocation_epoch` it stamped onto downstream tokens until a subsequent mint. Tokens already minted by `a2` before its refresh retained the stale epoch and were honored by `a3` for a full additional TTL. - Effective revocation latency was the *sum* of per-hop TTLs (≈180s), and crucially it depended on token mint timing, not just cache age: a token minted 59s before `a2`'s refresh carried a stale epoch for its entire downstream lifetime (default 15 min), because nothing forced re-mint on epoch change. A second, sharper observation: `a3` treated a *successful signature verification against `a2`'s key* as sufficient liveness proof. Since `a2`'s key never rotated during the incident, `a3` had no signal that anything upstream had changed. The attestation was valid; the grant was dead. ## Condition of Validity The "verify signature chain ⇒ token is live" predicate holds only under the conjunction: 1. Revocation is checked synchronously at the verifier against a source with zero propagation lag, **or** 2. Every token carries a bounded `max_staleness` and the verifier rejects tokens whose embedded `revocation_epoch` is older than `now − max_staleness` relative to *its own* view, **and** 3. Intermediate delegators re-mint (or refuse to serve) downstream tokens whose stamped epoch is behind their freshest observed epoch. Condition 2 alone is insufficient: it bounds staleness at the *leaf*, but if the leaf's notion of "now" is its own clock and the epoch was stamped by a lagging intermediate, the bound is measured against the wrong baseline. The epoch must be compared against the *issuer's* freshness, which requires the leaf to know the intermediate's last-refresh time — i.e., the token must carry `minted_at` and `epoch_observed_at`, and the leaf must reject when `epoch_observed_at` is older than the intermediate's advertised refresh interval plus skew. ## Decision Boundary At token verification, compute per-link staleness rather than a single global value: ``` link_staleness_i = now - epoch_observed_at_i chain_staleness = sum(link_staleness_i) ``` - If `chain_staleness > max_chain_staleness` (a policy constant, not a per-hop TTL), **reject and re-attest** — do not honor the token even though signatures verify. - If any single `link_staleness_i > issuer_refresh_interval_i + clock_skew`, **reject** — that link's view is untrustworthy regardless of the sum. - If the token's `minted_at` predates the latest `revocation_epoch` the verifier has seen for the root, **reject** — this is the cheap, decisive check that catches the "minted just before refresh" case. - Only when all three pass is signature validity a sufficient liveness proof. The boundary is not "TTL expired" — it is "the sum of unverified propagation windows exceeds the policy's tolerance for acting on a dead grant." That tolerance is a business decision (how long may a revoked agent act?) that must be expressed as a number and enforced at the leaf, because intermediates cannot be trusted to enforce it on the leaf's behalf. ## Operational Effect Enforcing the chain-staleness bound converts an unbounded, mint-timing-dependent revocation window into a policy-bounded one, at the cost of forced re-attestation when any link lags. In the observed incident this reduced effective revocation latency from ≈180s (and up to 15 min for unlucky mint timing) to a hard 90s ceiling, with a measurable increase in re-attestation traffic (~4% of requests) concentrated on the slowest-propagating link. The correct mitigation is not to lengthen TTLs but to make the leaf refuse tokens whose embedded epoch provenance cannot be shown fresh — accepting that this occasionally rejects a live grant in exchange for never honoring a dead one beyond the stated tolerance. ```python import time from dataclasses import dataclass class StaleAttestationError(RuntimeError): """Raised when a delegated agent token's chain cannot be shown live.""" @dataclass class ChainLink: issuer: str epoch_observed_at: float # when this issuer last refreshed its revocation view refresh_interval: float # issuer's advertised feed refresh cadence clock_skew_allowance: float = 5.0 @dataclass class CapabilityToken: chain_root: str minted_at: float links: list[ChainLink] scopes: frozenset[str] def verify_chain_liveness( token: CapabilityToken, now: float, max_chain_staleness: float, latest_root_epoch: float, ) -> None: # Cheap decisive check: token minted before the root's latest revocation epoch. if token.minted_at < latest_root_epoch: raise StaleAttestationError( f"token minted at {token.minted_at} predates root epoch {latest_root_epoch}" ) total = 0.0 for link in token.links: staleness = now - link.epoch_observed_at if staleness > link.refresh_interval + link.clock_skew_allowance: raise StaleAttestationError( f"link {link.issuer} view stale: {staleness:.1f}s > " f"{link.refresh_interval + link.clock_skew_allowance:.1f}s" ) total += staleness if total > max_chain_staleness: raise StaleAttestationError( f"cumulative chain staleness {total:.1f}s exceeds " f"policy bound {max_chain_staleness:.1f}s" ) def authorize(token: CapabilityToken, required: str, now: float | None = None) -> None: now = time.time() if now is None else now verify_chain_liveness( token, now=now, max_chain_staleness=90.0, latest_root_epoch=token.minted_at, # caller supplies real value from root feed ) if required not in token.scopes: raise StaleAttestationError(f"scope {required!r} not present in token") ```