ADR 0024: Trust-root lineage and per-lineage receipt-head retention
- Status: Accepted
- Date: 2026-09-29
Refinement: ADR 0025 supersedes the one-active-signer restriction and the replacement-only signer receipt surface with signed backup-authority addition. The lineage/head-retention decisions and bootstrap preimage below remain unchanged.
Context
ADR 0022 introduced customer-signed policy approval evidence (envelopes, receipts) and reserved signer lifecycle, trust roots, and enforcement rollout for later implementation. ADR 0023 fixed the approval-statement and verification contract. Slice 3 now needs durable state for the remaining pieces: a signing registry, trust roots that survive a lost-all-keys reset, the per-request receipt-chain head, and the enforcement cutover flag.
Two requirements collide on the receipt-chain head table:
- The row-locked
seqassignment point and the per-requestsigner_epochcheck must operate on a single mutable active head per workspace. - A trust reset (all authorized keys lost) starts a new independently pinned root, and every prior root and its head must remain immutable, separately verifiable evidence.
A workspace_id-primary-keyed head table satisfies (1) but cannot itself hold
“immutable archived heads per lineage” (2) — one row per workspace cannot retain
one frozen head per retired root. Keying the live head on
(workspace_id, root_id) satisfies (2) but turns the hot seq-assignment row
into a composite and makes “which head is active” implicit.
Decision
Active head + per-lineage archive. The schema keeps policy_receipt_heads
as a single workspace_id primary-keyed active-head pointer whose root_id
column is the active-root pointer (changed only inside the trust-reset
transaction). A companion policy_receipt_heads_archive(workspace_id, root_id, receipt_id, seq, head_digest, signer_epoch, updated_at, archived_at) holds one
frozen row per superseded lineage. A reset copies the prior head into the
archive exactly as it stood; policy_receipt_heads_archive_guard then rejects
every update or delete.
The rest of the Slice-3 schema follows the same immutable-evidence discipline (Slice-2 trigger style):
policy_trust_roots— permanent lineage records (genesis_bundle_digest,previous_root_id?,previous_head_digest?,recorded_by,created_at).previous_root_id/previous_head_digestare an unsigned discontinuity annotation naming what was retained, not continuity evidence; a new root never extends an old chain.recorded_byis the authenticated operator action, not proof of customer acceptance. No update, no delete.policy_signers— identity and COSE key immutable, status forward-only (active → superseded | revoked), never deleted, one active signer per(workspace, root).signer_epochincrements per authorized transition within a root and restarts at 1 for a new root.policy_enforcement_state—inactive → enforcedonce at cutover, forward-only and permanent.- Slice-2
policy_envelopes/policy_receiptsgainroot_id; envelope versions and receipt sequences are unique per(workspace_id, root_id)so a reset restarts each at 1 rather than colliding across roots.
The design rationale is recorded normatively in the Slice-3 protocol specification §8 (the record ADR 0022 defers schema detail to); this ADR captures the lasting storage/trust-boundary choice.
Bootstrap artifact_digest preimage. The first signer (bootstrap) and a
trust reset establish a genesis TrustBundle and emit no chained receipt —
SignerAuthority/ReplaceSigner is the only signer receipt and requires a
replacement plus a retired credential that bootstrap lacks. So
receipt_digest() does not apply, and the challenge’s artifact_digest needs
its own preimage. We bind it to the exact authorized-signer statement from spec
§5:
artifact_digest = sha256(canonical_cbor(SignerBootstrapBinding {
purpose: "signer_bootstrap", // fixed domain discriminant
workspace_id,
cose_public_key, // base64url of the COSE_Key CBOR
signer_epoch,
}))
Implemented as signer_bootstrap_digest() in maskura-pipeline-config (the
single source of truth) and wrapped by policy_trust::bootstrap_artifact_digest()
so ceremony issuance and assertion verification can never derive different
bytes. The purpose discriminant gives the same domain separation
ReceiptBody.purpose provides inside digest_of, so a bootstrap binding can
never collide with a receipt or any other artifact digest. This is option (b)
of the candidate preimages — a purpose-built statement digest over
(workspace, COSE key, signer_epoch) — chosen for the most literal match to
spec §5 and the easiest independent audit; (a) reusing digest_of(TrustCredential)
was rejected because TrustCredential carries fields (label, status) not part
of the authorized-signer statement.
Consequences
- Enables: the hot seq-assignment path stays a single-row update on
policy_receipt_heads; evidence retention across resets is first-class and immutable, so a customer can independently verify any prior root’s head without trusting a service-provided reset bundle. - Costs: one extra table and a copy step in the reset transaction;
root_idmust be threaded through envelope/receipt writers and the verification path; the active-root pointer lives on the head row, so “which lineage is active” is only answerable by readingpolicy_receipt_heads. - Trust posture: a lost-all-keys reset is an explicit discontinuity — a new genesis root that a customer must pin out of band — never a forged continuation. This is enforced by construction (no old-chain signature is emitted for a reset) and by the immutable lineage rows.