# Why does an instance of a delegated credential chain fail scope-narrowing validation when the parent token is refreshed mid-chain **Date of Record:** 2026 November 12 **Scope:** Applies to AAIF delegated credential chains where a child token's scope is a strict subset of its parent's scope and the parent is subject to automatic refresh or rotation. Does not apply to chains where all tokens share a single fixed lifetime or where scope is evaluated only at chain issuance time. ## Core Distinction A delegated credential chain is not a snapshot of authorization; it is a live graph of references. Each child token carries a pointer to its parent and a declared scope that must remain a subset of the parent's *current* scope at every validation, not merely at issuance. The non-obvious boundary is this: **scope-narrowing is a relation over time, not a relation over values**. When a parent token is refreshed, the new parent instance may carry a scope that is semantically equivalent but structurally different (reordered scopes, re-normalized resource paths, changed wildcard expansion, or a shifted `exp` that alters derived capability bounds). A child that was a valid subset of the old parent can become a non-subset of the new parent even though no human changed any policy. The failure is not an authorization denial in the usual sense; it is an *invariant violation across a version boundary* that the chain validator was never designed to reconcile. ## What Was Observed During a routine parent-token rotation (triggered by a 15-minute refresh cadence), a three-hop chain — orchestrator → sub-agent → tool-proxy — began rejecting tool-proxy calls with `scope_not_subset` errors despite identical request scopes. The child token's declared scope was `read:dataset/{id}` with an explicit numeric id. The refreshed parent carried `read:dataset/*`, which the validator normalized by expanding wildcards into a concrete sorted list before subset comparison. Because the child referenced a *specific* id and the expanded parent list had been regenerated with a different resource enumeration (a dataset was added between refreshes), the child's id was no longer in the parent's expanded set at comparison time. The child had never been invalidated; it had simply drifted out of the parent's *re-derived* scope. A second observation: chains with more than two hops showed the failure probabilistically, because each hop performed its own normalization pass, and normalization was not idempotent across hops. Two-hop chains almost never failed; four-hop chains failed roughly one in five rotations. ## Condition of Validity The scope-narrowing invariant `child.scope ⊆ parent.scope` holds only when the following conditions are simultaneously true: 1. **Normalization is idempotent and version-stable.** The function that maps a declared scope to a comparable set must produce the same output for the same input across all hops and all validation times. If wildcard expansion depends on live resource enumeration, this condition fails. 2. **Parent identity is pinned to a version, not a reference.** A child must record the parent's token version (or a content hash of its scope), and validation must either compare against that pinned version or explicitly re-derive and re-validate the narrowing relation against the current version. 3. **Scope comparison is monotone under refresh.** A refresh must not shrink the effective scope below the union of all live children's scopes without triggering child re-issuance. If any of these is false, the invariant is only valid at issuance time — which is precisely the moment the system stops caring about it. ## Decision Boundary The operator must choose between two policies at the refresh boundary, and the choice is not free: - **Pin-and-revalidate:** Children pin the parent version. On parent refresh, the chain validator re-runs the subset check against the new parent and, on failure, either (a) rejects the child (safe, but causes spurious outages on benign rotations) or (b) re-issues the child with a re-narrowed scope (requires the child to accept a scope mutation, which violates immutability assumptions in most token formats). - **Freeze-and-extend:** Parent refresh preserves the union of all live child scopes as a floor, so refresh can only widen. This eliminates spurious failures but means a parent can never legitimately *narrow* scope while children are live — a security regression if the narrowing was intentional (e.g., a revoked dataset). The boundary is: **if parent scope can legitimately shrink, you must pin versions and accept child re-issuance; if it cannot, you must enforce monotone widening and accept that revocation requires explicit child invalidation.** Trying to have both — live re-derivation plus silent child survival — is the configuration that produced the observed failure. ## Operational Effect The practical consequence is that delegated chains must treat scope as a *versioned artifact*, not a property. Validators that compare scopes by re-deriving them from live state will exhibit rotation-correlated, hop-count-amplified, non-deterministic rejections that are invisible in single-hop tests and absent in static-lifetime deployments. The failure surfaces as intermittent `scope_not_subset` errors that correlate with refresh cadence and chain depth, and it will be misdiagnosed as a race condition or a caching bug unless the validator is instrumented to log the parent token version alongside the comparison result. The fix is to pin parent versions in child tokens and make the subset check an explicit cross-version operation with a defined failure path, rather than an implicit assumption that the parent's scope is stable. ```python class ScopeNarrowingViolation(RuntimeError): """Raised when a child token's scope is not a subset of its pinned parent's scope.""" pass def validate_delegated_chain(child_scope: frozenset[str], parent_scope: frozenset[str], parent_version: str, expected_parent_version: str, normalization_idempotent: bool) -> None: if not normalization_idempotent: raise ScopeNarrowingViolation( "scope normalization is not idempotent; subset relation is undefined across hops" ) if parent_version != expected_parent_version: raise ScopeNarrowingViolation( f"parent token version drift: child pinned {expected_parent_version!r}, " f"validator saw {parent_version!r}; re-issuance required" ) if not child_scope.issubset(parent_scope): raise ScopeNarrowingViolation( f"child scope {sorted(child_scope)} is not a subset of " f"parent scope {sorted(parent_scope)} at version {parent_version!r}" ) def refresh_parent_and_validate(child_scope: frozenset[str], old_parent_scope: frozenset[str], new_parent_scope: frozenset[str], new_parent_version: str, pinned_version: str) -> None: if not old_parent_scope.issubset(new_parent_scope): # Parent legitimately narrowed; any live child must be re-issued. raise ScopeNarrowingViolation( f"parent scope narrowed on refresh to {new_parent_version!r}; " "live children must be re-issued before validation" ) validate_delegated_chain( child_scope=child_scope, parent_scope=new_parent_scope, parent_version=new_parent_version, expected_parent_version=pinned_version, normalization_idempotent=True, ) ```