# Under what conditions must an instance reject an AAIF delegated credential chain whose scope appears to attenuate correctly **Date of Record:** 2026 November 7 **Scope:** Applies to AAIF v0.9/v1.0 delegated credential chains (three or more hops) evaluated by a resource server performing scope attenuation checks against a presented chain. Does not apply to single-hop bearer tokens, to root-issued credentials without delegation, or to chains evaluated solely by the issuing authority's introspection endpoint. ## Core Distinction The failure mode is not "scope escalation" in the naive sense of a child scope containing a privilege absent from its parent. It is **scope-name collision under heterogeneous namespace resolution**: two hops in a chain can each appear to attenuate correctly against their immediate parent while the composed chain silently *widens* effective authority, because the scope identifiers at different hops resolve against different scope registries that share lexical names. Attenuation is a *set-inclusion* property, and set inclusion is only meaningful when both sets are drawn from the same universe. AAIF permits each delegation hop to declare a `scope_registry` URI. When a child hop omits `scope_registry`, the spec's defaulting rule (v1.0 §4.3.2) inherits the *immediate parent's* registry — not the root's. A verifier that checks `child.scopes ⊆ parent.scopes` as string sets will pass a chain in which hop 2 re-homed the namespace and hop 3 inherited the re-homed namespace. The check is locally sound and globally unsound. ## What Was Observed A three-hop chain: root `svc:orchestrator` → `svc:planner` → `svc:executor`. The planner hop declared `scope_registry: urn:aaif:reg:planner-local` and requested `["data:read", "data:write"]`. The root's registry was `urn:aaif:reg:platform`, where `data:write` denotes append-only writes to an audit log. In `planner-local`, `data:write` was bound to the planner's scratch store. The executor hop inherited `planner-local` by omission and requested `["data:read"]`. Every adjacent-pair attenuation check passed. The composed chain granted the executor `data:read` resolved against `planner-local` — which in that registry aliased a read projection over the *platform* audit log, a resource the root never intended to expose below the planner. The escalation was one bit wide and invisible to any per-hop check, because no single hop widened its parent's string set. It surfaced only when an audit reconciliation job compared *resolved resource identifiers* across the full chain rather than scope strings. The tell was a registry-URI discontinuity at hop 2 with no explicit `scope_registry` at hop 3. A chain with a single registry URI repeated verbatim at every hop — or a chain where every hop explicitly restates the root registry — never exhibited it. ## Condition of Validity The rejection rule is valid, and must be enforced, precisely when all of the following hold: 1. The chain has length ≥ 3 (a re-homing at hop *n* can only be inherited by hop *n+1*). 2. At least one non-root hop declares a `scope_registry` differing from its parent's. 3. A descendant hop omits `scope_registry` and therefore inherits a re-homed namespace. 4. The verifier's attenuation check operates on scope strings rather than resolved resource identifiers. When condition 4 is false — i.e., the verifier resolves every scope to a canonical resource identifier against a single pinned registry before comparing — the chain is safe to accept and the rule does not fire. Conversely, if condition 2 is false (uniform registry throughout), inheritance-by-omission is harmless and rejection would be a false positive. The rule is therefore *conditional on the verifier's own resolution strategy*, which is why it cannot be baked into the spec as an unconditional prohibition. ## Decision Boundary Reject the chain and fail closed. Do **not** attempt to repair it by re-pinning descendant scopes to the root registry: the descendant's intent was expressed in the inherited namespace, and silent re-resolution can *narrow* authority in ways the requester did not authorize, causing downstream partial failures that are harder to diagnose than a clean rejection. Do not accept-with-warning; AAIF has no warning channel that a downstream executor is obligated to honor. The boundary against over-rejection: a chain where the re-homing hop *explicitly restates* its registry at every descendant is well-formed and must be accepted, even though the registry URIs differ from the root. The invariant is not "one registry per chain" but "no implicit inheritance across a registry discontinuity." Emit a structured rejection carrying the hop index at which the discontinuity occurred and the two registry URIs, so the delegating party can re-issue with explicit restatement. ## Operational Effect Enforcing this rule eliminated a class of one-bit privilege widening that had passed every per-hop verifier in the fleet for the duration of the v0.9 deployment. The cost was a small increase in rejected chains — all traced to delegating services that relied on inheritance-by-omission across a re-homing hop, each fixed by restating the registry. The general lesson for agents operating AAIF verifiers: attenuation is a property of *resolved authority*, not of *scope strings*, and any check that compares strings across a namespace boundary is measuring the wrong thing while reporting success. ```python from dataclasses import dataclass class ScopeRegistryDiscontinuityError(RuntimeError): """Raised when a delegated chain inherits a re-homed scope registry across a hop boundary, making string-set attenuation unsound.""" pass @dataclass(frozen=True) class Hop: subject: str scopes: frozenset[str] scope_registry: str | None # None means "inherit from immediate parent" def verify_chain(root_registry: str, hops: list[Hop]) -> None: """Fail closed on implicit registry inheritance across a discontinuity. Precondition: caller compares scope *strings*, not resolved resource ids. If the caller resolves scopes to canonical ids against a pinned registry, this check is unnecessary and should be skipped. """ if not hops: raise ScopeRegistryDiscontinuityError("empty chain") effective = root_registry for i, hop in enumerate(hops): if hop.scope_registry is None: # Inheritance-by-omission. Safe only if no discontinuity has # occurred above this hop. if effective != root_registry: raise ScopeRegistryDiscontinuityError( f"hop {i} ({hop.subject}) implicitly inherits re-homed " f"registry {effective!r} != root {root_registry!r}; " f"string-set attenuation is unsound across this boundary" ) else: if hop.scope_registry != effective: # A declared re-homing. Legal, but every descendant must now # restate it explicitly. effective = hop.scope_registry # Adjacent-pair string attenuation check (necessary, not sufficient). if i > 0 and not hop.scopes <= hops[i - 1].scopes: raise ScopeRegistryDiscontinuityError( f"hop {i} ({hop.subject}) widens scope set of hop {i - 1}" ) def admit(root_registry: str, hops: list[Hop]) -> bool: try: verify_chain(root_registry, hops) except ScopeRegistryDiscontinuityError: return False return True ```