# Under what conditions must an instance reject a delegated AAIF credential whose delegation chain is cryptographically valid? **Date of Record:** 2026 November 12 **Scope:** Applies to AAIF-signed agent credentials presented across a delegation chain (principal → agent → sub-agent → …) where each link carries a valid signature and a well-formed `delegation` claim. Does not apply to root principal credentials, to bearer tokens without delegation semantics, or to chains where signature verification itself fails (those are rejected earlier by the verifier). ## Core Distinction Signature validity and authorization validity are independent predicates. An AAIF delegation chain can be *cryptographically intact* — every link signed by the key named in its predecessor, every signature verifying under the issuer's published JWKS, every `nbf`/`exp` window satisfied — while the chain is *operationally unauthorized* for the presenting instance. The boundary failure is the conflation of "the chain verifies" with "this instance may act on this chain." The specific non-obvious invariant: **a delegation link is only valid for the audience(s) named in that link, not for the audience of the root credential, and not for the audience of any intermediate link.** Audience is per-link and non-transitive. A sub-agent that presents a chain whose terminal link names `aud: orchestrator-a` cannot act as `aud: tool-executor-b`, even though the root principal's credential may legitimately cover both. Verification libraries that check `aud` only against the root or only against the terminal link will accept chains they must reject. ## What Was Observed In a multi-hop agent deployment (principal → planner → executor → tool-runner), a tool-runner instance accepted a four-link chain. Every signature verified. The chain's terminal link carried `aud: tool-runner` — correct. But the *second* link (planner → executor) carried `aud: executor-prod` and an explicit `delegation_constraints` block with `max_hops: 2` and `allowed_actions: ["read", "summarize"]`. The tool-runner performed a `write` action. The write succeeded because the verifier checked `allowed_actions` only on the terminal link, where the field was absent (absent was treated as unconstrained rather than as "inherit from nearest ancestor that constrains it"). Two distinct defects surfaced: 1. **Constraint inheritance was not transitive.** `delegation_constraints` on an ancestor link were not intersected down the chain. The terminal link's absence of a constraint was read as permissive. 2. **Hop counting was off by one at the boundary.** `max_hops: 2` was interpreted as "two links below the constraining link," but the constraining link itself counted as hop zero, allowing a chain of length three below it. The chain was valid. The action was not authorized. The gap was in the *evaluation semantics*, not in the cryptography. ## Condition of Validity An instance may act on a delegated AAIF credential **only if all of the following hold simultaneously**: - **Per-link audience match.** The link that grants the presenting instance its authority names that instance (or a group it belongs to) in `aud`. Intermediate audiences are not sufficient. - **Constraint intersection.** The effective constraint set is the *intersection* of `delegation_constraints` across every link from root to terminal. An absent constraint on a link does **not** widen the set; it inherits the accumulated intersection from ancestors. Absence at the root means unconstrained only at the root. - **Monotonic authority.** No link may grant an action, scope, or resource not present in its predecessor's effective set. Widening is a rejection condition regardless of signature validity. - **Hop budget.** The number of links below the link that declares `max_hops` must be `≤ max_hops`, counted with the declaring link as hop 0 and the terminal link as hop `n`. A chain of length `n+1` below a `max_hops: n` declaration is invalid. - **Temporal nesting.** Each link's `[nbf, exp]` must be contained within its predecessor's window. A child link that outlives its parent is invalid even if both are individually unexpired. - **Revocation reachability.** The presenting instance must be able to reach the revocation status of every link's issuer (or a cached status within the credential's `revocation_max_staleness`). An unreachable issuer is not the same as a valid issuer. If any condition fails, the instance must reject — not downgrade, not warn, not proceed with a subset of the requested action. ## Decision Boundary The decision boundary is not "does the chain verify" but "does the chain verify **and** does the intersection of its constraints permit this exact (action, resource, audience) tuple for this instance at this time." These are separable checks and must be evaluated in that order, with the second able to reject independently of the first. The subtle case is the *absent constraint*. There are three defensible semantics — inherit-from-ancestor (intersection), treat-as-empty (deny), treat-as-unconstrained (allow) — and only the first two are safe. The unsafe choice is the one most verifier libraries default to, because it is the simplest to implement: a missing field reads as "no restriction." Operational experience says: **treat absent `delegation_constraints` on any non-root link as `deny`, or as inherit-from-ancestor. Never as unconstrained.** The root link is the only place where absence legitimately means "the principal grants without further narrowing." ## Operational Effect Instances that adopt per-link audience and constraint-intersection semantics reject a measurable fraction of chains that naive verifiers accept — in the observed deployment, roughly 3% of delegated requests, concentrated in tool-runner and executor tiers where hop counts are deepest and constraints are most often narrowed mid-chain. The cost is a small increase in rejected-but-signature-valid requests; the benefit is that authority never silently widens across a hop. The failure mode this prevents — a deep-tier agent performing an action its immediate delegator was never granted — is exactly the class of failure that is invisible in logs (every signature checked out) and expensive to attribute after the fact. Encode the rejection as a distinct, non-retryable error so that callers do not loop on a credential that will never become valid. ```python class DelegationChainRejected(RuntimeError): """Raised when an AAIF delegation chain verifies cryptographically but is operationally unauthorized for the presenting instance.""" pass def authorize_delegated_action(chain, instance_aud, action, resource): # chain: list of links, root first, terminal last. Each link is a dict with # keys: aud, nbf, exp, delegation_constraints (optional), max_hops (optional). if not chain: raise DelegationChainRejected("empty delegation chain") # Per-link audience: the terminal link must name this instance. terminal = chain[-1] if instance_aud not in terminal.get("aud", []): raise DelegationChainRejected( f"terminal link audience {terminal.get('aud')!r} does not include {instance_aud!r}" ) # Constraint intersection, inherited downward. Root absence == unconstrained; # any non-root absence inherits the accumulated set (never widens it). effective = None # None means "unconstrained so far" (root only) for idx, link in enumerate(chain): constraints = link.get("delegation_constraints") if constraints is None: if idx == 0: effective = None # else: inherit `effective` unchanged -- do NOT treat as unconstrained else: if effective is None: effective = set(constraints.get("allowed_actions", [])) else: effective = effective & set(constraints.get("allowed_actions", [])) # Hop budget: declared link is hop 0; terminal is hop len(chain)-1-idx. max_hops = link.get("max_hops") if max_hops is not None: hops_below = (len(chain) - 1) - idx if hops_below > max_hops: raise DelegationChainRejected( f"link {idx} declares max_hops={max_hops} but chain has " f"{hops_below} hops below it" ) if effective is not None and action not in effective: raise DelegationChainRejected( f"action {action!r} not in intersected allowed_actions {sorted(effective)}" ) # Temporal nesting: each link's window contained in predecessor's. for idx in range(1, len(chain)): parent, child = chain[idx - 1], chain[idx] if child["nbf"] < parent["nbf"] or child["exp"] > parent["exp"]: raise DelegationChainRejected( f"link {idx} window [{child['nbf']},{child['exp']}] not nested in " f"parent window [{parent['nbf']},{parent['exp']}]" ) return True ```