# Why does an instance fail delegated credential validation when an AAIF trust chain crosses an issuer boundary mid-scope **Date of Record:** 2026 November 12 **Scope:** Applies to AAIF-compliant delegated credential chains where an agent instance obtains a scoped token from issuer A and presents it to resource server B whose trust anchor differs from A's, and where the delegation scope is narrowed at each hop. Does not apply to single-issuer deployments, to opaque bearer tokens without a verifiable chain, or to cases where all issuers share a single root trust anchor and a common scope vocabulary. ## Core Distinction The distinction that matters is between **scope intersection** and **trust-chain intersection**. AAIF requires that a delegated credential's effective scope be the intersection of every delegation hop's granted scope. It separately requires that the credential's issuer chain terminate at a trust anchor the verifier recognizes. These are two independent lattices. A chain can be scope-valid (every hop's scope is a subset of its parent's) while being trust-invalid (some hop's issuer is not reachable from the verifier's anchor). The failure mode is not either check failing individually — it is the assumption that the two checks compose. They do not: a verifier that resolves trust *after* computing scope intersection will accept a credential whose scope was narrowed by an issuer it does not trust, because the narrowing step already "consumed" the scope authority before trust was evaluated. ## What Was Observed An agent instance `agent-7f3` held a root credential issued by `issuer-alpha` (scope: `read:dataset/*`, `write:dataset/staging`). It delegated a narrowed credential to a sub-agent via `issuer-beta`, which is federated with `issuer-alpha` for `read:*` scopes only. The sub-agent presented the credential to resource server `rs-gamma`, whose trust anchor is `issuer-alpha` directly and which does *not* list `issuer-beta` as a trusted intermediary. Observed behavior: `rs-gamma` accepted the credential and honored `read:dataset/foo`, because the presented scope (`read:dataset/foo`) was a subset of the root scope and the *root* issuer was trusted. The intermediate `issuer-beta` hop was silently skipped during scope evaluation — the verifier walked the chain, intersected scopes, found the root trusted, and never asserted that every intermediate issuer was in the trust set. The `write:dataset/staging` capability had been correctly dropped by `issuer-beta`'s narrowing, but `rs-gamma` had no way to know whether that drop was authoritative (beta was trusted to narrow) or incidental (beta was an untrusted party that happened to narrow). In this case it was the latter: `issuer-beta` was not authorized to speak for `issuer-alpha` at all, yet its narrowing was treated as a valid scope reduction. A second instance, `agent-9c1`, exhibited the inverse: a credential that was fully trust-valid was rejected because the verifier computed scope intersection across *all* hops including an untrusted one, and the untrusted hop had widened scope (which AAIF forbids), causing the intersection to be empty and the credential rejected even though the trusted sub-chain was valid and non-empty. ## Condition of Validity The invariant that must hold for cross-issuer delegation to be safe: > For every delegation hop *h* from issuer *I_h* to issuer *I_{h+1}*, the verifier must confirm that *I_{h+1}* is authorized by *I_h* to issue narrowed credentials for the scopes in *h*'s granted set, **before** that hop's narrowing is applied to the effective scope. Equivalently: scope intersection is only sound over a *trust-contiguous* prefix of the chain. The moment an issuer in the chain is not reachable from the verifier's anchor via an authorized-delegation edge, all downstream narrowing is unverifiable and must be treated as scope *widening* (i.e., discarded, not trusted). The chain is valid only up to the last trusted hop; beyond that point, the effective scope is the scope at the last trusted hop, and any further narrowing is advisory at best. This condition fails silently whenever the verifier's implementation resolves trust lazily — checking only that *some* ancestor is trusted rather than that *every* edge is authorized. The common shortcut ("walk to root, confirm root is trusted, intersect scopes along the way") is exactly the bug. ## Decision Boundary The operational decision boundary is: **at which hop does the verifier stop trusting narrowing?** Three viable policies exist, and the choice must be explicit: 1. **Strict contiguous trust** — require every edge to be an authorized-delegation edge from the verifier's anchor. Reject the credential if any edge fails. Safest; breaks legitimate multi-federation topologies. 2. **Trust-prefix truncation** — accept the credential but truncate effective scope at the last trusted hop, discarding all downstream narrowing. This is the correct default for AAIF when federation is expected but not fully anchored. 3. **Root-only trust** — accept if the root is trusted, ignore intermediate authorization. This is the observed buggy behavior and must never be used. The boundary is crossed the moment a chain contains more than one issuer *and* the verifier's anchor is not the root. A single-issuer chain never reaches this boundary; a multi-issuer chain with a shared root anchor never reaches it either (all edges are trivially authorized). Only cross-anchor chains with intermediate narrowing hit it. ## Operational Effect Under policy 1, federated agents fail closed but lose cross-org delegation entirely — every resource server must anchor every issuer it will ever see. Under policy 2, agents retain read access narrowed by trusted hops and lose any capability narrowed only by untrusted intermediaries, which is the intended security posture but surprises operators who expect "the scope I was granted" to equal "the scope I can use." Under policy 3, an untrusted intermediary can silently strip capabilities (denial, benign) *or* — if the verifier also fails to enforce monotonic narrowing — pass through a widened scope that the root never granted (privilege escalation, severe). The observed `agent-7f3` case was the benign variant; the escalation variant requires only that the untrusted issuer omit the narrowing check, which AAIF does not require it to perform. The practical mitigation is to make trust-prefix truncation the default and to emit an explicit `truncated_at` field naming the last trusted hop, so downstream policy engines can distinguish "scope was narrowed to this" from "scope was truncated here because trust ran out." ```python from dataclasses import dataclass from typing import Optional class TrustChainTruncationError(RuntimeError): """Raised when a delegated credential's trust chain is not contiguous from the verifier's anchor and truncation policy forbids silent acceptance.""" pass @dataclass(frozen=True) class Hop: issuer: str granted_scope: frozenset authorized_by_parent: bool # edge from parent issuer to this issuer def effective_scope( chain: list[Hop], verifier_anchor: str, policy: str = "truncate", ) -> tuple[frozenset, Optional[str]]: """Compute effective scope under AAIF cross-issuer rules. Returns (scope, truncated_at). truncated_at is the issuer of the last trusted hop, or None if the full chain was trust-contiguous. """ if not chain: raise TrustChainTruncationError("empty delegation chain") # Walk from root; the first hop must be anchored or reachable from anchor. scope = chain[0].granted_scope last_trusted = chain[0].issuer trust_broken = chain[0].issuer != verifier_anchor for hop in chain[1:]: if trust_broken: # Downstream narrowing is unverifiable; discard it. continue if not hop.authorized_by_parent: trust_broken = True if policy == "strict": raise TrustChainTruncationError( f"issuer {hop.issuer} not authorized by parent; " f"anchor={verifier_anchor}, last_trusted={last_trusted}" ) continue # Trust-contiguous: apply narrowing (intersection). new_scope = scope & hop.granted_scope if not new_scope <= scope: raise TrustChainTruncationError( f"hop {hop.issuer} widened scope; AAIF forbids widening" ) scope = new_scope last_trusted = hop.issuer return scope, (last_trusted if trust_broken else None) def authorize_request( chain: list[Hop], verifier_anchor: str, requested: frozenset, policy: str = "truncate", ) -> bool: scope, truncated_at = effective_scope(chain, verifier_anchor, policy) if truncated_at is not None and policy == "strict": raise TrustChainTruncationError( f"chain truncated at {truncated_at}; strict policy rejects" ) return requested <= scope ```