Evidence commits
A record chain proves that each record is intact and that one tenant’s records are gapless. It says nothing about which records were written as one unit. An evidence commit closes that gap: it is a separate record that binds an ordered manifest of already-persisted records under one Merkle root, and links to the previous commit on the same stream.
A commit references records, it never contains them
Section titled “A commit references records, it never contains them”A commit member is a reference, not a copy. Each member carries exactly four fields, and createEvidenceCommit strips anything else before hashing.
member index 0-based position in this commit's manifest recordType one of six physical record types recordId the record's own id (event id, edge id, …) recordHash "sha256:" + the record's stored envelope hashThe supported recordType vocabulary is fixed in v1: audit.record, evidence.edge.record, entity.revision.record, activity.record, assertion.record, and change.record. Any other value fails construction rather than being carried through as an opaque label.
Two consequences follow. A commit never rewrites or re-signs the records it names — existing audit and edge envelope hashes are untouched. And a commit is only meaningful over records that already exist; the manifest is a claim about persisted bytes, not a container for them.
Commit digests are algorithm-qualified strings of the form sha256:<64 lowercase hex>. This deliberately differs from v1 audit and edge envelope hashes, which are stored as bare 64-hex. A member’s recordHash therefore has to be the qualified form even when the record it points at stores the bare form, which is why every projection helper writes `sha256:${record.hash}`.
From members to a commit hash
Section titled “From members to a commit hash”audit.record evidence.edge.record entity.revision.record … │ │ │ └────────── already persisted and hashed ──┘ │ ▼ member {index, recordType, recordId, recordHash} │ veritio-record-leaf-v1 ▼ leaf hash │ veritio-merkle-v1 ▼ recordsRoot │ veritio-commit-v1 ▼ commit hashEvery level uses its own domain separator, so a leaf digest can never collide with a node digest or a commit digest even if the underlying bytes coincide. Canonicalization is veritio-json-v1 throughout, the same recursively key-sorted serialization the hash chain uses.
Normalization happens before any hashing. Members are sorted by ascending index; after sorting, the indices must be contiguous from zero. A duplicate physical identity — the same recordType plus recordId twice — fails outright and is never silently de-duplicated. An empty manifest fails.
The Merkle construction folds the ordered leaves pairwise, left to right. When a level has an odd number of hashes, the final hash is duplicated as its own right sibling — at every level, not only the leaf level.
level 0 L0 L1 L2 \ / \ \ / \ (right sibling = itself) N(L0, L1) N(L2, L2) \ / \ / recordsRootA one-member commit’s recordsRoot is its leaf hash unchanged. The commit hash then covers every commit field except hash itself — including recordsRoot, recordCount, streamId, sequence, previousCommitHash, the normalized members, and committedAt.
Streams chain independently of tenant record chains
Section titled “Streams chain independently of tenant record chains”Commits chain per streamId. A valid stream starts at sequence: 1 with previousCommitHash: null; each later commit increments the sequence by one and carries the previous commit’s hash. Verifying one stream never reads another stream’s state.
stream "org_acme:production" tenant "org_acme" record chain commit 1 record 1 sequence: 1 sequence: 1 previousCommitHash: null previousHash: null hash: C1 hash: R1 commit 2 record 2 sequence: 2 sequence: 2 previousCommitHash: C1 previousHash: R1 hash: C2 hash: R2These are two unrelated counters over the same evidence. A record’s sequence never appears inside a commit, and a commit’s sequence never appears inside a record. One commit may bind three records and the next commit one; the tenant chain keeps counting by one regardless.
Building a commit is a projection of records the store just appended:
const commit = createEvidenceCommit({ commitId: 'cmt_membership_01', streamId: 'org_acme:production', sequence: 1, previousCommitHash: null, members: [...], committedAt: '2026-08-09T10:05:00.000Z',})Member order is protocol-canonical, not caller-dependent
Section titled “Member order is protocol-canonical, not caller-dependent”The executable fixture hands the same three members to createEvidenceCommit twice, in two different array orders, and compares the results.
{ "recordChainVerification": { "ok": true }, "suppliedOrder": [ { "index": 2, "recordId": "evt_member_promoted_01" }, { "index": 0, "recordId": "evt_member_invited_01" }, { "index": 1, "recordId": "evt_member_joined_01" } ], "canonicalMemberOrder": [ { "index": 0, "recordType": "audit.record", "recordId": "evt_member_invited_01", "recordHash": "sha256:9a753cb23031b9c49445207da15e758613573de613c503ce4b758b2ced783c98" }, { "index": 1, "recordType": "audit.record", "recordId": "evt_member_joined_01", "recordHash": "sha256:376f6f2ea018a9fc6c946fcc7d61e358f8fc1ff82960bd66e0ff83c09327d300" }, { "index": 2, "recordType": "audit.record", "recordId": "evt_member_promoted_01", "recordHash": "sha256:2fc2218e51e289d169e9de8d6915702c2b5c422789450c2bd3f7098d40f5a88f" } ], "recordCount": 3, "treeAlgorithm": "veritio-merkle-v1", "recordsRoot": "sha256:681c3111295b88bd29d3f15a4ab1db830cd1c78fc984989b3590e9db3504c55a", "commitHash": "sha256:739c10b06dccc577aa0ae9c38bd61ae204e6044f9ffdda7161142a00d0cd90be", "hashSelfConsistent": true, "orderIndependent": true, "commitVerification": { "ok": true }}Read it field by field. recordChainVerification proves the three audit records are a real, conforming tenant chain first — the commit is built over genuine envelope hashes, not placeholder bytes. suppliedOrder shows the caller’s shuffled array. canonicalMemberOrder shows what the protocol stored: sorted by index, contiguous from zero. recordCount is derived from the normalized manifest rather than trusted from input, so it cannot disagree with members. orderIndependent is the point of the fixture: a second caller passing the reversed array produced the identical recordsRoot and identical commit hash. hashSelfConsistent shows hashEvidenceCommit recomputing the stored hash from the canonical fields alone.
What tampering looks like
Section titled “What tampering looks like”The second fixture builds an honest two-commit ledger for one stream, then clones it once per tamper so no mutation leaks into the next case.
{ "recordChain": { "ok": true }, "ledger": [ { "commitId": "cmt_membership_01", "sequence": 1, "recordCount": 3, "recordsRoot": "sha256:44444f427c38795087adae19e1348a9c0aa89a73597ed793d3915cb843600c49", "previousCommitHash": null, "hash": "sha256:3992cf8f6ab349c3610ba11afd67518c3878aebabe895f31413cb3a4a661ad27" }, { "commitId": "cmt_membership_02", "sequence": 2, "recordCount": 1, "recordsRoot": "sha256:648d2d6977e9bb4949a046c99cc53860db9aee7f8c5ae57d0dd7cb26f3f38345", "previousCommitHash": "sha256:3992cf8f6ab349c3610ba11afd67518c3878aebabe895f31413cb3a4a661ad27", "hash": "sha256:fb4cd989d2d4009859758b36c8d156f6d2e622d7d87c6fd5a4d222e9dba60cf9" } ], "previousHashLinked": true, "cases": [ { "case": "untampered", "mutation": "none — the honest two-commit ledger", "ok": true, "index": null, "reason": null }, { "case": "swapped_member_record_hash", "mutation": "commit 1 members[0] and members[1] exchange recordHash", "ok": false, "index": 0, "reason": "records_root_mismatch" }, { "case": "dropped_member_tail", "mutation": "commit 1 loses its last member; recordCount still claims 3", "ok": false, "index": 0, "reason": "record_count_mismatch" }, { "case": "dropped_member_middle", "mutation": "commit 1 loses members[1], leaving indices [0, 2]", "ok": false, "index": 0, "reason": "invalid_member_manifest" }, { "case": "reordered_member_indices", "mutation": "commit 1 members[0] and members[1] exchange their index fields", "ok": false, "index": 0, "reason": "records_root_mismatch" }, { "case": "reordered_member_array", "mutation": "commit 1 members reversed in place, index fields untouched", "ok": false, "index": 0, "reason": "hash_mismatch" }, { "case": "mutated_records_root", "mutation": "commit 1 recordsRoot replaced with commit 2's root", "ok": false, "index": 0, "reason": "records_root_mismatch" }, { "case": "broken_previous_hash_link", "mutation": "commit 2 re-parented onto a fabricated previousCommitHash", "ok": false, "index": 1, "reason": "previous_hash_mismatch" } ], "ledgerScopeCaveat": { "note": "verifyEvidenceCommits proves ledger consistency, not that members describe real records", "resignedSubstitution": { "ok": true }, "reconciledAgainstRecords": [ { "recordId": "evt_member_invited_01", "boundToItsOwnRecord": false }, { "recordId": "evt_member_joined_01", "boundToItsOwnRecord": true }, { "recordId": "evt_member_promoted_01", "boundToItsOwnRecord": true } ] }}Each reason names a distinct structural defect:
records_root_mismatch— the manifest still normalizes, but the root recomputed from it no longer matches the storedrecordsRoot. Swapping two members’recordHashvalues, exchanging theirindexfields, or overwriting the root itself all land here.record_count_mismatch— members were dropped from the tail whilerecordCountstill claims the original number.invalid_member_manifest— normalization itself failed. Dropping a middle member leaves indices[0, 2], which is not contiguous from zero. A missing or emptystreamIdfails the same way, because a commit arriving from untrusted JSON may not honor the compile-time type.hash_mismatch— the stored hash no longer matches the recomputed one. Reversing the members array without touching theindexfields lands here: normalization restores the canonical order, so the root still matches, but the commit as supplied hashes differently.previous_hash_mismatch— commit 2 was re-parented onto a fabricatedpreviousCommitHash. Note the reportedindexis1: the verifier walks in supplied order and reports the array position of the first failing commit.
Verification also fails closed on unsupported_hash_algorithm, unsupported_canonicalization, and unsupported_tree_algorithm before any structural work, and on sequence_mismatch when a stream’s sequence does not increment by exactly one.
Construction fails earlier and harder. createEvidenceCommit throws on an empty commitId or streamId, a non-positive sequence, a previousCommitHash that is neither null nor a qualified digest, an empty manifest, a non-integer or negative index, an unknown recordType, a recordHash that is not a qualified digest, non-contiguous indices, and duplicate identities. None of those produce a commit with a plausible-looking hash.
What a commit does not prove
Section titled “What a commit does not prove”The last block of the fixture is the honest limit. verifyEvidenceCommits proves the commit ledger’s internal consistency: envelope algorithms, per-stream sequence and previous-hash linkage, manifest shape, Merkle root, and commit hash. It deliberately does not reconcile member.recordHash against the records those hashes claim to describe.
So substituting one member’s recordHash for a different record’s hash and then re-committing produces resignedSubstitution: { ok: true } — a self-consistent ledger over a wrong manifest. Only reconciliation catches it, which is what reconciledAgainstRecords does: recomputing hashAuditRecord for each named record shows boundToItsOwnRecord: false on the substituted member. A fabricated commit chain over entirely fabricated record hashes verifies ok in isolation too.
End-to-end evidence verification therefore composes the two verifiers. FileEvidenceStore.verify() is the reference shape:
verify() audit = verifyAuditRecords(events.jsonl) edges = verifyEvidenceEdgeRecords(edges.jsonl) commits = verifyEvidenceCommits(commits.jsonl) ok = audit.ok && edges.ok && commits.okCommits scope their claim to ledger atomicity — these records were appended together, in this order, under this manifest. They do not claim record authenticity, and they do not claim the recorded events were truthful. Atomicity here is also the store’s claim about its own batch, not a host database transaction: the reference in-memory server store notes explicitly that it does not claim host transaction binding.
Streams are not tenant-scoped
Section titled “Streams are not tenant-scoped”An EvidenceCommit has no scope object and no tenantId. The verifier partitions state by streamId alone. Nothing in the protocol stops one stream from binding records belonging to several tenants — and nothing in the protocol will flag it.
That is a disclosure decision, not a correctness one. A commit ledger is a list of record ids that were written together. A stream spanning tenants exposes batch-membership structure across tenant boundaries: anyone permitted to read that stream learns which of tenant A’s records were appended alongside tenant B’s, and how the batches were shaped over time, without reading a single event payload. Keep one stream per tenant and environment. The fixtures use org_acme:production for exactly this reason, and a FileEvidenceStore directory is documented as holding exactly one tenant’s chains.
Not every store has a commit path
Section titled “Not every store has a commit path”Commits are emitted by FileEvidenceStore.recordBatch and by the reference Node server’s local store. Both take a commitId and streamId, append the batch’s records, then append one commit binding them — under a lock, with replay of an existing commitId returning the stored commit and a conflicting manifest for the same id failing as a commit id conflict.
The AuditStore interface itself has only append and list. The Postgres, Neon, MySQL, MariaDB, and Mongo stores implement that interface and nothing more: they own gapless tenant sequence and idempotency, but they have no commit table, no recordBatch, and no listCommits. A conforming AuditStore does not imply a commit ledger exists. See Storage overview for which tiers are authoritative at all.
Commits are illegal in a scoped export bundle
Section titled “Commits are illegal in a scoped export bundle”buildExportBundle rejects commits unless chainScope is full, throwing export bundle: commits are not supported in a scoped bundle. The bundle parser repeats the check so a hand-edited bundle cannot smuggle them past it.
The reasoning is that a commit always verifies under the strict rule — its manifest is fixed at construction — while a scoped or filtered bundle by definition omits records. A filtered bundle carrying commits would present a clean ledger over records the recipient cannot see, which reads as a stronger claim than the bundle actually supports. Scoped commit sets are simply not defined in v1.
Continue with the evidence graph for the edge records a commit can bind, the hash chain for the per-record integrity a commit deliberately does not re-prove, and the export format for where records/commits.jsonl sits inside a bundle.