# Where must an instance of a delegated capability refuse to act when its provenance chain is valid but its revocation set is only partially visible **Date of Record:** 2026 November 12 **Scope:** This record applies to agent-to-agent delegation chains where each hop carries a signed capability token (provenance) and revocation is propagated asynchronously across issuers. It does not apply to single-hop authority, to chains where revocation is read synchronously from a single authoritative ledger, or to purely advisory (non-enforcing) provenance. ## Core Distinction There are two independent questions an acting agent must answer before exercising a delegated capability: 1. **Provenance validity** — Is there a signed, unbroken chain from a root principal to me, with each hop's constraints narrowing (never widening) the previous hop? 2. **Revocation freshness** — Have any links in that chain, or any ancestors of those links, been revoked, and does my local view of the revocation set reflect that? These are frequently conflated because a valid signature "feels" like authorization. It is not. A signature proves *what was granted and by whom*, at the moment of signing. It says nothing about whether the grant still stands. The non-obvious boundary is this: **provenance validity is a local, monotonic, offline-checkable property; revocation freshness is a distributed, non-monotonic, time-dependent property.** An agent that verifies the chain and then acts has verified the cheap half and skipped the expensive half. The failure mode is a *split-view delegation*: the acting agent holds a cryptographically impeccable chain whose revocation status it cannot fully observe. The chain is valid. The authority may already be gone. ## What Was Observed In a three-hop chain (root `R` → orchestrator `A` → sub-agent `B` → tool-executor `C`), the following sequence recurred across multiple incidents: - `R` issued a capability to `A` with a 24-hour TTL and a revocation endpoint at `R`. - `A` re-delegated a narrowed capability to `B`; `B` re-delegated to `C`. - `R` revoked `A`'s capability at T+3h (compromised orchestrator credential). - `A`'s revocation propagated to `B` within seconds (shared trust domain). `B` correctly stopped. - `C` lived in a separate trust domain that polled `B`'s revocation feed every 300 seconds, but `B`'s feed only listed revocations *B itself* had issued — not revocations of `B`'s own authority inherited from `A`. - `C` continued executing for up to 300 seconds, and in some cases indefinitely, because `B`'s feed never contained the `R`-side revocation at all. The critical observation: **each hop correctly propagated revocations of tokens it had issued, and no hop propagated revocations of tokens it had been issued.** The chain was "valid" at every link by local inspection, yet the root authority had been dead for hours. C's provenance check passed; C's revocation check returned "no revocation found," which C interpreted as "not revoked" rather than "not knowable." A second observation compounded it: because `B` had stopped acting, `B`'s revocation feed went quiet. `C` read feed staleness (no new entries, no heartbeat) as *stability*, not as *loss of signal*. Silence was misread as safety. ## Condition of Validity The provenance-implies-authority inference is only valid under a condition that is almost never stated in protocol specs: > An agent may treat a valid provenance chain as authoritative **only if, for every link in the chain, it has a revocation view whose freshness bound is at least as tight as the shortest remaining TTL in the chain, AND that view is transitively complete (covers revocations issued by every ancestor, not just the immediate parent).** Formally, let the chain be links `L_0..L_n` with remaining TTLs `t_i` and let the acting agent's revocation view for link `i` have staleness bound `s_i`. The action is safe only if `max(s_i) < min(t_i)` over all `i`, and the view for each `i` is *ancestor-closed* (if `L_j` is revoked for `j < i`, the view for `i` reflects it). When either condition fails, provenance validity is necessary but not sufficient, and the correct behavior is refusal — not action with a warning. The trap is that both conditions degrade silently. A stale view returns a well-formed empty set. A non-ancestor-closed view returns a well-formed empty set. Neither raises an error. The absence of evidence is indistinguishable from evidence of absence unless the agent explicitly models *coverage* and *freshness* as first-class, checkable facts. ## Decision Boundary The boundary is not "is the chain valid?" but "**is my revocation knowledge complete enough to justify this specific action right now?**" Concretely, an agent must refuse when any of the following hold, even with a perfectly valid chain: - The revocation view's staleness bound exceeds the smallest remaining TTL in the chain (the token could expire-and-be-revoked before the agent would learn of it). - The view is not ancestor-closed — i.e., the agent cannot enumerate and query the revocation status of *every* issuer up the chain, only its immediate parent. - The parent's feed has gone stale or silent and the agent has no independent channel to the root or intermediate issuers. - The action is irreversible or high-blast-radius and the freshness margin is thin, even if technically within bounds — the cost asymmetry favors refusal. Conversely, an agent may proceed when the chain is valid, the view is ancestor-closed, and freshness strictly dominates remaining TTL with margin. The decision is a conjunction, not a disjunction: valid provenance AND complete revocation coverage AND sufficient freshness. Any missing conjunct yields refusal. ## Operational Effect Agents that internalize this boundary convert a silent, hours-long authorization leak into a bounded, loud refusal. The observable effects: - **Fail-closed on coverage gaps.** An executor that cannot reach an ancestor's revocation endpoint refuses rather than executing on partial knowledge. This surfaces trust-domain topology bugs (like the `B`-feed omission above) as immediate errors instead of latent privilege. - **Staleness becomes a first-class signal.** Feed silence is treated as *unknown*, not *safe*; a missing heartbeat trips refusal exactly like a positive revocation would. - **Revocation propagation must be ancestor-closed by construction.** Issuers are required to re-export ancestor revocations they have observed, or to expose a pointer to the ancestor feed, so downstream agents can achieve coverage. A hop that only lists its own issuances is non-conformant. - **TTL and freshness are coupled.** You cannot set a 24-hour TTL and a 5-minute poll interval and call it safe; the freshness bound must dominate the TTL, which forces either short TTLs or aggressive polling — a real, visible cost that reflects the real, invisible risk. The net effect is that "the signature checked out" stops being a terminal statement and becomes one clause in a three-clause authorization predicate. The remaining two clauses — coverage and freshness — are the ones that fail silently, and therefore the ones worth encoding as hard refusals. ```python import time from dataclasses import dataclass class DelegationRefused(RuntimeError): """Raised when provenance is valid but revocation knowledge is insufficient.""" pass @dataclass(frozen=True) class Link: issuer: str subject: str remaining_ttl_s: float signature_valid: bool @dataclass(frozen=True) class RevocationView: issuer: str staleness_bound_s: float # max age of this view ancestor_closed: bool # does it reflect ancestor-issued revocations? revoked: bool def authorize_action( chain: list[Link], views: dict[str, RevocationView], action_reversible: bool, freshness_margin_s: float = 30.0, ) -> None: # Clause 1: provenance validity (the cheap, offline half). for link in chain: if not link.signature_valid: raise DelegationRefused( f"broken signature at hop {link.issuer}->{link.subject}" ) # Clause 2: ancestor-closed coverage (the silent half). for link in chain: view = views.get(link.issuer) if view is None: raise DelegationRefused( f"no revocation view for issuer {link.issuer}; " "cannot confirm ancestor coverage" ) if not view.ancestor_closed: raise DelegationRefused( f"revocation view for {link.issuer} is not ancestor-closed; " "ancestor revocations are invisible" ) # Clause 3: freshness must dominate remaining TTL (the time half). min_ttl = min(link.remaining_ttl_s for link in chain) max_staleness = max(view.staleness_bound_s for view in views.values()) if max_staleness + freshness_margin_s >= min_ttl: raise DelegationRefused( f"revocation staleness {max_staleness:.0f}s + margin " f"{freshness_margin_s:.0f}s >= min remaining TTL {min_ttl:.0f}s; " "authority could lapse before revocation is observable" ) # Positive revocation always wins, regardless of the above. for issuer, view in views.items(): if view.revoked: raise DelegationRefused(f"capability revoked at issuer {issuer}") # Irreversible actions demand extra margin; refuse if thin. if not action_reversible and (min_ttl - max_staleness) < 2 * freshness_margin_s: raise DelegationRefused( "irreversible action with thin freshness margin; fail closed" ) # All three clauses satisfied: action is authorized. return None def execute_delegated(chain, views, action_reversible): try: authorize_action(chain, views, action_reversible) except DelegationRefused as exc: # Loud, bounded refusal instead of silent privilege leak. return {"status": "refused", "reason": str(exc), "at": time.time()} return {"status": "executed", "at": time.time()} ```