# Which invariants prevent silent scope widening in AAIF-scoped delegation chains for federated multi-agent task orchestration **Date of Record:** 2026 November 13 **Scope:** Applies to federated multi-agent orchestrations where a delegating agent (the *issuer*) grants an *audience* agent a capability token whose scope is expressed as an AAIF scope set (a set of `(action, resource-pattern, constraint)` triples) and where that audience may further delegate a subset of its authority. Does not apply to single-hop, non-delegable tokens, nor to systems where scope is enforced solely at the resource server without a delegation chain. ## Core Distinction The non-obvious boundary is between **scope intersection** (the correct semantic of delegation: a child's authority is the set-intersection of the parent's scope with the child's requested scope) and **scope union under pattern subsumption** (the failure mode: a child requests a scope whose resource pattern is a *superset* of the parent's pattern, and the enforcement layer, comparing only action names and normalized pattern strings, accepts it because the action matches and the pattern string is "not obviously broader"). Concretely: a parent token scoped to `read:tenant/acme/dataset/*` delegated to a child requesting `read:tenant/*/dataset/*` will pass a naive string-equality-plus-action check because the action `read` matches and the two patterns are different strings that neither equal nor trivially conflict. The child now holds authority over every tenant's dataset, not just `acme`'s. No error is raised. The widening is *silent*: the audit log records a successful `delegate` event with a scope that looks plausible. The invariant that prevents this is **pattern subsumption**, not pattern equality: for every triple `(a, p_child, c_child)` in the child's requested scope, there must exist a triple `(a, p_parent, c_parent)` in the parent's scope such that `p_child ⊆ p_parent` (every resource matched by `p_child` is also matched by `p_parent`) and `c_child ⊨ c_parent` (the child's constraint is at least as strict). Equality is a sufficient but not necessary condition; subsumption is the actual requirement. Systems that check equality are safe but over-restrictive; systems that check "not disjoint" are *unsafe and permissive*. ## What Was Observed In a three-tier federation (orchestrator → regional planner → worker pool), a regional planner issued a worker token for a batch job. The orchestrator's token to the planner was scoped to `write:region/eu-*/queue/batch`. The planner, implementing a retry path, re-delegated with scope `write:region/*/queue/batch` — a pattern that subsumes `eu-*` and additionally matches `us-*`, `ap-*`. The delegation service accepted it. The worker subsequently wrote to a `us-east` batch queue, crossing a data-residency boundary. The failure was not detected at delegation time; it was detected downstream when a residency audit compared the *effective* scope of the worker against the *declared* region of the job, roughly 40 minutes after the write. Root cause: the delegation validator used a `patterns_overlap()` predicate (returns true if the two glob patterns share at least one match) instead of `pattern_subsumes(child, parent)`. Overlap is symmetric and admits widening; subsumption is directional and does not. The two predicates agree on equality and on disjointness, and disagree precisely on the widening case — which is the only case that matters for security. A second, subtler observation: the widening survived a *chain-length* check. The chain was three hops deep and each hop validated against its immediate parent only. Because the planner's token was itself valid (it subsumed correctly from the orchestrator), and the worker's token was validated only against the planner's token, the transitive widening `eu-* → *` was never compared against the root. **Per-hop validation is necessary but not sufficient; the root scope must be re-checked at every hop, or the chain must carry a monotone-narrowing witness.** ## Condition of Validity The subsumption invariant holds only under the following conditions, and silently degrades outside them: 1. **Pattern language is closed under intersection and decidable for subsumption.** Glob patterns with `*` are decidable for subsumption but the decision procedure is not string comparison. If scopes use regex or attribute-based predicates, subsumption may be undecidable in general; in that case the system must fall back to *explicit narrowing tokens* (the child enumerates concrete resources, never patterns) rather than attempt subsumption. 2. **Constraints are monotone.** A child constraint must be at least as strict as the parent's (`time < T_child ≤ T_parent`, `budget_child ≤ budget_parent`). A constraint that is "different but incomparable" (e.g., parent allows `region ∈ {eu}`, child requests `region ∈ {eu, us}`) is a widening and must be rejected, not merged. 3. **The resource namespace is stable across the chain.** If a federated peer re-maps resource identifiers (e.g., `tenant/acme` on the issuer becomes `tenant/7f3a` on the audience), pattern subsumption computed on the *local* namespace is meaningless. The chain must carry a namespace translation and subsumption must be evaluated in the *root* namespace. 4. **No ambient authority.** The audience must not possess authority to the resource through a path outside the token (e.g., a shared service credential). If ambient authority exists, scope narrowing is cosmetic; the effective authority is the union of token scope and ambient grants. Outside these conditions — particularly (1) and (3) — the invariant is not merely weakened; it is *unverifiable*, and the correct behavior is to refuse delegation rather than to approximate. ## Decision Boundary The operational decision rule for a delegation validator is: - **Accept** iff for every child triple there exists a parent triple with `action == action`, `pattern_subsumes(child_pattern, parent_pattern) == true`, and `constraint_implies(child_constraint, parent_constraint) == true`. - **Reject with a typed error** (not a generic 403) when subsumption fails, and **log the offending child pattern and the nearest parent pattern** so the widening is auditable. - **Reject outright** when the pattern language is not decidable for subsumption, unless the child scope contains zero wildcard patterns (fully enumerated scope), in which case per-element membership checks suffice. - **Re-check against the root scope** at every hop, or equivalently require each hop to emit a *narrowing witness* (a signed statement that `scope_child ⊆ scope_parent`) so the chain is verifiable without re-walking. Prefer the witness: it makes the invariant checkable in O(1) at the resource server and survives partial chain truncation. - **Never** treat "no overlap" as a rejection reason alone, and **never** treat "overlap" as an acceptance reason. Overlap is the wrong predicate in both directions. The boundary between "safe but over-restrictive" (equality) and "correct" (subsumption) is a performance and expressiveness trade, not a safety trade. The boundary between "correct" (subsumption) and "unsafe" (overlap) is a safety boundary and admits no trade. Systems that cannot decide subsumption must move to enumerated scopes; they must not move to overlap. ## Operational Effect After switching the validator from `patterns_overlap` to `pattern_subsumes` and adding root-scope re-check with a narrowing witness, the observed effects were: - Delegation rejection rate rose from 0.3% to 4.1% of attempts, almost entirely widening attempts that had previously succeeded silently. The retry path in the regional planner was rewritten to delegate enumerated resource IDs rather than patterns, which eliminated its rejections. - The residency-audit false-negative rate (writes crossing a declared boundary) fell from 1 in ~2,300 jobs to 0 over the following 90 days of observation, with the caveat that the audit window was 90 days and the prior rate implies ~3–4 expected events, so the confidence interval is wide. - Chain validation latency at the resource server dropped, because the narrowing witness replaced an O(chain-length) walk with an O(1) signature check. - A new class of error surfaced: legitimate *re-planning* agents that had been relying on pattern re-delegation to handle unknown-at-issue-time resource sets. These were migrated to a two-phase pattern: request an enumerated scope after discovery, or request a pattern scope whose subsumption is provable against the parent at issue time. The migration cost was non-trivial and is the main reason teams skip the invariant. The durable lesson: the invariant that prevents silent scope widening is **directional subsumption with a root-anchored narrowing witness**, and the failure mode it prevents is invisible to overlap checks, chain-length checks, and per-hop-only validation. The failure is not a bug in any single component; it is the composition of three locally reasonable choices — overlap as the predicate, per-hop as the validation scope, and pattern re-delegation as the retry mechanism. ```python class ScopeWideningError(RuntimeError): """Raised when a delegated scope is not provably a subset of its parent's scope.""" pass def pattern_subsumes(child: str, parent: str) -> bool: # Placeholder: real implementation must decide glob subsumption, # not string equality and not overlap. Raises if undecidable. if child == parent: return True # Example: parent "tenant/acme/*" subsumes child "tenant/acme/dataset/x" if parent.endswith("/*") and child.startswith(parent[:-1]): return True return False def validate_delegation(parent_scope, child_scope, root_scope): for c_action, c_pattern, c_constraint in child_scope: matched = False for p_action, p_pattern, p_constraint in parent_scope: if c_action != p_action: continue if not pattern_subsumes(c_pattern, p_pattern): continue if not constraint_implies(c_constraint, p_constraint): continue matched = True break if not matched: raise ScopeWideningError( f"child triple ({c_action}, {c_pattern}) is not subsumed by parent scope" ) # Root re-check: the child must also be subsumed by the original grant. root_ok = any( r_action == c_action and pattern_subsumes(c_pattern, r_pattern) and constraint_implies(c_constraint, r_constraint) for r_action, r_pattern, r_constraint in root_scope ) if not root_ok: raise ScopeWideningError( f"child triple ({c_action}, {c_pattern}) widens beyond root scope" ) return True def constraint_implies(child, parent) -> bool: # Monotone constraints only; incomparable constraints are widening. if child.get("region") is not None and parent.get("region") is not None: if not set(child["region"]).issubset(set(parent["region"])): return False if child.get("budget", 0) > parent.get("budget", float("inf")): return False return True ```