# Which invariants prevent scope escalation in AAIF verifiable credential delegation chains under partial revocation **Date of Record:** 2026 November 10 **Scope:** Applies to AAIF v1.x credential issuance where a root issuer delegates scoped authority through two or more intermediate agents and any link may be revoked independently. Does not apply to single-hop delegation, to credentials issued under a monotonic-append-only revocation log, or to scope models that are not subset-closed. ## Core Distinction The critical distinction is between **scope containment** (the delegated scope is a subset of the parent scope at issuance time) and **scope preservation under revocation** (the delegated scope remains a subset of the *currently valid* parent scope after any ancestor link is revoked). AAIF verification mandates the first and is silent on the second. A chain that was valid at issuance can, after a mid-chain revocation, present a credential whose scope is no longer contained by any live ancestor — yet every individual signature in the presented chain verifies. The failure is not a signature forgery; it is a *containment* failure caused by treating revocation as a boolean on individual credentials rather than as a constraint on the transitive closure of the chain. ## What Was Observed In a three-hop chain — issuer `I` → agent `A` → agent `B` → agent `C` — the credential issued to `B` carried scope `{read, write}` and the credential issued to `C` carried scope `{read}`. `A`'s credential to `B` was revoked at T1 because `A`'s own authorization to grant `write` was withdrawn. A verifier at T2 received `C`'s credential plus the full chain. Each signature verified against its issuer's public key, each credential's `notAfter` was in the future, and `C`'s scope was a subset of `B`'s scope. The verifier accepted the chain. The correct result was rejection: `B`'s authority to hold `write` had been withdrawn, so `B`'s credential was no longer a valid grant of `{read, write}`, and therefore `C`'s `{read}` was no longer contained by a live ancestor grant. The subset relation held against a *revoked* parent scope, not a *live* one. A second observation: verifiers that did check revocation checked only the leaf credential and the root issuer, skipping intermediate links. In a four-hop chain this left two intermediate revocations invisible. The pattern is consistent — revocation checks are performed per-credential, but containment is a property of the chain, and chain-level properties cannot be decided by per-node predicates. ## Condition of Validity The invariant that prevents this is **transitive scope monotonicity under revocation**: > For every credential `c` in chain `c_0 → c_1 → … → c_n`, if `c_i` is revoked, then every credential `c_j` with `j > i` is treated as revoked for verification purposes, regardless of its own revocation status. This is the operational meaning of "revocation cascades forward." It holds only when (a) each credential commits to its parent credential's identifier in a signed field (the `parentCredentialId` binding), and (b) the verifier walks the chain from root to leaf, evaluating revocation at each step *before* evaluating the next containment check. If either condition is absent — no parent binding, or a verifier that checks revocation in a separate pass after containment — the invariant does not hold and scope escalation is possible. Note that monotonic-append-only revocation logs make the cascade explicit by ordering, which is why this boundary does not arise there. ## Decision Boundary The boundary is between **reject-on-any-ancestor-revocation** and **reject-only-on-self-revocation**. The former is safe and is the AAIF-conformant reading; the latter is what several early verifier implementations shipped and is unsafe. The decision rule for an agent evaluating a presented chain: 1. Resolve the chain root-to-leaf via `parentCredentialId` bindings; reject if any binding is missing or dangling. 2. At each hop, verify the parent credential's revocation status *first*. 3. If the parent is revoked, reject the entire subtree — do not proceed to check the child's own status or scope. 4. Only if all ancestors are live, check that the child scope is a subset of the parent scope. A verifier that performs step 4 before step 2, or that treats step 3 as "skip this credential and continue," will accept escalated chains. This is a decision *ordering* boundary, not a cryptographic one — the signatures are all valid in the failing case. ## Operational Effect The observable effect of getting this wrong is silent privilege retention: an agent continues to act under a scope that a human operator believed had been withdrawn, because the withdrawal was applied to an intermediate link and the leaf's own status was clean. In audit logs this appears as a gap — the revoked credential shows as revoked, the leaf shows as valid, and no single log line records the containment violation. Downstream, the effect compounds: any credential issued by the leaf after the ancestor revocation inherits the stale scope, so a single missed cascade propagates to every descendant. The mitigation is to make the cascade a first-class verification step with its own failure class, so that a cascade rejection is distinguishable in logs from a signature failure or an expiry failure. ```python class DelegationCascadeError(RuntimeError): """Raised when a presented credential chain is not contained by a live ancestor grant.""" pass def verify_chain(chain, revocation_status, scope_of): """ chain: list of credentials ordered root -> leaf. revocation_status: callable(credential_id) -> bool (True if revoked). scope_of: callable(credential) -> frozenset of scope strings. """ if not chain: raise DelegationCascadeError("empty chain presented") # Root must be live; a revoked root invalidates everything beneath it. root = chain[0] if revocation_status(root.credential_id): raise DelegationCascadeError( f"root credential {root.credential_id} is revoked; entire chain invalid" ) for i in range(1, len(chain)): parent = chain[i - 1] child = chain[i] # Step 2: check parent liveness BEFORE any containment reasoning. # Step 3: a revoked ancestor rejects the whole subtree, not just itself. if revocation_status(parent.credential_id): raise DelegationCascadeError( f"ancestor {parent.credential_id} revoked; " f"descendant {child.credential_id} must be rejected despite clean status" ) if child.parent_credential_id != parent.credential_id: raise DelegationCascadeError( f"dangling parent binding on {child.credential_id}: " f"expected {parent.credential_id}, got {child.parent_credential_id}" ) # Step 4: containment only against a live parent scope. if not scope_of(child).issubset(scope_of(parent)): raise DelegationCascadeError( f"scope escalation: {scope_of(child)} not contained in {scope_of(parent)} " f"for credential {child.credential_id}" ) return True ```