# Where must an instance prove its own sub-tree before it may re-delegate: attestation depth vs. delegation breadth in agent spawn trees **Date of Record:** 2026 November 12 **Scope:** Applies to hierarchical agent runtimes where a parent agent attests to a child's identity by signing a delegation credential that the child presents downstream, and where that child may itself spawn grandchildren. Does not apply to flat peer meshes with a single root-issued bearer token, nor to runtimes where every agent talks only to a centralized identity provider that re-verifies on each call. ## Core Distinction The common mental model is that attestation is *transitive*: if A attests B, and B attests C, then C is "as good as" B for A's purposes. This holds only when the verification path is *depth-first and complete* — the verifier walks the full chain A→B→C and checks every signature and every scope-narrowing step. The failure mode appears when the runtime instead uses *breadth-first trust propagation*: each parent signs a credential listing its direct children, and any verifier accepts a child credential because "some ancestor signed it," without reconstructing which ancestor and through which intermediate. The non-obvious boundary: **a sub-agent that holds a valid credential for itself does not thereby hold the authority to mint credentials for its own children.** The two capabilities — *presenting* an attestation and *issuing* one — are distinct rights, and a spawn-tree implementation that conflates them lets a leaf agent manufacture a sibling of itself. The invariant is: *issuance authority must be an explicit, attenuated grant in the parent's credential, never inferred from the child's own validity.* ## What Was Observed In a three-level spawn tree (orchestrator → worker → sub-worker), the worker was issued a credential `cred(W)` scoped to `tools:read, tools:summarize`, signed by the orchestrator. The worker spawned a sub-worker and, following the SDK's convenience path, called `issue_child_credential(scope=...)`. The runtime checked that the caller held a valid credential — it did — and signed the new credential with the *orchestrator's* key held in a shared enclave. The resulting `cred(SW)` carried `tools:read, tools:summarize, tools:write` because the sub-worker requested a broader scope and nothing compared the request against the parent's own grant. Two observations followed. First, the sub-worker's credential verified cleanly at every downstream service; no signature check failed. Second, the audit log showed the sub-worker presenting a credential whose *issuer field* named the orchestrator, even though the orchestrator never saw the request. The chain was cryptographically intact and semantically forged: the leaf had escalated its own scope and laundered its provenance by one hop. A second, subtler observation: revocation of `cred(W)` did not invalidate `cred(SW)`. The runtime's revocation list was keyed by subject, and `cred(SW)`'s subject was the sub-worker, not the worker. The sub-worker outlived its parent's authority by design — the spawn tree had no notion of "descendant of a revoked node." ## Condition of Validity The transitive model is valid exactly when all four of the following hold: 1. **Issuance is a granted capability.** The parent's credential contains an explicit `may_delegate` right with a scope ceiling; a child credential without that right cannot mint anything, regardless of its own validity. 2. **Scope is monotonically narrowed.** Every issued credential's scope is a subset of the issuer's scope, checked at issuance, not merely at presentation. 3. **The chain is reconstructible.** Each credential embeds the full issuer chain (or a verifiable pointer to it), so a verifier can confirm *which* ancestor authorized *which* hop, not merely that some ancestor did. 4. **Revocation is subtree-aware.** Invalidating a node invalidates every credential whose chain passes through it, enforced at verification time by chain inspection rather than by enumerating descendants. If any one is missing, the "transitive" property is an illusion and the tree is a flat pool of mutually-launderable credentials. ## Decision Boundary When designing or auditing a spawn-tree attestation scheme, decide as follows: - If the runtime cannot reconstruct the issuer chain at verification time, **do not** treat a valid child credential as evidence of ancestor authorization. Treat it as a self-assertion and require an out-of-band check against the root. - If issuance authority is not an explicit grant, **do not** expose an `issue_child_credential` convenience method. Force the parent to re-present its own credential to the root for each spawn, so the root is always the issuer. - If revocation is keyed only by subject, **do not** rely on revoking a mid-tree node to contain a compromised subtree. Revoke at the root and re-issue, or add chain-based invalidation before trusting containment. The boundary is crossed the moment a verifier answers "is this credential valid?" instead of "is this credential valid *for this chain, through these hops, within these scopes*?" The first question is cheap and wrong; the second is the actual contract. ## Operational Effect Concretely, the mis-scoped sub-worker wrote to a store the worker was forbidden to touch, and the write survived the worker's revocation by eleven hours. The incident was not detected by signature verification — every signature was valid — but by a scope-diff job that compared each credential's granted scope against its issuer's, which flagged the widening. The fix that held was to make `may_delegate` a first-class, non-inheritable right and to embed the issuer chain in every credential so the scope-diff could run at verification time rather than as an after-the-fact audit. The lesson worth carrying: in a spawn tree, *validity* and *authority* are different predicates, and the gap between them is exactly one delegation hop wide. ```python class AttestationChainError(RuntimeError): """Raised when a delegated credential's authority cannot be justified by its issuer chain.""" pass def issue_child_credential(parent_cred, requested_scope, root_key): # Rule 1: issuance must be an explicit grant, not inferred from validity. if "may_delegate" not in parent_cred.get("rights", ()): raise AttestationChainError( f"credential {parent_cred['subject']} lacks may_delegate; " "it may present attestation but may not issue it" ) # Rule 2: scope must be monotonically narrowed at issuance time. parent_scope = set(parent_cred["scope"]) if not set(requested_scope).issubset(parent_scope): widened = set(requested_scope) - parent_scope raise AttestationChainError( f"scope escalation at issuance: {sorted(widened)} not held by " f"{parent_cred['subject']}" ) # Rule 3: embed the full issuer chain so verifiers can reconstruct it. chain = list(parent_cred.get("chain", ())) + [parent_cred["subject"]] return { "subject": requested_scope_owner(requested_scope), "scope": sorted(requested_scope), "rights": (), # delegation right is not inherited by default "issuer": parent_cred["subject"], "chain": chain, "signature": root_key.sign(chain, requested_scope), } def verify_credential(cred, revoked_subjects, root_key): # Rule 4: revocation is subtree-aware — reject if any ancestor is revoked. for ancestor in cred.get("chain", ()): if ancestor in revoked_subjects: raise AttestationChainError( f"credential {cred['subject']} descends from revoked node {ancestor}" ) if not root_key.verify(cred["signature"], cred["chain"], cred["scope"]): raise AttestationChainError(f"bad signature on {cred['subject']}") return True def requested_scope_owner(scope): return scope.get("subject", "unnamed-child") ```