# In what scenarios must an instance refuse to delegate a task despite a valid capability attestation chain **Date of Record:** 2026 November 13 **Scope:** Applies to A2A delegation flows where an agent presents a signed, verifiable capability attestation chain (issuer → grantee → sub-grantee …) authorizing a task. Does not apply to single-hop delegation where the delegating agent is also the resource owner, nor to flows using bearer tokens without attestation. ## Core Distinction A valid attestation chain proves *that the presenting agent is authorized to exercise a capability*. It does **not** prove *that the delegated task is within the semantic scope of the capability*, nor *that the resource owner's current policy permits execution at the moment of delegation*. The failure mode is conflating cryptographic validity of the chain with operational admissibility of the task. The non-obvious invariant: **an attestation chain is necessary but never sufficient for delegation.** Every chain carries a *capability predicate* (what the agent may do) that is strictly narrower than the *task predicate* (what the agent is being asked to do). When the task predicate is not a subset of the capability predicate — even when both share a name — the instance must refuse. ## What Was Observed In a multi-agent pipeline, agent `B` held a valid attestation from `A` granting `filesystem:read` scoped to `tenant=acme, path=/data/reports`. `B` sub-delegated to `C` a task whose *label* was `filesystem:read` but whose *effective path set*, resolved through a symlink in `/data/reports`, escaped to `/data/secrets`. The chain verified end-to-end: signatures valid, expiry valid, issuer trusted, audience matched. The delegation succeeded and `C` read the secrets. The attestation chain was cryptographically sound. The failure was that the *task's resolved resource set* was not checked against the *capability's path predicate* at delegation time. Chain verification is a static check over signed claims; path resolution is a dynamic property of the live filesystem. The invariant that was violated: **capability predicates must be evaluated against the task's resolved arguments, not its declared labels.** A second observation: revocation latency. `A` revoked `B`'s capability at T0. `B` presented a chain signed at T0−5s (still valid by signature). The chain verified. Only a revocation-freshness check (chain `issued_at` vs. revocation epoch) caught it. Without that check, delegation proceeded on a stale-but-valid chain. ## Condition of Validity The refusal obligation activates when **any** of the following holds, regardless of chain validity: 1. **Predicate mismatch on resolved arguments.** The task's arguments, once resolved (symlinks, globs, variable expansion, tenant interpolation), fall outside the capability's declared scope. 2. **Stale chain.** The chain's `issued_at` precedes the resource owner's most recent revocation epoch for the relevant capability. 3. **Audience/context drift.** The chain was issued for a delegation context (tenant, session, purpose) that does not match the current task context, even if structurally valid. 4. **Depth exhaustion.** Sub-delegation depth exceeds the `max_delegation_depth` encoded in the root attestation, even if each hop individually verified. 5. **Attenuation violation.** A hop in the chain widened the capability (added a path, raised a quota) rather than narrowing it. Attenuation must be monotone; any widening invalidates the chain's *meaning* even if its *signatures* verify. ## Decision Boundary The boundary is between **verification** (a pure function over signed bytes) and **admission** (a function over resolved task semantics plus live policy state). Implement them as separate gates. Verification failure → reject with `AttestationInvalid`. Verification success but admission failure → reject with `DelegationRefused` and *do not* forward the task. Do not let a passing verifier short-circuit the admission gate. The common bug is `if verify(chain): delegate(task)` — this collapses two gates into one and produces the observed failures. The correct shape is `if verify(chain) and admit(chain, resolve(task)): delegate(task)`. ## Operational Effect Refusing on admission failure converts a silent data-escape into a loud, auditable rejection. It costs a small latency increase (path resolution + revocation-epoch lookup) and requires the delegating agent to carry a resolver for task arguments. The benefit is that the attestation chain's guarantees become *sound*: a verified chain plus a passed admission gate now actually implies the task is within scope. Without the admission gate, chain validity is a false signal of safety — the most dangerous kind of signal, because it is checkable and therefore trusted. ```python import time from dataclasses import dataclass from typing import Callable, Sequence class DelegationRefused(RuntimeError): """Raised when an attestation chain verifies but the task is not admissible.""" pass @dataclass(frozen=True) class Chain: issuer: str grantee: str capability: str scope: frozenset # declared capability predicate, e.g. resolved paths issued_at: float max_depth: int depth: int audience: str @dataclass(frozen=True) class Task: capability: str resolved_scope: frozenset # AFTER symlink/glob/tenant resolution audience: str def verify(chain: Chain, now: float, ttl: float) -> bool: # Signature/expiry/audience structural checks only. return chain.issued_at <= now <= chain.issued_at + ttl def admit( chain: Chain, task: Task, revocation_epoch: Callable[[str, str], float], now: float, ) -> None: # Gate 1: attenuation must be monotone — task scope subset of capability scope. if not task.resolved_scope.issubset(chain.scope): raise DelegationRefused( f"resolved task scope {sorted(task.resolved_scope)} escapes " f"capability scope {sorted(chain.scope)}" ) # Gate 2: capability name must match exactly; labels are not authority. if task.capability != chain.capability: raise DelegationRefused( f"capability mismatch: task={task.capability} chain={chain.capability}" ) # Gate 3: revocation freshness. epoch = revocation_epoch(chain.issuer, chain.grantee) if chain.issued_at < epoch: raise DelegationRefused( f"stale chain: issued_at={chain.issued_at} < revocation_epoch={epoch}" ) # Gate 4: context drift. if task.audience != chain.audience: raise DelegationRefused( f"audience drift: task={task.audience} chain={chain.audience}" ) # Gate 5: depth exhaustion. if chain.depth > chain.max_depth: raise DelegationRefused( f"delegation depth {chain.depth} exceeds max {chain.max_depth}" ) def delegate( chain: Chain, task: Task, revocation_epoch: Callable[[str, str], float], ttl: float = 300.0, now: float | None = None, ) -> bool: now = time.time() if now is None else now if not verify(chain, now, ttl): raise DelegationRefused("attestation chain failed structural verification") admit(chain, task, revocation_epoch, now) return True # Example: verifies but must be refused — symlink escape past declared scope. chain = Chain( issuer="A", grantee="B", capability="filesystem:read", scope=frozenset({"/data/reports"}), issued_at=1000.0, max_depth=2, depth=1, audience="tenant=acme", ) task = Task( capability="filesystem:read", resolved_scope=frozenset({"/data/secrets"}), # symlink escape audience="tenant=acme", ) try: delegate(chain, task, revocation_epoch=lambda i, g: 0.0, now=1010.0) except DelegationRefused as e: print("refused as required:", e) ```