Receipt-chain verification contract (ADR-0026 re-verifiability, made enforceable)
Implementation status. The core of this contract is implemented and unit/integration-tested in this repository:
icn-governance::verify(integrity/authenticity recompute overGovernanceDecisionReceipt/GovernanceProof, fail-closedPass/Fail/Unresolved/NotApplicablefolding whereNotApplicableis the least-severe status, chain-link and collision checks), consumed by a rewrittenicnctl audit verifythat recomputes the decision hash from content instead of comparing hash strings. Per-ladder-class field recompute (activation → mutation-plan → mutation-applied) and read-path re-verification inside the gateway store remain follow-on. This proves integrity (and authenticity where a key is supplied), not authorization or legitimacy.
What this document is. The normative design contract for a mechanical, offline re-verifier of ICN receipt chains. It writes down what "verify a receipt chain" must check, making the re-verifiability invariant already asserted by ADR-0026 falsifiable rather than merely stated.
What this document is NOT. It does not modify the receipt schema, the receipt store's on-disk format, or any auth/write path; and a passing verdict proves integrity/authenticity only — never authorization or institutional legitimacy. Canonical current state lives in `../STATE.md`.
Problem (evidence, not assertion)
ADR-0026 records four invariants for the receipt-and-provenance envelope; two of them are the entire value proposition:
- Immutable — "Once a layer's record is signed and persisted, it is not edited. Reversal is a counter-record, not a mutation."
- Re-verifiable — "Anyone with the public keys can verify the signature chain. Vote sets are merkle-rooted; proof hashes bind the set."
The primitives to enforce re-verifiability exist and are unit-tested.
icn-governance::proof exposes compute_vote_hash, verify_signature, ~14
per-class compute_record_hash(fields…) associated functions, and one
verify_binding(&self) (on the legacy GovernanceProof type); a separate
verify_binding family lives in icn-kernel-api::proofs. The mutation rung is
genuinely hash-chained (ActivationCrossedReceipt.decision_record_hash →
MutationPlanRecordedReceipt.activation_record_hash →
MutationAppliedReceipt.plan_record_hash → EvidencePacket…). (Note: the
Layer-2 ladder classes expose compute_record_hash(fields…) as an associated
function, not a &self verify_binding/verify — see "Where it belongs".)
But the store/read/audit path never re-verifies. As of b44a1821
(2026-07-17), there is exactly one runtime re-verification anywhere, and it is
not on the path that matters here: the gossip ingest handler
(apps/governance/src/actor.rs::handle_incoming) calls verify_binding() +
verify_signature() on an incoming legacy Layer-1 GovernanceProof to
reject bad remote proofs. Nothing re-verifies a stored Layer-2 receipt on
read, audit, or export:
- No component recomputes a stored receipt's hash or checks its signature when reading it back. The only re-verification (above) is at gossip ingest of the legacy Layer-1 proof, not on the store/audit/export path this contract covers.
- The gateway receipt store hydrates receipts with
serde_json::from_slice(&bytes)and no read-side hash recomputation (icn-gateway/src/receipt_store.rs). Persistence is trust-on-write: the store guarantees write-once uniqueness and a(class, record_hash)bind collision sentinel, but it never re-derives the hash from the payload on read. icnctl audit verify(bins/icnctl/src/main.rs,verify_receipt_chain) performs string-equality checks only — e.g.chain.decision_hash == decision_hash, "every allocation references the correct decision hash", "every intent hash is claimed by an allocation". It never recomputes a record hash from the receipt's canonical fields and never verifies a signature.
Consequence: a receipt whose stored record_hash field is internally
consistent with its sibling references, but whose content has been altered,
passes every check the system currently runs. The invariant ADR-0026 promises
("re-verifiable", "immutable") is not mechanically defended anywhere. This is a
credibility hole at the exact center of the value proposition — the appliance
evidence-export witness, a steward's audit, and any future federated peer all
inherit it.
This document is the missing Layer 4 re-verification contract. ADR-0026 §
Layer 4 (ProvenanceQuery, candidate ADR-0072) is about discovery/query
("holder-queryable"); this contract is the orthogonal re-verification half
("re-verifiable"), which ADR-0026 asserts as an invariant but leaves to no
component.
Scope
In scope: a pure, side-effect-free verifier that takes a set of already-serialized
receipts (from the store, from an evidence export, or from a federated peer) and
returns a structured verdict. It reuses the existing proof.rs hashing and
signature functions — it does not re-implement the BLAKE3 domain-tagged
encoding (re-implementation would risk divergence from the canonical algorithm
and is explicitly forbidden here).
Out of scope: the query/index surface (ADR-0026 Layer 4 / candidate ADR-0072); any new receipt class; any change to how receipts are written; cross-node transport; and challenge/reversal semantics (those are a separate gap — see Related work).
The verification contract
A verifier over a receipt set MUST perform, and report per-receipt and per-chain, all of the following. Every check fails closed: an unknown class, a missing field, a decode error, or an unresolvable reference is a FAIL, never a skip-and-pass.
V1 — Record-hash re-derivation (per receipt)
For each receipt, recompute its record_hash (or class-specific proof hash)
from its canonical fields and compare to the stored value. For the Layer-2
ladder classes, this means reading the receipt's public fields and passing them,
in canonical order, into that class's compute_record_hash(fields…) associated
function (these classes expose no &self verify_binding); for the legacy
GovernanceProof, verify_binding(&self) may be called directly.
- PASS iff recomputed == stored.
- A byte-altered field that does not also (impossibly, without the key) update a signed hash MUST produce a FAIL here. This is the falsification test for the whole contract (see Acceptance).
V2 — Signature verification (per receipt, where a signature is present)
Where a receipt carries a signature and the signer's verifying key is resolvable
(via the DID document / identity manager), verify it with verify_signature.
- PASS iff the signature verifies against the recomputed hash and the resolved key.
- Key not resolvable → report
UNRESOLVED_KEY(a distinct, non-PASS verdict — the verifier states what it could not check rather than silently passing).
V3 — Chain-link continuity (per chain)
For the hash-chained ladder, verify each rung's back-reference equals the prior
rung's recomputed record_hash (activation→decision, plan→activation,
applied→plan, evidence→applied). For string-id-linked receipts (the non-ladder
classes), verify referential integrity (every referenced id resolves within the
set or is explicitly marked external).
- PASS iff every link resolves to a V1-verified record.
- A rung pointing at a hash no receipt in the set produces → FAIL
(
BROKEN_LINK), not a silent gap.
V4 — Merkle vote-root (where applicable)
For receipts binding a vote set, recompute the merkle/compute_vote_hash root
and compare to the bound value.
- PASS iff recomputed root == bound root.
V5 — Immutability cross-check (per store or export)
Confirm the ADR-0026 immutability invariant structurally: no two distinct
payloads share a (class, record_hash) bind (this is what the store's collision
sentinel enforces at write time; the verifier confirms it at read time over the
whole set), and any "reversal" is a new receipt referencing the original, never
an edit of it.
Verdict shape
The verifier returns a structured result, not a boolean: per-receipt
{class, record_hash, v1, v2, v3, v4} each ∈ {PASS, FAIL, UNRESOLVED, N/A},
plus a chain-level roll-up and a top-level verified: bool that is true iff
every applicable check is PASS and none is FAIL. UNRESOLVED (e.g. a key the
verifier does not hold) makes verified false but is reported distinctly from
FAIL — an honest verifier says "I could not check X", never "X is fine."
Where it belongs
- Verifier core:
icn-governance(averifymodule besideproof.rs) or a thin new module that depends only onicn-governance::proof+icn-identityfor key resolution. It is pure and takes noStore— it operates on already-hydrated receipts, so it is equally usable over a live store, an evidence export, or a peer's bytes. No kernel expansion: this is app-layer verification of app-layer receipts; the kernel already stores them opaquely and is not involved. - CLI consumer: replace the string-equality body of
icnctl audit verify(verify_receipt_chain) with a call into the verifier core, so the existing command becomes a real re-verifier. This is the primary, already-existing consumer named by ADR-0008 ("Receipt Chain Vertical Slice and Audit Verify"). - Evidence-export consumer (follow-on): the rehearsal evidence-export
validators (
docs/scripts/validate-rehearsal-evidence.pyand the:v1packet path) can call the same verifier so an exported packet is verifiable by its recipient with no live node.
Authorization, receipts, surfaces, custody (feature-placement checklist)
Per `ICN_OPERATING_MODEL.md` §Feature-placement:
- What kind of thing is this? Generic runtime logic (verification of a core
noun), plus a tool consumer (
icnctl). Not a new noun, not a package concept. - Where does it belong? An engine-adjacent pure verifier in
icn-governance- the existing
icnctlcommand. No package vocabulary; firewall-clean.
- the existing
- Who authorizes it? Nobody — verification reads and recomputes; it grants no authority and mutates no state. It is safe to run by anyone holding the receipts (this is the point of "re-verifiable").
- What receipt proves it? None needed — verification is a read. (It may emit an operator log line; it must not write a receipt, to avoid a verify-writes-a-receipt-that-must-be-verified regress.)
- Which surface shows it? CLI now; steward cockpit "chain verified ✓/✗" badge is a natural but out-of-scope follow-on.
- Custody/privacy/exit? The verifier reads existing receipts and resolves public keys only; it introduces no new stored data and no new disclosure. It must not log receipt bodies or DIDs at INFO (see the logging-redaction gap noted in Related work).
Acceptance criteria (falsifiable)
- Tamper is caught. A test that takes a valid, stored ladder receipt,
flips one byte of a canonical field, and re-serializes it MUST make the
verifier return
verified: falsewith a V1FAILon that receipt. Today, the same tampered receipt passesicnctl audit verify. This single test is the falsification of the current state and the definition of done for the core. - Honest genuine PASS. A complete, untampered rehearsal chain (organizer→
member loop, the #2406–#2409 path) verifies
verified: truewith every applicable check PASS and noUNRESOLVEDwhen the node's keys are available. - Honest partial. The same chain verified with a key withheld reports
UNRESOLVED_KEYon the signature check andverified: false— never a silent PASS. - Broken link is caught. A chain missing one rung reports
BROKEN_LINK, not a pass over the remaining rungs. - No re-implementation drift. The verifier calls the existing
proof.rscompute_record_hash(fields…)/compute_vote_hash/verify_signaturefunctions (andverify_bindingwhere a type exposes it); a grep shows it does not define its own BLAKE3 domain-tag encoding. (Because the ladder classes expose only the associatedcompute_record_hash, the verifier is a per-class shim that reads fields and re-passes them — still no BLAKE3 re-implementation.)
Non-claims
- The verifier core (
icn-governance::verify) and itsicnctl audit verifyconsumer are implemented and tested (see the Implementation-status note at the top); the remaining follow-on pieces are not: per-ladder-class field recompute (activation → mutation-plan → mutation-applied), gateway store-side read-path re-verification, and the concretesupersedes-linked domain-policy adoption receipt class (deferred to #2434). - A green verifier proves integrity and authenticity of recorded facts, not their legitimacy: it does not check that a decision was authorized, that a quorum was real, or that a mandate stood behind an act. Those are separate gaps (see Related work). A verified receipt still "records a process fact and grants zero authority."
- This is not federation, not production, not a pilot, and closes no human gate.
Related work
- ADR-0026 — the envelope this contract makes re-verifiable; ADR-0008 (receipt chain + audit verify origin); candidate ADR-0072 (Layer 4 query, distinct from this re-verification).
- `community-proof-spine-0.1.md` — the closest existing artifact: it already demonstrates the "blake3-bound receipt → recompute-to-verify" pattern for one runtime authority path (#2084). This contract generalizes that recompute discipline to the whole receipt set; it is not a duplicate (that spec verifies one path; this defines the generic re-verifier), but new work should reuse its recompute conventions.
- The legitimacy half — that a recorded decision was actually authorized — is out of scope here and tracked as separate gaps: adopted-policy evaluation (the CCL evaluator-selection seam, parked under #2141) and act-time mandate enforcement (ADR-0019 "kernel dispatch gated by mandates — NOT IMPLEMENTED").
- A concrete
supersedes-linked receipt class for domain-policy adoption (one consumer of the generic chain-link verifier above) is not part of this change. Its emission requires a crash-atomic cross-store transition (domain-state store and receipt store are separatesled::Dbinstances) and is deferred — see issue #2434 and theapps/governance/close_journal.rswrite-ahead-log pattern it should follow. This verifier defines only the generic re-verification primitive. - Logging redaction: the verifier must not reintroduce DID/body leakage into operational logs (a known observability gap).