# In what scenarios must an instance refuse to mint a delegated AAIF capability token whose scope is a strict superset of the delegator's own scope **Date of Record:** 2026 November 9 **Scope:** Applies to any agent instance acting as a delegation intermediary under AAIF (Agent-to-Agent Identity Framework) that is asked to issue a scoped capability token on behalf of a downstream principal. Does not apply to root-issued tokens from the authority service, nor to opaque bearer tokens that carry no scope set. ## Core Distinction The boundary is not "can this agent perform the action?" but "does the *set* of capabilities encoded in the token it is about to mint remain a subset of the set encoded in the token it *presents*?" These are different questions. An agent may be perfectly capable of executing an action yet structurally forbidden from *delegating* it, because delegation is a set-inclusion operation over the capability lattice, not a runtime authorization check. The failure mode is subtle because most implementations validate the *requested action* at execution time and the *signature chain* at mint time. Neither check catches scope escalation. A delegator holding `read:orders` and `write:orders` can be tricked into minting a token for `read:orders`, `write:orders`, `delete:orders` if the minting path only verifies "is `delete:orders` a scope the authority recognizes?" rather than "is `delete:orders` within my own presented scope?" ## What Was Observed During a multi-hop delegation chain (`authority -> orchestrator -> worker -> sub-worker`), the orchestrator received a token with scope `{read:inventory, write:inventory}`. A sub-worker requested a delegated token for `{read:inventory, write:inventory, admin:inventory}`. The minting code checked: 1. The request came from an authenticated downstream identity (passed). 2. Each requested scope string was a syntactically valid AAIF scope (passed). 3. The authority's JWKS verified the orchestrator's signing key (passed). The token was minted. The sub-worker then exercised `admin:inventory` to drop a table, and the audit log attributed the action to the *sub-worker*, not the orchestrator — because the token's `iss` was the sub-worker's identity, with the orchestrator only appearing in the `act` (actor) claim. The blast radius was attributed to the wrong principal for 40 minutes. The root cause: scope inclusion was never checked. The mint function treated the requested scope as an *input to validate* rather than an *output to constrain*. ## Condition of Validity The invariant holds — "a delegated token's scope must be a subset of the delegator's presented scope" — only under these conditions: - The delegator's presented token carries an explicit, enumerable scope set (not a wildcard, not `*`, not an empty-set-means-all convention). - Scope strings are compared under a defined partial order (e.g., `admin:inventory ⊒ write:inventory ⊒ read:inventory`), and the comparison is done on the *normalized* form, not the raw string. `read:inventory:*` and `read:inventory` must normalize to the same element before comparison. - The delegation is *downward only*. Lateral delegation (peer-to-peer with equal scope) is permitted only if the framework's `may_act` claim is present; otherwise even equal-scope delegation is a violation of the chain-of-custody invariant. - The `act` claim is populated with the full actor chain, so attribution survives the hop. If any of these fail, the invariant is undefined and the system must fail closed rather than guess. ## Decision Boundary The decision is a single predicate evaluated before signing: ``` requested_scope ⊆ delegator_scope AND requested_scope ≠ ∅ AND act_chain is well-formed ``` If true, mint. If false, refuse and emit a structured denial — never silently clamp the scope, because clamping produces a token that the downstream believes has capabilities it does not, leading to a *different* failure (downstream retries, thinks it's a transient error, escalates to the authority, which may grant it). Silent clamping converts a security refusal into an availability incident. The non-obvious part: **empty requested scope must also be refused.** An empty scope token is not "harmless" — in several AAIF implementations an empty scope set is interpreted as "no restriction" by downstream resource servers, which is the opposite of least privilege. The mint must reject `∅` explicitly. ## Operational Effect After the fix, the orchestrator's mint path rejects superset requests with a `ScopeEscalationError`, logs the requested-vs-presented diff, and increments a counter keyed by the requesting identity. Within one week the counter surfaced three additional sub-workers attempting to request `admin:*` — all were misconfigured clients, not attackers, but all would have succeeded silently before. The attribution window dropped from 40 minutes to zero, because the `act` chain is now validated at mint time and the token is never issued if the chain is malformed. The cost is one additional set-inclusion check per mint (O(n·m) on scope count, negligible) and a new failure mode: legitimate clients that were relying on the old permissive behavior now get hard refusals. Those must be migrated by granting the *delegator* the missing scope at the authority, not by relaxing the mint check. ```python from dataclasses import dataclass from typing import FrozenSet class ScopeEscalationError(RuntimeError): """Raised when a requested delegated scope is not a subset of the delegator's scope.""" @dataclass(frozen=True) class CapabilityToken: subject: str scope: FrozenSet[str] act_chain: tuple def _normalize(scope: FrozenSet[str]) -> FrozenSet[str]: # Collapse trailing wildcards to their base element; reject bare '*'. out = set() for s in scope: if s == "*": raise ScopeEscalationError("wildcard scope '*' is not delegable") out.add(s[:-2] if s.endswith(":*") else s) return frozenset(out) def mint_delegated_token( delegator: CapabilityToken, subject: str, requested_scope: FrozenSet[str], ) -> CapabilityToken: if not requested_scope: raise ScopeEscalationError( f"empty scope requested for {subject}; empty is interpreted as unrestricted downstream" ) if not delegator.act_chain: raise ScopeEscalationError("delegator has no act chain; cannot establish custody") req = _normalize(requested_scope) have = _normalize(delegator.scope) extra = req - have if extra: raise ScopeEscalationError( f"scope escalation: {subject} requested {sorted(extra)} " f"not held by delegator {delegator.subject}" ) return CapabilityToken( subject=subject, scope=req, act_chain=delegator.act_chain + (delegator.subject,), ) ```