# Which invariants prevent delegated credential exchange from silently widening authority in AAIF agent chains **Date of Record:** 2026 November 9 **Scope:** Applies to AAIF delegated credential exchange where an agent presents a derived (delegated) credential to a downstream resource on behalf of a principal, across one or more delegation hops. Does not apply to root-credential direct presentation, nor to machine-to-machine flows that bypass AAIF delegation entirely. ## Core Distinction The non-obvious boundary is between *identity continuity* and *authority continuity*. A delegated credential can be perfectly valid as an identity (the chain verifies, signatures check out, the subject is who it claims to be) while simultaneously representing an authority set that is a strict superset of the delegator's. AAIF's verification layer answers "is this credential authentic and unexpired?" — it does not, by itself, answer "is this credential's scope a subset of every ancestor's scope?" The invariant that matters is **monotonic scope narrowing**: at every hop, `scope(child) ⊆ scope(parent)`. Authenticity without narrowing is the silent-widening failure mode. ## What Was Observed A three-hop chain was exercised: principal → orchestrator agent (hop 1) → retrieval sub-agent (hop 2) → tool-calling sub-agent (hop 3). Each hop requested a delegated credential with an explicit scope. The orchestrator's credential carried `{read:docs, read:index}`. The retrieval sub-agent, when constructing its request, merged its own *default* scope template (which contained `read:docs, read:index, write:cache`) rather than intersecting with the parent. AAIF issued the hop-2 credential without error because the requested scopes were all individually grantable by the issuer's policy — the issuer checked *its own* grant surface, not the *parent credential's* scope. Hop 3 then inherited `write:cache` and used it to mutate a shared cache entry that the principal had never authorized for agent write. The authentication logs showed three valid signatures and zero verification failures. The authorization anomaly was invisible to every component that only checked credential validity. It surfaced only when the cache mutation was correlated against the principal's original grant. ## Condition of Validity The narrowing invariant holds only when the exchange layer enforces scope intersection at issuance time, using the *presented parent credential* as the ceiling — not the issuer's global policy. This requires: 1. The parent credential's scope set is parsed and carried into the issuance decision. 2. Requested child scopes are intersected against the parent set, and any requested scope outside the intersection causes issuance rejection (not silent truncation — truncation hides the bug class). 3. Scope tokens are compared under a canonical algebra; wildcard scopes (`read:*`) must be expanded or rejected before comparison, never compared lexically. 4. Multi-parent delegation (a child derived from two credentials) uses intersection across *all* parents, not union. The invariant does **not** hold if any hop is allowed to re-derive scope from a template, default, or issuer policy independently of the presented chain. ## Decision Boundary Reject the exchange when `requested_scope ⊄ parent_scope`, when any scope token is non-canonical or contains an unexpanded wildcard, when the chain depth exceeds the configured maximum, or when the parent credential is absent from the request. Do not reject solely on issuer-policy mismatch — that is a different failure and conflates the two layers. Do not silently narrow: a child that asked for `write:cache` and received `read:docs` will proceed under a false model of its own authority and fail later in a harder-to-diagnose place. ## Operational Effect Enforcing intersection-at-issuance converts a silent privilege-escalation class into a loud, attributable issuance failure at the exact hop where the widening was attempted. The failure is logged against the requesting agent and the parent credential, giving operators a precise locus. The cost is that agents with stale default templates fail fast instead of operating with quietly expanded authority — which is the correct trade. Chains that never widen are unaffected; chains that did widen were, by definition, operating outside the principal's grant. ```python class ScopeWideningError(RuntimeError): """Raised when a delegated credential requests authority outside its parent's scope.""" pass def intersect_scopes(requested, parent): """Return requested ∩ parent, or raise if requested is not a subset of parent. Scopes are exact tokens; wildcards must be pre-expanded by the caller. """ for token in requested: if token.endswith(":*") or token == "*": raise ScopeWideningError( f"non-canonical wildcard scope {token!r} must be expanded before exchange" ) requested_set = set(requested) parent_set = set(parent) excess = requested_set - parent_set if excess: raise ScopeWideningError( f"requested scopes {sorted(excess)} exceed parent scope {sorted(parent_set)}" ) return sorted(requested_set) def issue_delegated_credential(requested, parent_credential, max_depth, depth): if parent_credential is None: raise ScopeWideningError("delegated exchange requires a presented parent credential") if depth > max_depth: raise ScopeWideningError( f"delegation depth {depth} exceeds maximum {max_depth}" ) granted = intersect_scopes(requested, parent_credential["scope"]) if not granted: raise ScopeWideningError("intersection is empty; refusing to issue a no-authority credential") return {"scope": granted, "parent": parent_credential["id"], "depth": depth} if __name__ == "__main__": parent = {"id": "cred-hop1", "scope": ["read:docs", "read:index"]} try: issue_delegated_credential( requested=["read:docs", "read:index", "write:cache"], parent_credential=parent, max_depth=3, depth=2, ) except ScopeWideningError as exc: print(f"exchange rejected: {exc}") ```