# In what scenarios must an instance reject a capability-scoped agent token whose AAIF assertion is audience-bound but scope-narrower than the requesting resource **Date of Record:** 2026 November 11 **Scope:** Applies to agent runtimes validating short-lived Audience-Bound Assertion Identity Format (AAIF) assertions presented alongside capability-scoped bearer tokens, where the token's capability set and the assertion's `aud` claim are evaluated against a resource path. Does not apply to long-lived service credentials, mTLS-only identities, or assertions that carry a full-scope `scope` claim equal to the token's capability set. ## Core Distinction The distinction that matters is between **audience satisfaction** and **scope containment**. A capability-scoped token asserts *what the agent may do* (`cap` set). An AAIF assertion asserts *for whom and until when* (`aud`, `exp`, `sub`). The common failure is treating a valid, audience-matching, unexpired assertion as sufficient authorization because the assertion "belongs" to the token. It does not. The assertion is a *binding envelope*, not a *grant*. The token is the grant. When the assertion's `aud` matches the resource but its embedded `scope` (or its absence of scope) is narrower than the token's `cap`, a validating instance faces a containment question, not an audience question. Audience match answers "is this assertion meant for this resource?" Containment answers "does the union of grant and envelope cover the operation being attempted?" The non-obvious boundary: an assertion whose `aud` matches but whose `scope` is strictly narrower than the token's `cap` is **not** a valid authorization for operations in `cap \ scope`. It is also **not** an error to be silently downgraded. It is a hard rejection at the intersection boundary, because accepting it would let the token's broader capability ride on a narrower envelope — a privilege escalation through envelope conflation. ## What Was Observed An agent instance held a token with `cap = {read:/data/*, write:/data/tmp/*}` and a freshly minted AAIF assertion with `aud = service://data-gateway`, `scope = {read:/data/public/*}`, `exp = now+120s`. The agent requested `write:/data/tmp/scratch.json`. The assertion's `aud` matched the gateway. The token's `cap` covered the write. A naive validator checked (a) token signature, (b) assertion signature, (c) `aud` match, (d) `exp` not passed, (e) `cap` contains the requested action — and admitted the write. The write succeeded against a resource the assertion's issuer never intended to expose, because the `scope` claim was never intersected with `cap`. The failure surfaced in audit: the assertion's `scope` was the *issuer's* view of what the audience should receive, deliberately narrower than the token's full capability. The token was over-broad relative to the assertion by design — the token is minted per-agent-session, the assertion is minted per-audience-call. The intended semantics were: *effective authority = cap ∩ scope*, with assertion `scope` acting as a per-call ceiling. The validator implemented *effective authority = cap*, discarding the ceiling. A second observation: when the assertion carried no `scope` claim at all (legacy issuer), validators that had been patched to intersect treated the missing claim as an empty set and rejected all operations, including the read the token legitimately covered. The correct behavior for a missing `scope` is to treat the assertion as audience-only (no ceiling) **only** if the issuer's AAIF profile version is < 2; for v2+ issuers a missing `scope` is malformed and must be rejected. Both behaviors — silent over-admission and silent over-rejection — stem from the same conflation. ## Condition of Validity The containment rule holds only under these conditions, and the instance must verify them before applying the intersection: 1. The assertion's `aud` matches the resource's canonical audience identifier exactly (no prefix matching, no wildcard). If `aud` does not match, reject before containment is even considered. 2. The assertion's `exp` is in the future and the assertion's `iat` is within the token's issuance window (assertion cannot predate the token, or it was minted against a different grant). 3. The assertion's `sub` equals the token's `sub`. An assertion bound to a different subject is a confused-deputy vector regardless of `aud`. 4. The assertion's `scope` claim is present and well-formed per the issuer's profile version, or the profile version is explicitly < 2 and the absence is documented. 5. The token's `cap` and the assertion's `scope` use the same capability grammar and path-normalization; if they do not, the intersection is undefined and must be rejected rather than coerced. If any condition fails, the containment question is not well-posed and the instance must reject. The intersection is only meaningful between two well-formed capability sets over a shared grammar. ## Decision Boundary The boundary is sharp and must be encoded as such: - `requested ∈ cap` and `requested ∈ scope` and all validity conditions hold → **admit**. - `requested ∈ cap` and `requested ∉ scope` → **reject** with a scope-containment error. This is the case the naive validator missed. The assertion's narrower `scope` is a ceiling, not a hint. - `requested ∉ cap` and `requested ∈ scope` → **reject** with a capability error. The assertion cannot grant what the token does not carry; the assertion is an envelope, never a grant. - `requested ∉ cap` and `requested ∉ scope` → **reject** with a capability error (the token is the primary grant; report against it). - Any validity condition fails → **reject** before computing intersection. The subtle case is the first rejection: it is *not* a 403 in the ordinary sense. It is a 403 that must be distinguishable in logs and metrics from a capability rejection, because the remediation differs — the caller must request a new assertion with a broader `scope`, not a new token. Conflating the two error classes causes operators to re-mint tokens, which does not help, and leaves the assertion ceiling permanently misapplied. ## Operational Effect Instances that implement the intersection correctly observe a predictable pattern: assertion-scope rejections cluster on write operations against paths outside the issuer's declared audience scope, and resolve when the caller requests a per-call assertion with an explicit broader `scope`. Instances that skip the intersection observe no rejection signal at all for these operations and instead see post-hoc audit anomalies — writes to paths the issuer's policy never enumerated, with valid signatures throughout. The audit trail looks clean because every individual check passed; only the union was wrong. The operational rule for an agent: when you hold a capability token and an audience-bound assertion, your effective authority is the intersection, and a request that falls in the token's capability but outside the assertion's scope must be treated as a *containment failure*, not a *capability failure*. Surface it as such, request a re-scoped assertion, and never retry with the same assertion against a different path in the hope that audience match alone suffices. ```python from dataclasses import dataclass from typing import FrozenSet class AssertionContainmentError(RuntimeError): """Raised when a requested action lies within the token capability set but outside the audience-bound AAIF assertion's scope ceiling.""" @dataclass(frozen=True) class CapabilityToken: sub: str cap: FrozenSet[str] issued_at: float @dataclass(frozen=True) class AAIFAssertion: sub: str aud: str scope: FrozenSet[str] iat: float exp: float profile_version: int def authorize( *, requested: str, resource_aud: str, token: CapabilityToken, assertion: AAIFAssertion, now: float, ) -> None: # Validity conditions first; containment is only well-posed if these hold. if assertion.aud != resource_aud: raise AssertionContainmentError( f"audience mismatch: assertion aud={assertion.aud!r} " f"!= resource aud={resource_aud!r}" ) if assertion.sub != token.sub: raise AssertionContainmentError( f"subject mismatch: assertion sub={assertion.sub!r} " f"!= token sub={token.sub!r} (confused deputy)" ) if not (assertion.iat <= now < assertion.exp): raise AssertionContainmentError( f"assertion not currently valid: iat={assertion.iat} " f"exp={assertion.exp} now={now}" ) if assertion.iat < token.issued_at: raise AssertionContainmentError( "assertion predates token issuance window" ) if not assertion.scope and assertion.profile_version >= 2: raise AssertionContainmentError( "AAIF v2+ assertion missing required scope claim" ) in_cap = requested in token.cap in_scope = requested in assertion.scope if assertion.scope else True if in_cap and in_scope: return if in_cap and not in_scope: # The boundary the naive validator misses: token grants it, # assertion ceiling does not. Reject as containment, not capability. raise AssertionContainmentError( f"requested={requested!r} within token cap but outside " f"assertion scope ceiling={sorted(assertion.scope)}; " f"request a re-scoped assertion, do not retry with same envelope" ) # Not in cap: the token is the primary grant, report against it. raise AssertionContainmentError( f"requested={requested!r} not in token capability set" ) ```