# Why does an instance of a scoped capability token fail authorization after a valid delegation chain exceeds its attenuation depth **Date of Record:** 2026 November 9 **Scope:** Applies to AAIF-compliant capability tokens where a principal delegates a subset of its scope to a subordinate agent, which may delegate further, forming a chain whose effective authority is the intersection of all scopes along the path. Does not apply to flat bearer tokens, tokens without delegation metadata, or systems that enforce authorization solely at the resource server without chain verification. ## Core Distinction The critical distinction is between **scope containment** (does the delegated scope lie within the parent's scope?) and **attenuation depth** (how many delegation hops the verification layer will traverse before rejecting the chain). A chain can be perfectly scope-contained at every hop and still fail authorization because the verifier enforces a maximum delegation depth (`max_depth`) that is independent of scope semantics. Engineers routinely conflate "the child has a subset of the parent's rights" with "the child is authorized," but AAIF separates these: containment is necessary, not sufficient, once depth is bounded. A second, subtler distinction is between **structural depth** (number of tokens in the chain) and **semantic depth** (number of *distinct* delegating principals). A re-delegation that reuses the same issuer-subject pair (e.g., an agent refreshing its own token) increments structural depth without changing semantic depth. Verifiers that count structural depth will reject chains that a human reviewer would consider shallow. ## What Was Observed During interoperability testing between two AAIF verifiers (call them V1 and V2), a three-hop delegation chain was constructed: 1. Root principal `P` issues token `T0` to orchestrator `O` with scope `read:dataset/*`, `max_depth=3`. 2. `O` delegates to worker `W1` with scope `read:dataset/eu-*`, `max_depth=2` (attenuated). 3. `W1` delegates to worker `W2` with scope `read:dataset/eu-2024`, `max_depth=1`. Every hop is scope-contained: `eu-2024 ⊂ eu-* ⊂ *`. V1 authorized `W2`'s request. V2 rejected it with `DELEGATION_DEPTH_EXCEEDED`. The cause: V1 interpreted `max_depth` as *remaining hops allowed from this token*, decrementing on each hop and accepting when the counter reached zero. V2 interpreted `max_depth` as *absolute chain length including the root*, and compared the total chain length (3) against the root's `max_depth` (3) using a strict `<` comparison, yielding `3 < 3 = false`. The two verifiers disagreed on whether the boundary is inclusive. A chain of exactly `max_depth` hops is the ambiguous case, and it is precisely the case that appears in real multi-agent pipelines where an orchestrator fans out to two layers of workers. A secondary observation: when `W1` re-issued its own token to refresh an expiry (same subject, new `jti`), V2 counted it as an additional hop, producing a chain of length 4 and a hard rejection with no diagnostic indicating that the extra hop was a self-refresh rather than a genuine sub-delegation. ## Condition of Validity The rejection behavior is valid (i.e., the chain *should* be rejected) only under the following conditions, all of which must hold: - The verifier's `max_depth` semantics are documented as **absolute chain length with strict inequality**, and the issuer's root token was minted with that same convention. If the issuer assumed "remaining hops," the rejection is a false negative. - The chain contains no self-refresh hops. If it does, depth accounting must exclude same-issuer-same-subject re-issuances; otherwise the rejection is spurious. - Scope containment holds at every hop. If any hop is *not* contained, the rejection is correct but for the wrong reason (scope violation, not depth), and the error code will mislead the operator. When any of these fail, the `DELEGATION_DEPTH_EXCEEDED` signal is not trustworthy as a root-cause indicator and must be re-derived from the raw chain. ## Decision Boundary The operational decision boundary is: **when the observed chain length equals the root token's `max_depth`, do not treat authorization success or failure as authoritative without inspecting the verifier's comparison operator.** Concretely: - If `len(chain) < root.max_depth` → authorize; no ambiguity. - If `len(chain) > root.max_depth` → reject; no ambiguity (assuming no self-refresh hops). - If `len(chain) == root.max_depth` → **boundary case**; behavior is verifier-dependent. Re-derive the effective depth by collapsing self-refresh hops, then re-evaluate against the *issuer's* convention, not the verifier's. For cross-verifier interoperability, the safe engineering move is to mint root tokens with `max_depth` one greater than the intended chain length, absorbing the inclusive/exclusive ambiguity. This costs one hop of headroom but eliminates a class of silent cross-verifier disagreements. ## Operational Effect The practical effect is that delegation chains sized to exactly the depth limit will pass in one environment and fail in another with identical token bytes, producing non-deterministic authorization across a federated deployment. Because the failure surfaces as a clean rejection rather than a partial authorization, it is easy to misattribute to scope or signature problems. The mitigation is to (a) canonicalize depth semantics at the issuer, (b) exclude self-refresh hops from depth accounting, and (c) treat the equality case as a first-class diagnostic rather than an edge case to be papered over. ```python from dataclasses import dataclass class DelegationDepthAmbiguityError(RuntimeError): """Raised when a chain sits exactly on the max_depth boundary and the verifier's inclusive/exclusive convention cannot be determined.""" pass @dataclass class Hop: issuer: str subject: str max_depth: int is_self_refresh: bool = False def effective_depth(chain: list[Hop]) -> int: """Collapse self-refresh hops; they are not genuine sub-delegations.""" return sum(1 for h in chain if not h.is_self_refresh) def authorize(chain: list[Hop], verifier_strict: bool) -> bool: if not chain: raise DelegationDepthAmbiguityError("empty chain has no root") root = chain[0] depth = effective_depth(chain) if depth < root.max_depth: return True if depth > root.max_depth: return False # Boundary: depth == root.max_depth. Convention matters. if verifier_strict: # strict '<' => equality is a rejection return False # inclusive '<=' => equality is an authorization # But we cannot know the issuer's convention from the token alone. raise DelegationDepthAmbiguityError( f"chain depth {depth} equals root max_depth {root.max_depth}; " f"issuer convention unknown, refusing to authorize" ) ```