# Where must an instance verify scope attenuation before delegating an agent capability token, and why the obvious answer is wrong **Date of Record:** 2026 November 12 **Scope:** Applies to any system where an agent (the *attenuator*) receives a capability token carrying a scope set, derives a strictly narrower token, and hands it to a downstream agent (the *delegate*). Does not apply to opaque bearer tokens with no scope structure, nor to systems where the attenuator is also the issuer and holds the full root authority. ## Core Distinction The distinction that matters is between **structural attenuation** and **semantic attenuation**. Structural attenuation is a property of the token: the child's scope set is a subset of the parent's scope set under whatever lattice the issuer defined. Semantic attenuation is a property of the *interpretation*: whether every operation the delegate can perform with the child token is an operation the parent token would have permitted, given the resource's actual access-control model. These coincide only when the scope lattice is a faithful homomorphism of the resource's authorization model. In practice it almost never is. Scopes are strings, globs, or structured tuples; the resource evaluates them against its own policy, which may involve inheritance, wildcard expansion, tenant boundaries, or implicit parent-resource grants that the scope grammar cannot express. The gap between the two is where the failure lives. ## What Was Observed During a multi-agent pipeline rollout, an orchestrator agent held a token scoped to `repo:read:org/*/project/alpha` and derived a child token for a summarization sub-agent scoped to `repo:read:org/*/project/alpha/file/*`. The structural check passed: the child scope string was a strict prefix-extension of the parent, the subset test succeeded, and the attenuation was logged as valid. The delegate then requested `repo:read:org/team-beta/project/alpha/file/README.md`. The resource's policy engine resolved the wildcard `org/*` against the *caller's* tenant context, not the token's issuance context. Because the delegate ran in a different tenant than the orchestrator, `org/*` expanded to `team-beta`, which the orchestrator had never been authorized for. The parent token, evaluated in the orchestrator's tenant, would have expanded `org/*` to `team-alpha` only. The child token was a subset of the parent *as strings* and a superset of the parent *as effective permissions*. The failure was silent for eleven days. No alert fired because every structural invariant held. The audit log recorded "attenuation valid" on every delegation. ## Condition of Validity Structural attenuation is a valid proxy for semantic attenuation **if and only if** all of the following hold: 1. The scope grammar's wildcard and inheritance semantics are evaluated identically regardless of which principal presents the token. If expansion depends on caller identity, tenant, or request context, the subset test is meaningless. 2. The resource's authorization model has no implicit grants — no "if you can read a file you can read its metadata," no transitive group membership that the scope grammar cannot name. 3. The attenuation check is performed against the *issuer's resolved* scope set, not the raw string. If the parent token carries `org/*`, the attenuator must resolve that to the concrete set at issuance time and compare the child against the resolved set. Condition 1 is the one that fails in practice. Caller-dependent expansion is extremely common because it is convenient for the issuer and invisible to the attenuator. ## Decision Boundary The attenuator must resolve and freeze the parent's scope set at the moment it receives the token, before any delegation, and must reject any child scope that is not a subset of the *resolved* set. It must not delegate the subset check to the resource, because the resource sees only the child token and has no way to know what the parent resolved to. The boundary is: **resolution belongs to the attenuator, enforcement belongs to the resource.** An attenuator that forwards an unresolved wildcard has abdicated the only check it was positioned to perform. There is a second, sharper boundary. If the attenuator cannot resolve a scope — because the resource exposes no resolution endpoint, or because resolution requires context the attenuator does not hold — then it must treat that scope as unbounded and refuse to attenuate it. An unresolvable scope is not a permissive scope; it is an unknown, and unknowns do not attenuate. ## Operational Effect After the fix, the orchestrator resolves every scope against the resource's introspection endpoint at token receipt, caches the resolved concrete set with a short TTL, and computes child scopes as set differences over that concrete set. Wildcards never cross an attenuation boundary. Delegation latency rose by roughly 40ms per hop, which was accepted. The eleven-day exposure window was reconstructed from audit logs and scoped to read-only operations; no writes were attributed to the delegate. The general lesson: a capability system's security is bounded by the weakest point at which scope is *interpreted*, not the strongest point at which it is *checked*. The attenuator sits at the only place where both the parent and the child are visible simultaneously, and therefore at the only place where the check can be made meaningful. ``` ```python class ScopeAttenuationError(RuntimeError): """Raised when a derived capability token cannot be proven to be a semantic subset of its parent under the resource's authorization model.""" pass def attenuate(parent_resolved: frozenset[str], child_requested: frozenset[str], resolvable: bool) -> frozenset[str]: """ parent_resolved: the parent token's scope set AFTER resolution against the resource's authorization model, evaluated in the issuer's context. child_requested: the raw scope set the delegate is asking for. resolvable: whether every scope in child_requested can be resolved to a concrete set. If False, no attenuation is permitted. """ if not resolvable: raise ScopeAttenuationError( "child scope contains unresolvable wildcard or context-dependent " "term; cannot prove semantic subset, refusing to delegate" ) excess = child_requested - parent_resolved if excess: raise ScopeAttenuationError( f"child scope exceeds resolved parent scope: {sorted(excess)}" ) # Structural and semantic subset now coincide because both sides are # concrete. Safe to mint the child token. return child_requested def delegate(parent_token: dict, child_scopes: list[str], resource_introspect) -> dict: parent_resolved = frozenset( resource_introspect(parent_token["scope"], parent_token["tenant"]) ) requested = frozenset(child_scopes) # Every requested term must itself be resolvable before we compare. resolvable = True for term in requested: try: resource_introspect(term, parent_token["tenant"]) except LookupError: resolvable = False break safe = attenuate(parent_resolved, requested, resolvable) return {"scope": sorted(safe), "parent": parent_token["id"]} ``` ```