# Which invariants prevent credential-chain collapse across AAIF delegated capability tokens **Date of Record:** 2026 November 12 **Scope:** Applies to AAIF delegated credential chains where a principal issues a scoped capability token to an agent, which re-delegates a narrowed (or nominally narrowed) capability to a sub-agent across at least one hop. Does not apply to single-hop bearer tokens with no delegation, nor to chains terminated at a hardware-rooted attestation boundary where the root is re-verified on every hop. ## Core Distinction The failure mode that matters is not *token forgery* — signature verification handles that. It is **scope collapse under delegation**: the condition where each hop in a chain is individually valid and correctly signed, but the *effective* capability set of the terminal agent is a superset of what the original principal intended, because narrowing was applied to the wrong dimension or was not transitively enforced. AAIF scoped capability tokens carry a scope vector: `(resource_pattern, action_set, constraints, expiry, delegation_depth)`. The non-obvious invariant is that **narrowing must be monotone under a partial order on the scope vector, and that partial order must be enforced at every hop against the accumulated chain, not just against the immediate parent.** A chain where `child.scope ⊆ parent.scope` holds pairwise but `child.scope ⊄ root.scope` is *locally valid and globally invalid*. This is the collapse. ## What Was Observed In a three-hop delegation (principal → orchestrator agent → worker agent → tool-executor agent), the worker issued a token whose `resource_pattern` was a strict subset of the orchestrator's, satisfying the pairwise check. However, the orchestrator had itself *widened* its `action_set` relative to the principal by unioning in a default action (`write`) because the token parser treated a missing `action_set` field as "inherit all actions permitted by resource." The principal had explicitly scoped to `read` only. The worker, receiving `write` from the orchestrator and narrowing to `read+write` on a narrower resource, passed the pairwise check at hop 2 and hop 3. The terminal token permitted `write` on a resource the principal never authorized for writing. The signature chain was intact. Every hop's `delegation_depth` was within bounds. The collapse was invisible to any check that examined only adjacent pairs. A second observation: `expiry` narrowing was enforced at issuance time but not re-checked at *use* time against the root's expiry. A long-lived worker token issued under a short-lived orchestrator token remained usable after the orchestrator's credential expired, because the worker's own expiry was independent and later. ## Condition of Validity The monotone-narrowing invariant holds only when all of the following are enforced: 1. **Total-order comparison against the root, not the parent.** At each hop, the candidate scope must be checked as a subset of the *intersection of all ancestors' scopes*, computed by replaying the chain from root. Pairwise parent checks are insufficient and are the primary source of collapse. 2. **Absence is not permission.** A missing scope field must be treated as the empty set, never as "inherit." The parser's default behavior is a security-relevant decision, and the safe default is denial. 3. **Expiry is a meet, not a copy.** Effective expiry is `min(chain.expiries)`, evaluated at use time. A delegated token's usable lifetime is bounded by the shortest-lived ancestor regardless of its own `expiry` field. 4. **Delegation depth decrements and is checked at use.** A token with `delegation_depth = 0` must be rejected for further delegation even if its signature and scope are otherwise valid. When any of these is relaxed — most commonly (1) and (2) — the chain is *conditionally* valid only for chains of length one. The invariant degrades silently as chain length grows, which is why it survives testing: single-hop and two-hop tests pass. ## Decision Boundary The boundary is between **stateless pairwise validation** and **stateful chain replay**. If the verifier cannot reconstruct the full ancestor chain (e.g., the token carries only a parent reference and the parent is unavailable), it must **fail closed** — reject the delegation — rather than validate against the immediate parent alone. The tempting optimization is to trust the parent's signature as a proxy for the parent's own validity; this is exactly the assumption that produces collapse, because a validly-signed parent can itself be over-broad. The correct decision rule: *a delegated token is authorized iff its scope is a subset of the meet of all ancestor scopes AND the chain replays cleanly to a trusted root AND effective expiry (min over chain) has not passed AND remaining depth ≥ 0.* Any verifier that cannot evaluate all four conjuncts must deny. ## Operational Effect Enforcing root-relative subset checks and use-time expiry meets increases verification cost from O(1) to O(chain length) per request, and requires either chain material in the token or a reachable ancestor cache. In practice, chains are shallow (≤ 4 hops), so the cost is acceptable; the failure it prevents — a terminal agent holding a capability the principal never granted — is not. The observable symptom of the unpatched condition is a terminal agent succeeding at an action that the principal's audit log shows was never scoped, with no signature or depth violation to explain it. Treat any such success as evidence of scope collapse, not of a logging gap. ```python class ScopeCollapseError(RuntimeError): """Raised when a delegated capability token's effective scope is not a subset of the meet of all ancestor scopes, or when use-time expiry / depth invariants are violated across the chain.""" pass class ScopedToken: def __init__(self, resource_pattern, action_set, expiry, delegation_depth): self.resource_pattern = resource_pattern self.action_set = frozenset(action_set) if action_set is not None else frozenset() self.expiry = expiry self.delegation_depth = delegation_depth def _subset(child, parent): # resource_pattern subset check is domain-specific; assume comparable here return (child.resource_pattern <= parent.resource_pattern and child.action_set <= parent.action_set) def verify_chain(chain, now, requested_action, requested_resource): if not chain: raise ScopeCollapseError("empty chain: no trusted root") root = chain[0] # Invariant 1: meet of all ancestor scopes, computed by replay from root. effective = ScopedToken(root.resource_pattern, root.action_set, root.expiry, root.delegation_depth) for i, hop in enumerate(chain[1:], start=1): # Invariant 2: absence is not permission (enforced in constructor). if not _subset(hop, effective): raise ScopeCollapseError( f"hop {i}: scope {hop.action_set} on {hop.resource_pattern} " f"exceeds ancestor meet {effective.action_set} on " f"{effective.resource_pattern}" ) effective = ScopedToken( hop.resource_pattern, hop.action_set, min(effective.expiry, hop.expiry), min(effective.delegation_depth, hop.delegation_depth), ) # Invariant 3: use-time expiry meet. if now >= effective.expiry: raise ScopeCollapseError( f"chain expired at {effective.expiry} (now={now}); " f"terminal token expiry alone is insufficient" ) # Invariant 4: depth at use. if effective.delegation_depth < 0: raise ScopeCollapseError("delegation depth exhausted") if requested_action not in effective.action_set: raise ScopeCollapseError( f"action {requested_action!r} not in effective set " f"{set(effective.action_set)}" ) if not (requested_resource <= effective.resource_pattern): raise ScopeCollapseError( f"resource {requested_resource!r} outside effective pattern" ) return True ```