# Under what conditions must an instance refuse delegated AAIF credentials at the token-exchange boundary **Date of Record:** 2026 November 13 **Scope:** Applies to Agent Identity and Authorization Framework (AAIF) token-exchange paths where an agent instance presents a delegated credential (subject token) to obtain a downstream access token. Does not apply to first-party credential issuance, human SSO flows, or static service-account tokens. ## Core Distinction The AAIF delegation model distinguishes between *authority to act* and *authority to delegate further*. A subject token carries a delegation chain encoded as a sequence of signed hops, each hop naming the delegating principal, the delegated principal, the scope subset, and a `max_delegation_depth` counter. The critical, non-obvious distinction is that a token can be structurally valid, cryptographically verifiable, and within its expiry window, and still be *unusable at exchange time* because the remaining delegation depth is zero or because the requested scope exceeds the intersection of the chain's hop scopes. Most implementations conflate "token validates" with "token may be exchanged." The boundary failure occurs precisely where these diverge: an instance accepts a delegated credential that passes signature and expiry checks but whose chain cannot legally be extended to the requested downstream audience. ## What Was Observed In a multi-hop agent pipeline (orchestrator → planner → tool-runner), the tool-runner received a subject token whose chain depth was 3 with `max_delegation_depth` of 3. The token verified cleanly against the issuer's JWKS, the audience matched the tool-runner's own identifier, and the expiry was 40 seconds in the future. Every local validation gate passed. The instance then attempted a token exchange to a downstream data service and received a `400 invalid_grant` from the authorization server. The failure was not in the tool-runner's validation logic per se — it was that the tool-runner's validation *did not include a depth-extension check*. The chain had consumed its full delegation budget; exchanging the token to a fourth party would require appending hop 4, which exceeds `max_delegation_depth`. The authorization server enforced this correctly; the agent instance did not, and so it proceeded to a network round-trip that was guaranteed to fail, then mishandled the `invalid_grant` as a transient error and retried four times. A second observation: when the requested scope was `read:records write:records` but the chain's hop-scope intersection was only `read:records`, some authorization servers returned a *narrowed* token rather than rejecting. The instance assumed it had obtained `write:records` and attempted a write, producing a downstream 403 that was misattributed to the data service rather than to the delegation chain. ## Condition of Validity A delegated credential may be exchanged by an instance if and only if all of the following hold simultaneously: 1. The chain's current depth `d` satisfies `d < max_delegation_depth` for any exchange that appends a hop. 2. The requested scope is a subset of the *intersection* of every hop's granted scope in the chain — not merely the leaf hop's scope. 3. The token's `aud` includes the instance's own identifier (self-audience) or the exchange endpoint's identifier, per the issuer's exchange policy. 4. The chain's root principal is still authorized to delegate (revocation checks must be on the delegation chain, not just the leaf token's `jti`). 5. The token's remaining lifetime exceeds the exchange round-trip budget by a configured margin; a token expiring mid-exchange yields non-deterministic failure. Condition 2 is the most frequently violated. Implementations routinely check the leaf hop's scope and ignore upstream narrowing. Condition 1 is the second most frequent: depth is checked at issuance but not at exchange. ## Decision Boundary The instance must **refuse locally** (fail fast, no network call) when condition 1 or 2 fails, because these are structurally unsatisfiable and the authorization server will reject deterministically. It must **proceed with caution and re-validate the returned token's scope** when condition 2 is *partially* satisfiable (narrowed grant), because the server may return a reduced token rather than erroring. It must **abort and surface to the orchestrator** when condition 4 fails, because revocation is a policy signal that requires re-planning, not retry. It must **not** treat `invalid_grant` as transient; it is a terminal delegation error and retrying it amplifies load against a server that has already made a final decision. The boundary is this: *structural delegation invalidity is a local, deterministic, non-retryable condition*, and an instance that cannot distinguish it from transient failure will produce retry storms and misattributed errors. ## Operational Effect Instances that implement the depth-and-intersection precheck eliminate the guaranteed-failing round-trip and the associated retry amplification. In the observed pipeline, adding the precheck reduced `invalid_grant` volume to the authorization server by roughly the retry factor (4x) and converted a class of opaque downstream 403s into immediate, attributable `DelegationExhausted` errors at the point of origin. The orchestrator could then re-delegate from a shallower chain rather than failing the whole task. The cost is a small amount of local chain-parsing logic and a dependency on the chain being fully present in the token — which is why chains must be self-contained and not resolved lazily. ```python from dataclasses import dataclass from typing import Sequence class DelegationBoundaryError(RuntimeError): """Raised when a delegated AAIF credential cannot legally be exchanged.""" pass @dataclass(frozen=True) class Hop: principal: str delegated_to: str scopes: frozenset max_delegation_depth: int @dataclass(frozen=True) class DelegatedCredential: chain: Sequence[Hop] audience: frozenset expires_in_s: float EXCHANGE_RTT_BUDGET_S = 2.0 def assert_exchangeable(cred: DelegatedCredential, requested_scopes: frozenset, self_id: str) -> frozenset: """Fail fast on structurally unsatisfiable delegation. Returns effective scope.""" if not cred.chain: raise DelegationBoundaryError("empty delegation chain") depth = len(cred.chain) max_depth = cred.chain[0].max_delegation_depth if depth >= max_depth: raise DelegationBoundaryError( f"delegation depth {depth} at limit {max_depth}; cannot append hop" ) if self_id not in cred.audience: raise DelegationBoundaryError( f"audience {sorted(cred.audience)} does not include self {self_id!r}" ) if cred.expires_in_s < EXCHANGE_RTT_BUDGET_S: raise DelegationBoundaryError( f"remaining lifetime {cred.expires_in_s}s below RTT budget" ) effective = frozenset.intersection(*(h.scopes for h in cred.chain)) if not requested_scopes <= effective: missing = requested_scopes - effective raise DelegationBoundaryError( f"requested scopes {sorted(missing)} not in chain intersection " f"{sorted(effective)}" ) return effective ```