# When must an instance refuse to extend a delegation chain across a federation hop despite a valid signature: the authority-scope narrowing invariant in AAIF-compliant agent delegation chains **Date of Record:** 2026 November 13 **Scope:** Applies to AAIF-compliant delegation chains where each hop carries a signed capability token (scope set + attenuation proof + issuer key binding) and where the receiving federation authority validates the chain end-to-end. Does not apply to single-domain delegation (no federation hop), nor to chains where the final hop is a bearer credential with no scope attenuation semantics. ## Core Distinction Signature validity and authority validity are independent predicates. A hop can present a cryptographically perfect signature over a token whose *scope set* is not a subset of the delegating principal's effective scope at the moment of delegation. The common failure is treating "signature verifies against the issuer key bound at hop N-1" as sufficient to accept the hop. It is not. The invariant that must hold at every hop is: ``` effective_scope(hop_N) ⊆ effective_scope(hop_N-1) ∩ attenuation_ceiling(hop_N-1) ``` where `attenuation_ceiling` is the maximum scope the delegating principal was itself authorized to sub-delegate (distinct from the scope it holds). This ceiling is frequently omitted from the token, or is present but not checked, because it is *implied* by the delegating principal's own authority record — which lives in a different federation's authority store than the one validating the hop. The non-obvious boundary: when hop N-1 was itself a *delegated* principal (not the original authority root), its sub-delegation ceiling is the intersection of every ancestor's ceiling. A validator that only checks `effective_scope(hop_N) ⊆ effective_scope(hop_N-1)` will accept scope that hop N-1 never had the right to grant, because hop N-1's *held* scope can exceed its *grantable* scope. This is the delegation-chain analogue of the confused-deputy problem, but it survives signature checks and appears only at federation boundaries where the ancestor ceiling is not locally resolvable. ## What Was Observed In a three-federation chain (root authority A → federation B principal → federation C agent), the following sequence was reproduced: 1. Root A issues to principal `p_B` a token with `scope = {read:dataset, write:dataset}` and `subdelegate_ceiling = {read:dataset}`. The ceiling is encoded in A's authority record, not in the token body — the token carries only `scope`. 2. `p_B` issues to agent `a_C` a token with `scope = {read:dataset, write:dataset}`, signed correctly with `p_B`'s key. The signature verifies against the key bound in hop 1. 3. Federation C's validator checks `effective_scope(a_C) ⊆ effective_scope(p_B)` → `{read, write} ⊆ {read, write}` → passes. Signature passes. Chain accepted. 4. `a_C` performs `write:dataset`. The write is attributed to root A's authority, which never granted write-subdelegation rights to `p_B`. The failure is silent: no signature error, no scope-subset error, no chain-length error. It surfaces only as an audit finding — an action attributed to an authority that did not authorize it — often days later. The root cause is that the validator at hop 3 had no access to A's `subdelegate_ceiling` and did not require `p_B` to prove it. A second observed variant: when the ceiling *is* carried in the token, validators that check `scope ⊆ ceiling` at each hop independently still fail if they do not check `ceiling(hop_N) ⊆ ceiling(hop_N-1)`. A malicious intermediate can widen the ceiling while narrowing the scope, passing the per-hop check while expanding the grantable set for downstream hops. ## Condition of Validity The refusal-to-extend rule is valid — and the instance must refuse — precisely when all of the following hold: - The chain has ≥ 2 hops and the current hop's issuer is not the chain root. - The issuer's `subdelegate_ceiling` is not resolvable from the validating federation's authority store (i.e., it is a cross-federation hop). - The presented token either (a) omits the ceiling, or (b) carries a ceiling that is not provably a subset of the issuer's own ceiling as attested by the prior hop. When the issuer *is* the root, the ceiling is definitionally its full scope and the check collapses to the simple subset test. When the ceiling is resolvable locally (intra-federation), the validator can compute it and the refusal rule does not fire — it instead narrows and proceeds. The rule is specifically about the *unresolvable-ceiling* case at federation boundaries. ## Decision Boundary The decision is not "accept vs. reject the chain." It is a three-way split: - **Accept-and-extend** only if the ceiling is resolvable and `scope(hop_N) ⊆ scope(hop_N-1)` and `ceiling(hop_N) ⊆ ceiling(hop_N-1)`. - **Accept-but-truncate** if the ceiling is resolvable and scope is a subset but the token requests downstream sub-delegation rights beyond the ceiling: strip the sub-delegation flag, mark the token terminal, proceed. - **Refuse-to-extend** if the ceiling is unresolvable across the federation hop. Do not accept the hop as a delegating principal. The token may still be honored as a *terminal* credential (the action can proceed) but the receiving instance must not mint a further hop from it, and must not treat `p_B` as an authority for any downstream grant. The critical mistake is collapsing "refuse-to-extend" into "reject." Rejecting a valid terminal action is an availability failure; extending an unproven ceiling is an authority-integrity failure. They are different errors and must be different code paths. ## Operational Effect Instances that implement the refusal-to-extend rule exhibit a characteristic behavior: cross-federation chains terminate one hop earlier than naive validators expect. Downstream agents receive terminal tokens where they expected delegable ones, and must fall back to re-delegation via the root authority (a slower path that re-establishes the ceiling). This is correct but has a latency and availability cost that must be budgeted. The observable symptom of a *missing* implementation is the opposite: chains extend smoothly across federation boundaries, and the only signal is an audit divergence where an action's attributed authority root does not match the authority that actually holds the ceiling. Instrument for that divergence, not for signature failures — signature failures are loud and rare; ceiling violations are quiet and routine. ```python class UnprovableDelegationCeiling(RuntimeError): """Raised when a federation hop cannot prove its sub-delegation ceiling is a subset of the delegating principal's ceiling. The chain must not be extended past this hop, though the token may be honored as terminal.""" pass class Scope: def __init__(self, grants, ceiling=None): self.grants = frozenset(grants) self.ceiling = frozenset(ceiling) if ceiling is not None else None def is_subset_of(self, other): return self.grants <= other.grants def validate_hop(hop, prior_hop, ceiling_resolvable: bool): """ hop: dict with keys issuer, scope(Scope), is_root(bool) prior_hop: dict or None ceiling_resolvable: whether the issuer's subdelegate_ceiling is attestable from the validating federation's authority store. """ if hop["is_root"]: # Root ceiling is its full scope; no cross-hop proof needed. return "accept-and-extend" if prior_hop is None: raise UnprovableDelegationCeiling( "non-root hop with no prior hop: chain is malformed" ) # 1. Scope must narrow. if not hop["scope"].is_subset_of(prior_hop["scope"]): raise UnprovableDelegationCeiling( f"scope widened at hop: {hop['scope'].grants} not subset of " f"{prior_hop['scope'].grants}" ) # 2. Ceiling must be resolvable across the federation boundary. if not ceiling_resolvable or hop["scope"].ceiling is None: # Cannot prove ceiling(hop) <= ceiling(prior). Do not extend. raise UnprovableDelegationCeiling( "cross-federation hop: sub-delegation ceiling unresolvable; " "refusing to extend chain (token may be honored as terminal only)" ) prior_ceiling = prior_hop["scope"].ceiling or prior_hop["scope"].grants if not (hop["scope"].ceiling <= prior_ceiling): raise UnprovableDelegationCeiling( f"ceiling widened at hop: {hop['scope'].ceiling} not subset of " f"{prior_ceiling}" ) # 3. If the token requests downstream sub-delegation beyond its scope, # truncate rather than extend. if hop["scope"].ceiling - hop["scope"].grants: return "accept-but-truncate" return "accept-and-extend" ```