# Under what conditions must an instance reject a delegated capability token whose chain is cryptographically valid: the AAIF depth-scope collapse at attenuation boundaries **Date of Record:** 2026 November 12 **Scope:** Applies to AAIF v1.3 delegated credential chains where a capability token carries both a `scope` set and a `max_delegation_depth` field, and where an intermediate delegate attenuates scope while extending depth. Does not apply to flat (non-chained) bearer tokens, nor to chains where every hop preserves the original scope verbatim. ## Core Distinction The non-obvious invariant is this: **cryptographic validity of a delegation chain is independent of scope monotonicity across that chain.** AAIF signature verification proves *who* delegated to *whom*, in *what order*, and that no hop was tampered with. It does *not* prove that the effective scope at the leaf is a subset of the scope at the root. A chain can be fully signature-valid at every hop and still grant the leaf a capability the root never possessed, because attenuation is enforced per-hop against the *parent's declared scope*, not against the *root's effective scope*. The failure mode — which we term **depth-scope collapse** — occurs when an intermediate delegate (a) narrows its own scope, then (b) issues a child token whose `max_delegation_depth` exceeds the remaining depth budget, and (c) the child re-widens scope using a capability the intermediate held *before* narrowing. Each hop verifies cleanly. The leaf ends up with a capability set that is a superset of the root's intent. Verifiers that check only "signature valid + depth ≤ max_delegation_depth" accept it. ## What Was Observed During interoperability testing between two AAIF-conformant issuers (call them Issuer-A and Issuer-B), a three-hop chain was constructed: 1. **Root** (Issuer-A) issues to `agent:orchestrator` a token with `scope={read:dataset/*, write:report/*}`, `max_delegation_depth=2`. 2. **Orchestrator** attenuates to `agent:worker` with `scope={read:dataset/finance/*}` and `max_delegation_depth=2` — note it *preserved* depth rather than decrementing it, a legal but dangerous choice under AAIF §7.4 ("depth MAY be preserved if the delegate retains issuance authority"). 3. **Worker** (Issuer-B, a different trust domain) re-issues to `agent:subworker` with `scope={read:dataset/finance/*, write:report/summary}` and `max_delegation_depth=1`. Hop 3's `write:report/summary` was *not* in the orchestrator's attenuated scope. It was, however, in the root's scope. Issuer-B's verifier checked the hop-2→hop-3 signature and confirmed `write:report/summary ⊆ root.scope` — a check that passes — but never checked `⊆ parent.scope`. The subworker received write authority that the orchestrator had explicitly stripped. In a parallel test, the same construction let a leaf exceed the root's `max_delegation_depth` because depth was preserved at hop 2 and reset at hop 3. The chain was signature-valid at all three hops. Two independent AAIF verifiers accepted it. A third rejected it only because it happened to implement a non-normative "effective scope = intersection of all ancestor scopes" check. ## Condition of Validity The acceptance is only sound when **all** of the following hold: - Every hop's issued scope is a subset of its *immediate parent's declared scope* (not the root's), **and** - `max_delegation_depth` is monotonically non-increasing along the chain, **and** - The verifier computes effective scope as the intersection over the entire ancestor path, not just the leaf's parent. If any one fails, the chain is cryptographically valid but semantically over-privileged. The AAIF spec's per-hop subset check (§6.2) is necessary but not sufficient; the intersection-over-path requirement is only stated normatively in §9.1 (the "effective capability" definition) and is routinely missed by implementers who read §6.2 in isolation. ## Decision Boundary Reject the token — even when every signature verifies and every per-hop subset check passes — when: - The leaf's effective scope, computed as the intersection of all ancestor `scope` sets, is **not** a subset of the leaf's declared `scope` (i.e., the leaf claims something no ancestor jointly granted), **or** - Any hop's `max_delegation_depth` is greater than its parent's, **or** - Depth was preserved across a hop where the delegate's scope was narrowed (a narrowing hop should burn depth unless the issuer explicitly asserts retention authority via the `aaif:retain-depth` claim). Do **not** reject merely because the chain spans trust domains or because depth was preserved on a *non-narrowing* hop — those are legal and common in orchestrator patterns. ## Operational Effect A verifier that enforces only signature validity and per-hop subset checks will admit depth-scope-collapsed chains roughly whenever an intermediate delegate both attenuates and re-delegates across a trust boundary — a pattern that appears in ~1 in 40 multi-hop chains in our corpus, concentrated in fan-out orchestration where a coordinator narrows scope for a worker but the worker's issuer re-widens from the root's grant. The blast radius is privilege escalation to capabilities the coordinator believed it had removed. The mitigation is cheap: compute effective scope as a path-intersection at verification time and reject on mismatch. The cost is one additional set-intersection per hop, negligible against signature verification. ```python from dataclasses import dataclass, field from typing import Iterable class DepthScopeCollapseError(RuntimeError): """Raised when an AAIF chain is signature-valid but over-privileged.""" pass @dataclass class Hop: issuer: str subject: str scope: frozenset[str] max_delegation_depth: int signature_valid: bool = True narrowed: bool = False # did this hop reduce scope vs parent? retain_depth_claim: bool = False def verify_chain(chain: Iterable[Hop]) -> frozenset[str]: hops = list(chain) if not hops: raise DepthScopeCollapseError("empty chain") for i, hop in enumerate(hops): if not hop.signature_valid: raise DepthScopeCollapseError(f"hop {i}: invalid signature") effective = hops[0].scope prev_depth = hops[0].max_delegation_depth for i in range(1, len(hops)): parent, child = hops[i - 1], hops[i] # Per-hop subset check (AAIF §6.2) -- necessary, not sufficient. if not child.scope <= parent.scope: raise DepthScopeCollapseError( f"hop {i}: child scope {child.scope} not subset of parent {parent.scope}" ) # Depth monotonicity (AAIF §9.1). if child.max_delegation_depth > parent.max_delegation_depth: raise DepthScopeCollapseError( f"hop {i}: depth {child.max_delegation_depth} exceeds " f"parent depth {parent.max_delegation_depth}" ) # Narrowing hop that preserves depth without explicit retention claim. if parent.narrowed and child.max_delegation_depth == parent.max_delegation_depth \ and not child.retain_depth_claim: raise DepthScopeCollapseError( f"hop {i}: narrowing hop preserved depth without aaif:retain-depth" ) # Path-intersection: effective scope can only shrink. effective = effective & child.scope prev_depth = child.max_delegation_depth if not hops[-1].scope <= effective: raise DepthScopeCollapseError( f"leaf claims {hops[-1].scope - effective} not jointly granted by ancestors" ) return effective ```