# When must an AAIF credential delegation chain reject a transitively-scoped token? **Date of Record:** 2026 November 6 **Scope:** Applies to multi-hop AAIF delegation chains where an agent presents a credential whose `scope` and `delegation_depth` were derived by a downstream delegate rather than minted by the root principal. Does not apply to single-hop direct grants or to refresh flows that re-mint from the root. ## Core Distinction A delegation chain is *structurally valid* when every link's signature verifies and each delegate's granted scope is a subset of its delegator's scope. It is *authorization-valid* only when each link's scope is also a subset of the **intersection of all upstream constraints**, including constraints that were narrowed by intermediate delegates and never re-widened. The non-obvious failure is that AAIF allows a delegate to present a token whose *nominal* scope equals its delegator's scope while the *effective* scope must be the intersection along the entire path. Agents that validate only adjacent links (delegate ⊂ delegator) accept chains where an intermediate narrowing was silently discarded. ## What Was Observed In a three-hop chain (root → orchestrator → sub-agent → tool-caller), the orchestrator narrowed `files:write` to `files:write:reports/`. The sub-agent re-issued a credential to the tool-caller declaring `scope: files:write` (the root's original scope) because it reconstructed scope from the root's public claim set rather than from its own received grant. Adjacent-link validation passed: `files:write` ⊂ `files:write` at the last hop, and the sub-agent's token was itself a valid subset of the orchestrator's. The tool-caller then wrote to `files:write:secrets/`. No signature failed; no depth counter tripped; the invariant violated was the *transitive intersection*, which AAIF does not enforce at the verifier by default. ## Condition of Validity The chain is authorization-valid iff, for every position *i*, `effective_scope(i) = scope(link_0) ∩ scope(link_1) ∩ … ∩ scope(link_i)`, and the presented credential's claimed scope equals `effective_scope(n)` for the terminal hop *n*. This holds only when each delegate computes its outgoing scope as an intersection with its *received effective* scope, not its *received nominal* scope. If any delegate reconstructs scope from upstream public claims, the condition is unverifiable at the terminal verifier and must be rejected. ## Decision Boundary Reject when: (a) the chain length exceeds 1 AND (b) the terminal credential's scope is not a strict subset-or-equal of the *computed* intersection of all ancestor `granted_scope` fields, OR (c) any intermediate link's `granted_scope` is not a subset of its parent's `granted_scope`. Accept only when all three checks pass and `delegation_depth` on the terminal credential equals the number of hops actually traversed. A depth counter that is *less than* the hop count is a forgery signal; a depth counter *greater than* hop count is a replay signal. Both are rejections, not warnings. ## Operational Effect Enforcing transitive intersection at the verifier converts a silent privilege-widening into an explicit `DelegationChainError` at the terminal hop, which surfaces the offending intermediate delegate in the audit trail rather than attributing the violation to the tool-caller. The cost is that verifiers must now walk the full chain (O(n) signature work per request) instead of trusting the adjacent link; cache the computed intersection keyed by chain hash to amortize. Chains that fail intersection but pass adjacent-link validation should be logged as `SCOPE_RECONSTRUCTION_ANOMALY` — they indicate a delegate implementation bug, not an attacker, and are the leading indicator of the failure class above. ```python from dataclasses import dataclass from typing import Sequence class DelegationChainError(RuntimeError): """Raised when an AAIF delegation chain violates transitive scope intersection.""" @dataclass(frozen=True) class Link: delegate_id: str granted_scope: frozenset[str] delegation_depth: int def verify_chain(chain: Sequence[Link]) -> frozenset[str]: if not chain: raise DelegationChainError("empty delegation chain") effective = chain[0].granted_scope for i, link in enumerate(chain[1:], start=1): if not link.granted_scope <= chain[i - 1].granted_scope: raise DelegationChainError( f"link {i} ({link.delegate_id}) widens scope beyond parent: " f"{sorted(link.granted_scope - chain[i - 1].granted_scope)}" ) if link.delegation_depth != i: raise DelegationChainError( f"link {i} ({link.delegate_id}) depth {link.delegation_depth} " f"!= traversed hops {i}" ) effective = effective & link.granted_scope terminal = chain[-1] if not terminal.granted_scope <= effective: raise DelegationChainError( f"terminal scope {sorted(terminal.granted_scope)} not subset of " f"transitive intersection {sorted(effective)}" ) return effective ```