source
ADR-016: Delegation Lineage Hash Index
Date: 2026-04-21
Date: 2026-04-21
Status
Accepted.
Context
Delegated JWT passports carry a signed delegation_chain so a cold verifier
can walk ancestry without loading every ancestor session blob from disk. Shape
checks alone are not enough: a signer could mint a self-consistent child token
that references a fabricated parent_jti, or re-parent a real ancestor JTI to
a different ancestor and make revocation checks walk the wrong lineage.
The stricter alternative of requiring the raw parent_token for every
delegated verification closes that gap, but it breaks the cold revocation
walk that the signed-chain design was meant to support.
Decision
Delegated verification now requires one of two anchors:
- The raw
parent_token, which is hashed and matched againstparent_token_hash, then used to reconstruct the exact expected parent chain. The child token’sdelegation_chainmust match that reconstructed lineage. - A trusted proxy-maintained lineage index mapping ancestor
jtito both the token hash and the recorded parent edge (parent_jti,parent_token_hash) captured when that ancestor session was started.
The proxy persists this index in lineage_hashes.json under the existing
passport-state lock. Cold verifiers use the index, not ancestor session JSON,
so revocation checks remain bounded while fabricated or re-parented ancestry
fails closed.
Consequences
- A delegated token with only a self-consistent signed chain is rejected by the
generic verifier unless the caller supplies
parent_tokenor bothtrusted_parent_token_hashesandtrusted_parent_lineage. - Existing cold proxy verification remains supported when the parent lineage was previously started and recorded by the proxy.
- If
lineage_hashes.jsonis missing or corrupt in a state directory that already has sessions, delegated verification fails closed until an operator rebuilds or rotates state. The pre-lineage migration path rebuilds the index from on-disk session JSONs so pre-migration intermediate sessions keep their real(parent_jti, parent_token_hash)edge — an empty backfill would mark every intermediate as a root and silently break grandchild re-parenting detection for chains that straddle the migration boundary (2026-04-21 audit).
Known limitation: Biscuit-origin lineage is intra-token, not whole-token
The JWT anchor design above compares sha256(whole_jwt_bytes) on both sides:
trusted_parent_token_hashes stores _passport_token_hash(stored_token) and
child tokens carry the same hash in parent_token_hash. For Biscuit-origin
delegated sessions, biscuit_passport._context_from_blocks emits each chain
link’s token_hash as sha256(block_source) — the Datalog text of one block
inside a nested Biscuit — not sha256(biscuit_bytes). Two consequences:
- Cold verification of a Biscuit-derived chain across proxy process
boundaries compares hashes from different domains and will not match the
proxy’s whole-token
trusted_parent_token_hashes. In-process flows avoid the mismatch only because the proxy holds the live session. - The “preserves revocation lineage” claim in the 2026-04-21 session doc for nested Biscuit children currently holds only through live session state, not through the lineage index alone.
Follow-up work (tracked for a separate ADR) must land one canonical
hash domain across JWT and Biscuit paths — either by extending
_context_from_blocks to carry sha256(accumulator_bytes) per block, or
by routing Biscuit verification through a Biscuit-native revocation check
that bypasses trusted_parent_token_hashes entirely. Neither is attempted
in this ADR.