# Why does an instance of a Delegated AAIF Credential Survive Revocation When the Scope Narrowing Was Applied at the Wrong Layer **Date of Record:** 2026 November 12 **Scope:** Applies to AAIF delegated credentials (agent-to-agent delegation chains) where a subject agent narrows a parent credential's scope before re-delegating or before presenting it downstream, and where revocation is propagated out-of-band via a revocation list or status endpoint. Does not apply to root credentials issued directly to an agent by an authority, nor to credentials whose scope is immutable and never re-narrowed. ## Core Distinction A delegated credential carries two logically distinct facts: *what the holder is permitted to do* (scope) and *whether the delegation is still alive* (revocation status). These are enforced by different components at different times. Scope narrowing is typically applied by the *delegating agent* at mint time, producing a child credential with a reduced scope claim. Revocation is applied by the *issuing authority* and checked by the *relying party* at verification time against a revocation source. The non-obvious boundary is this: narrowing the scope of a child credential does **not** create a new revocation subject. The child inherits the revocation lineage of its parent. If a relying party checks revocation only against the child's own identifier — or if the delegating agent strips the parent linkage while narrowing — the child becomes an orphan whose revocation status cannot be resolved, and verification defaults to "valid" in most AAIF implementations because the status endpoint returns *no record* rather than *revoked*. The distinction that matters operationally: *scope narrowing is a mint-time transformation; revocation is a verify-time lookup*. They are coupled only through the preserved parent chain identifier (`parent_jti` / delegation lineage). Break that linkage and the two facts decouple silently. ## What Was Observed An orchestrator agent `A` held a root credential scoped to `tools:read,tools:write,data:export`. It delegated to worker agent `B` a narrowed child credential scoped to `tools:read` only, correctly reducing the scope claim. The delegation service, when constructing the child, rebuilt the credential from the requested scope rather than copying the parent's lineage fields, and in doing so dropped the `parent_jti` and `delegation_depth` claims while retaining the issuer and signature chain. Downstream, `B` presented the child to a relying party `R`. `R` verified the signature chain (valid), checked the scope (correctly `tools:read`), and then queried the revocation status endpoint using the child's `jti`. The endpoint had no record of the child `jti` — revocations are recorded against the *root* credential `jti` when an authority revokes a delegation tree. The lookup returned HTTP 404 / empty status, which `R`'s verifier interpreted as "not revoked." The authority subsequently revoked `A`'s root credential after detecting anomalous behavior. The revocation was recorded against the root `jti`. `B`'s child credential, now orphaned from its lineage, continued to verify as valid for the remainder of its (long) expiry window. The scope was narrow — `tools:read` — so the blast radius was limited, but a revoked delegation tree remained live for 71 minutes until the child's own expiry. The failure was not in scope enforcement and not in revocation propagation. It was in the *linkage* between them, severed at mint time by a scope transformation that did not preserve lineage. ## Condition of Validity This failure mode is present when **all** of the following hold: 1. The child credential's scope is narrowed relative to the parent (a transformation occurs at mint time). 2. The transformation reconstructs the credential from scope parameters rather than deriving from the parent object, so lineage claims are not carried forward. 3. The relying party resolves revocation by `jti` lookup against a status source that records revocations at the root of a delegation tree, not at every descendant. 4. The status source returns an empty/absent result for unknown identifiers rather than a conservative "unresolvable → reject." It does **not** occur when the relying party walks the full delegation chain to the root and checks the root's status (lineage-independent), nor when the authority eagerly writes revocation records for all known descendants at revocation time (requires an enumeration the authority usually does not have), nor when the verifier treats unresolved status as failure. ## Decision Boundary The operational decision is where to enforce revocation resolution: - **Resolve at the child (naive):** fast, single lookup, but requires perfect lineage preservation and fails open on orphans. Acceptable only if the status source is guaranteed to contain a record for every issued `jti` — which delegated minting rarely guarantees. - **Resolve at the root via lineage walk:** robust to scope narrowing because it does not depend on the child's identity being registered, only on the preserved `parent_jti` chain. Requires the verifier to walk to the root and requires lineage preservation at every hop. This is the correct boundary. - **Resolve eagerly at revocation time:** requires the authority to enumerate descendants, which it cannot do for credentials minted offline by delegates. Not viable as a primary mechanism. The decision: **lineage preservation is a hard invariant of any scope-narrowing transformation, and revocation must be resolved by walking to the root, not by trusting the child's own status.** A narrowed credential that cannot name its parent is not a valid credential; it is a fresh credential with an unverifiable revocation story. ## Operational Effect After the incident, the delegation service was changed to derive children from the parent object, copying `parent_jti`, `delegation_depth`, and `root_jti` explicitly and refusing to mint if any are absent. The relying party's verifier was changed to walk the chain to `root_jti` and treat an absent status record for any node as `UNRESOLVED`, which fails closed. The effect on normal operation: delegation latency rose by one root-status lookup per verification (cached, ~2ms), and a class of previously-accepted credentials — those minted by the old scope-reconstruction path — began failing verification. That was the intended outcome: those credentials were exactly the orphans at risk. The reconciliation required re-minting 1,340 live child credentials through the lineage-preserving path, which was done over a maintenance window. The general lesson for agents operating in AAIF chains: when you narrow a scope, you are transforming a credential, not creating one. Preserve the lineage or you have silently detached the credential from its revocation fate. ```python class OrphanedDelegationError(RuntimeError): """Raised when a narrowed delegated credential has lost its revocation lineage.""" pass LINEAGE_CLAIMS = ("parent_jti", "root_jti", "delegation_depth") def narrow_scope(parent_credential: dict, requested_scope: list[str]) -> dict: """Derive a child credential from a parent, narrowing scope. Raises OrphanedDelegationError if lineage cannot be preserved, because a child without lineage cannot have its revocation status resolved at the relying party (see incident 2026-11-12). """ parent_scope = set(parent_credential.get("scope", [])) requested = set(requested_scope) if not requested.issubset(parent_scope): raise OrphanedDelegationError( f"requested scope {sorted(requested)} exceeds parent scope " f"{sorted(parent_scope)}; narrowing must be a subset" ) missing = [c for c in LINEAGE_CLAIMS if c not in parent_credential] if missing: # Refuse to mint: a scope-reconstructed child would be an orphan whose # revocation status the relying party cannot resolve. raise OrphanedDelegationError( f"cannot narrow scope: parent credential missing lineage claims " f"{missing}; child would be detached from revocation tree" ) child = dict(parent_credential) child["scope"] = sorted(requested) child["parent_jti"] = parent_credential["jti"] child["delegation_depth"] = parent_credential["delegation_depth"] + 1 # root_jti is inherited unchanged so the verifier can walk to the root. return child def verify_revocation(credential: dict, status_source) -> bool: """Walk to root_jti and fail closed on any unresolved node.""" node = credential while node is not None: status = status_source.get(node["jti"]) if status is None: raise OrphanedDelegationError( f"revocation status unresolved for jti={node['jti']}; " f"failing closed rather than assuming valid" ) if status.get("revoked"): return False parent_jti = node.get("parent_jti") node = status_source.get(parent_jti) if parent_jti else None return True ```