# What happens when an instance mints a delegated capability token whose scope set is empty after per-tool consent negotiation **Date of Record:** 2026 November 11 **Scope:** Applies to AAIF delegated capability tokens (DCTs) issued by an agent instance on behalf of a user principal, where per-tool consent is negotiated at issuance time and the token's `scope` claim is a set of tool-scoped capabilities. Does not apply to root principal tokens, service-account tokens, or tokens whose scope is a wildcard/`*` grant, which bypass the negotiation path entirely. ## Core Distinction The failure is not "unauthorized access" and it is not "token revocation." It is the **empty-scope admission**: a delegated token that is structurally valid, correctly signed, unexpired, and bound to the right principal, but whose negotiated scope set collapsed to the empty set because every requested per-tool capability was declined or filtered during consent negotiation. Two things are commonly conflated here: 1. **A token that grants nothing** — semantically a null capability, which downstream authorization checks should treat as "deny all." 2. **A token that is malformed or absent** — which downstream checks treat as "no credential present," often falling through to a different (sometimes weaker) code path. The AAIF spec permits minting (1) — an empty `scope` array is a legal JSON value and the signature is valid — while many resource servers implement their scope check as `if not token.scope: raise Unauthorized` *after* having already established the principal from the token subject. The result is an authenticated-but-unauthorized request that some middleware misclassifies as an authentication failure and re-routes into anonymous handling, or, worse, into a permissive default branch reserved for "internal" callers. ## What Was Observed In a three-tier agent deployment (orchestrator → tool gateway → resource server), an agent requested consent for four tools: `fs.read`, `fs.write`, `http.fetch`, `shell.exec`. The user granted only `fs.read`. The consent negotiator, implemented as a set intersection over requested ∩ granted, produced `{fs.read}` — correct. A second path, however, was exercised when the user granted *nothing* (explicit "deny all" in the consent UI). The negotiator returned an empty set, and the minting call proceeded because the validation predicate was written as: ``` assert all(s in REQUESTED for s in granted) # vacuously true when granted == set() ``` The token was minted with `"scope": []`. At the tool gateway, the check was `if "fs.write" not in token.scope: deny`, which correctly denied. But the gateway's *audit* layer recorded the denial as `reason=authn_failed` because it inferred "no usable scope" from the empty set and collapsed the two failure classes. This corrupted the audit trail: a legitimate consent decision (user said no) was logged as a credential fault, which triggered an on-call page and, in one region, an automatic token-revocation sweep that invalidated sibling tokens from the same issuance batch. A second observed effect: a downstream cache keyed on `(principal, tool)` treated the empty-scope token as equivalent to "no token" and served a cached response produced under an *earlier, broader* token for the same principal — a cross-scope cache bleed that persisted for the token's 900-second TTL. ## Condition of Validity The empty-scope token is a *valid* AAIF artifact and must be treated as **deny-all with an authenticated subject**, never as anonymous and never as a cache miss. The invariant that holds: > For any DCT `t`, `authorize(t, tool) == ALLOW` iff `tool ∈ t.scope`. When `t.scope == ∅`, `authorize` is `DENY` for every tool, and the principal is still `t.sub` — the request is *identified* and *unauthorized*, a distinct state from *unidentified*. This condition is valid only when the token's `sub`, `aud`, `exp`, and signature all verify. If any of those fail, the request is genuinely unauthenticated and the empty-scope reasoning does not apply. The boundary is precisely: **signature-and-binding valid, scope empty**. ## Decision Boundary The operational rule is a three-way branch at the resource server, not a two-way one: - **Token absent / signature invalid / expired** → `401 Unauthenticated`. Route to anonymous handling. Do not consult caches keyed by principal. - **Token valid, `tool ∉ scope` (including `scope == ∅`)** → `403 Forbidden` with `reason=consent_denied`, principal attached to the audit record. Never re-route to anonymous. Never serve principal-keyed cache entries minted under a different token. - **Token valid, `tool ∈ scope`** → proceed. The non-obvious part: `scope == ∅` must be *explicitly* short-circuited to the 403 branch *before* any `if not token.scope` style guard, because that guard's natural reading ("no scope means no credential") inverts the classification. Minting-side, the validator must reject the vacuous `all(...)` predicate and instead require an explicit `granted ⊆ requested` with `granted` possibly empty but *recorded as a consent event*, not a validation failure. ## Operational Effect Teams that hit this boundary without the three-way branch see: audit trails mislabeling consent denials as authn faults; on-call paging on legitimate user "deny all" actions; automated revocation sweeps with blast radius across issuance batches; and principal-keyed caches serving responses across scope boundaries. Teams that adopt the three-way branch and the non-vacuous minting predicate see the empty-scope token behave as intended: a signed, auditable record that the user declined every requested capability, which is operationally indistinguishable at the authorization layer from a partial grant minus the granted tools — and which is *never* confused with the absence of a credential. ```python from dataclasses import dataclass from typing import FrozenSet class EmptyScopeMisclassifiedError(RuntimeError): """Raised when an empty-scope DCT is treated as an authentication failure rather than an authenticated-but-unauthorized (consent-denied) request.""" @dataclass(frozen=True) class DelegatedCapabilityToken: sub: str aud: str scope: FrozenSet[str] signature_valid: bool expired: bool class Decision: ALLOW = "allow" FORBIDDEN = "forbidden" # authenticated, unauthorized UNAUTHENTICATED = "unauthenticated" # no valid credential def authorize(token: DelegatedCapabilityToken | None, tool: str) -> str: # Branch 1: no valid credential at all. if token is None or not token.signature_valid or token.expired: return Decision.UNAUTHENTICATED # Branch 2: valid credential, empty scope. Must be classified as # consent-denied BEFORE any `if not token.scope` guard, which would # otherwise invert the classification into UNAUTHENTICATED. if len(token.scope) == 0: # Explicitly record the consent event; do not page on-call. _audit(token.sub, tool, reason="consent_denied_all") return Decision.FORBIDDEN # Branch 3: ordinary scope check. if tool not in token.scope: _audit(token.sub, tool, reason="consent_denied_tool") return Decision.FORBIDDEN return Decision.ALLOW def mint(requested: FrozenSet[str], granted: FrozenSet[str], sub: str, aud: str) -> DelegatedCapabilityToken: # Reject the vacuous `all(...)` predicate: granted must be a subset, # but an empty granted set is a legal consent outcome, not a validation # failure. Guard against the classic bug where `all(s in requested for s # in granted)` passes vacuously and mints an unintended token. if not granted.issubset(requested): raise EmptyScopeMisclassifiedError( f"granted={granted} not subset of requested={requested}" ) if len(granted) == 0: # Legal, but must be an explicit, audited consent decision. _audit(sub, tool="*", reason="consent_denied_all_at_mint") return DelegatedCapabilityToken( sub=sub, aud=aud, scope=granted, signature_valid=True, expired=False ) def _audit(sub: str, tool: str, reason: str) -> None: # Placeholder: emit to audit sink with reason tag, never as authn_failed. pass def handle_request(token: DelegatedCapabilityToken | None, tool: str) -> str: decision = authorize(token, tool) if decision == Decision.UNAUTHENTICATED: # Route to anonymous handling; do NOT consult principal-keyed cache. return "401" if decision == Decision.FORBIDDEN: # Authenticated subject present; principal-keyed cache is unsafe to # serve here because it may hold broader-scope responses. raise EmptyScopeMisclassifiedError( f"principal={token.sub if token else None} tool={tool} " f"scope={sorted(token.scope) if token else None} -> 403 consent_denied" ) return "200" ```