Skip to content
VeritioDocs

Evidence commit hashing

Kind
reference
For
developer · operator · governance
Verified against
@veritio/core@0.4.7

An evidence.commit binds a set of already-persisted records into one atomic append. Anyone holding those commits must be able to recompute recordsRoot and hash without reading SDK source, in any language, and get the same bytes. This page is the normative construction; where prose here and spec/conformance/evidence-commit.json could ever disagree, the fixture wins.

Every digest below is SHA-256 over veritio-json-v1 canonical bytes: object keys sorted recursively, missing members omitted, UTF-8, no HTML escaping. Key order in the literals shown here is irrelevant — canonicalization sorts them before hashing.

Commit hashing never hashes a bare payload. Each of the three digest kinds carries a domain field inside its own hash input:

veritio-record-leaf-v1 one member → leaf hash
veritio-merkle-node-v1 two hashes → interior node hash
veritio-commit-v1 one commit → commit hash

The separators exist so that two different structures can never produce the same digest even if their remaining bytes coincide. A leaf digest can never be replayed as a node digest, and neither can be replayed as a commit digest or as a v1 audit-record envelope hash, which carries no domain marker at all.

Normalization runs before any hashing, in both createEvidenceCommit and verifyEvidenceCommits. It is a fail-closed gate, not a repair step:

  1. members must be a non-empty array.
  2. Each member is reduced to exactly index, recordType, recordId, recordHash. Unknown fields are stripped and never reach the hash input.
  3. index must be a non-negative integer. recordType must be one of audit.record, evidence.edge.record, entity.revision.record, activity.record, assertion.record, change.record. recordHash must match sha256:[a-f0-9]{64} — lowercase hex only.
  4. Members are sorted by ascending index. After sorting, the indices must be contiguous from zero.
  5. A duplicate physical identity, formed as recordType + NUL + recordId, throws. Duplicates are never silently de-duplicated.

Two consequences matter for callers. First, member order is protocol-canonical rather than caller-dependent: hand the same manifest to createEvidenceCommit in any array order and you get the same sorted members, the same root, and the same commit hash. Second, an incomplete manifest is a hard failure. Dropping a middle member leaves indices such as [0, 2], which fails contiguity rather than quietly committing a smaller set.

Each normalized member becomes one leaf. index is part of the hashed payload, not merely a sort key:

leaf = "sha256:" + sha256hex(canonicalJson({
domain: "veritio-record-leaf-v1",
index: member.index,
recordType: member.recordType,
recordId: member.recordId,
recordHash: member.recordHash,
}))

Because index is inside the leaf, moving a record to a different position changes its leaf hash and therefore the root. Position within the commit is itself committed evidence. Swapping the index fields of two members — leaving every recordHash intact — changes the root; the verifier reports records_root_mismatch.

Canonicalized, the first member of the worked example below serializes to exactly these bytes:

{"domain":"veritio-record-leaf-v1","index":0,"recordHash":"sha256:9a753cb23031b9c49445207da15e758613573de613c503ce4b758b2ced783c98","recordId":"evt_member_invited_01","recordType":"audit.record"}

treeAlgorithm is veritio-merkle-v1. Starting from the leaves in normalized member order, combine pairs left to right until one hash remains:

node = "sha256:" + sha256hex(canonicalJson({
domain: "veritio-merkle-node-v1",
left: level[i],
right: level[i + 1] ?? level[i],
}))

The ?? level[i] is the odd rule: when a level has an odd count, its final hash is duplicated as its own right sibling. This applies at every level, not only the leaf level. A three-member commit only exercises it at the leaves, which is why implementations that special-case the leaf level still pass a three-member fixture and then diverge on five:

3 members 5 members
L0 L1 L2 L0 L1 L2 L3 L4
\ / \ \ / \ / \
n0 n1 = N(L2, L2) n0 n1 n2 = N(L4, L4)
\ / \ / |
root m0 m1 = N(n2, n2)
\ /
root

At five members the odd rule fires twice: once at the leaf level (L4 duplicated) and once at the level above it (n2 duplicated). Test any independent implementation at five and seven members, not three.

The loop runs while more than one hash remains. A one-member commit therefore never enters it: recordsRoot is the leaf hash, unchanged, with no node hashing and no self-pairing. An implementation that pairs the single leaf with itself produces a different root and will fail conformance.

The commit hash covers every commit field except hash itself, wrapped under its own domain:

hash = "sha256:" + sha256hex(canonicalJson({
domain: "veritio-commit-v1",
commit: { …every commit field except hash… },
}))

commit is the whole record: recordType, schemaVersion, commitId, streamId, sequence, previousCommitHash, the normalized members array, recordCount, recordsRoot, canonicalization, hashAlgorithm, treeAlgorithm, and committedAt. Because the stored members array is inside the hash input verbatim, reordering the persisted array without touching any index field leaves recordsRoot reproducible but breaks the commit hash — the verifier reports hash_mismatch, not records_root_mismatch.

Chains are per streamId. A valid chain starts at sequence: 1 with previousCommitHash: null, then increments the sequence by one and carries the previous commit’s hash. Streams are independent; verifying one never reads another’s state.

This is the single most common integration error. Commit-level digests are algorithm-qualified: sha256: followed by 64 lowercase hex characters. v1 audit and edge record envelope hashes are bare 64-hex, and deliberately stay that way — introducing commits did not rewrite existing record hashes.

record.hash 9a753cb2…3c98 bare 64-hex
member.recordHash sha256:9a753cb2…3c98 qualified
commit.recordsRoot sha256:681c3111…c55a qualified
commit.hash sha256:739c10b0…90be qualified
commit.previousCommitHash sha256:… or null qualified

So a caller projecting persisted records into a manifest must write sha256:${record.hash} explicitly. Passing the bare hex through fails cleanCommitMember with a TypeError before anything is hashed.

This fixture runs in CI and its output is byte-compared. It builds three real audit records, projects them into a manifest, and commits the same manifest twice in two deliberately different caller orderings.

src/examples/commits/merkle-root.ts
import {
type AuditEvent,
type AuditRecord,
type EvidenceCommit,
type EvidenceCommitMember,
createAuditEvent,
createEvidenceCommit,
hashAuditRecord,
hashIdempotencyKey,
verifyAuditRecords,
hashEvidenceCommit,
verifyEvidenceCommits,
} from '@veritio/core'
export const commitScope = { tenantId: 'org_acme', environment: 'production' } as const
const membershipEvents: readonly { event: AuditEvent; idempotencyKey: string }[] = [
{
event: createAuditEvent({
id: 'evt_member_invited_01',
occurredAt: '2026-08-09T10:00:00.000Z',
actor: { type: 'user', id: 'usr_owner' },
action: 'organization.member.invited',
target: { type: 'organization', id: 'org_acme' },
scope: commitScope,
purpose: 'access_management',
lawfulBasis: 'contract',
retention: 'security_1y',
metadata: { role: 'viewer' },
}),
idempotencyKey: 'invitation:inv_123',
},
{
event: createAuditEvent({
id: 'evt_member_joined_01',
occurredAt: '2026-08-09T10:01:00.000Z',
actor: { type: 'user', id: 'usr_member' },
action: 'organization.member.joined',
target: { type: 'organization', id: 'org_acme' },
scope: commitScope,
purpose: 'access_management',
lawfulBasis: 'contract',
retention: 'security_1y',
metadata: { role: 'viewer' },
}),
idempotencyKey: 'membership:mem_123',
},
{
event: createAuditEvent({
id: 'evt_member_promoted_01',
occurredAt: '2026-08-09T10:02:00.000Z',
actor: { type: 'user', id: 'usr_owner' },
action: 'organization.member.promoted',
target: { type: 'organization', id: 'org_acme' },
scope: commitScope,
purpose: 'access_management',
lawfulBasis: 'contract',
retention: 'security_1y',
metadata: { role: 'admin' },
}),
idempotencyKey: 'promotion:mem_123',
},
]
/**
* Builds the three audit records the commit manifest binds. A real store stamps
* `appendedAt` from wall-clock time, which would move every record hash between
* runs, so this fixture pins that field and derives the envelope hashes with the
* protocol helper instead. The chain (sequence, previousHash) is still built the
* way a conforming store builds it, so `member.recordHash` references genuine
* record digests rather than placeholder bytes.
*/
export function pinnedMembershipRecords(): AuditRecord[] {
const records: AuditRecord[] = []
let previousHash: string | null = null
for (const [position, entry] of membershipEvents.entries()) {
const recordWithoutHash = {
event: entry.event,
sequence: position + 1,
previousHash,
hashAlgorithm: 'sha256',
canonicalization: 'veritio-json-v1',
appendedAt: '2026-08-09T10:03:00.000Z',
idempotencyKeyHash: hashIdempotencyKey(commitScope.tenantId, entry.idempotencyKey),
} satisfies Omit<AuditRecord, 'hash'>
const record: AuditRecord = { ...recordWithoutHash, hash: hashAuditRecord(recordWithoutHash) }
records.push(record)
previousHash = record.hash
}
return records
}
/**
* Projects persisted records into a commit manifest. `recordHash` must be the
* algorithm-qualified form (`sha256:<hex>`) even though v1 audit record hashes
* are stored as bare hex, and `index` — not array position — is the field the
* protocol canonicalizes on.
*/
export function commitMembersFor(records: readonly AuditRecord[]): EvidenceCommitMember[] {
return records.map((record, position) => ({
index: position,
recordType: 'audit.record' as const,
recordId: record.event.id,
recordHash: `sha256:${record.hash}`,
}))
}
/**
* Commits a manifest in whatever array order the caller happens to hold it. The
* commit id, stream, sequence, and `committedAt` are pinned so the Merkle root
* and commit hash are reproducible across runs and languages.
*/
export function commitOf(members: readonly EvidenceCommitMember[]): EvidenceCommit {
return createEvidenceCommit({
commitId: 'cmt_membership_01',
streamId: 'org_acme:production',
sequence: 1,
previousCommitHash: null,
members: [...members],
committedAt: '2026-08-09T10:05:00.000Z',
})
}
/**
* Proves that EvidenceCommit member ordering is protocol-canonical rather than
* caller-dependent: two callers that hand the same manifest to
* `createEvidenceCommit` in different array orders get the same sorted
* `members`, the same `veritio-merkle-v1` `recordsRoot`, and the same commit
* hash. It also shows `hashEvidenceCommit` recomputing the stored hash from the
* canonical fields and `verifyEvidenceCommits` accepting the one-commit chain.
*/
if (import.meta.main) {
const records = pinnedMembershipRecords()
const members = commitMembersFor(records)
// Two deliberately different caller orderings of the same three members.
const shuffled: EvidenceCommitMember[] = [members[2], members[0], members[1]]
const reversed: EvidenceCommitMember[] = [...members].reverse()
const commit = commitOf(shuffled)
const rebuilt = commitOf(reversed)
console.log(
JSON.stringify(
{
recordChainVerification: verifyAuditRecords(records),
suppliedOrder: shuffled.map((member) => ({
index: member.index,
recordId: member.recordId,
})),
canonicalMemberOrder: commit.members.map((member) => ({
index: member.index,
recordType: member.recordType,
recordId: member.recordId,
recordHash: member.recordHash,
})),
recordCount: commit.recordCount,
treeAlgorithm: commit.treeAlgorithm,
recordsRoot: commit.recordsRoot,
commitHash: commit.hash,
hashSelfConsistent: hashEvidenceCommit(commit) === commit.hash,
orderIndependent:
rebuilt.hash === commit.hash && rebuilt.recordsRoot === commit.recordsRoot,
commitVerification: verifyEvidenceCommits([commit]),
},
null,
2,
),
)
}
verified output
{
"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
}
}

What each field proves:

  • recordChainVerification.ok — the three member records are a genuine hash chain, so member.recordHash references real envelope digests rather than placeholder bytes.
  • suppliedOrder versus canonicalMemberOrder — the caller handed in [2, 0, 1]; normalization sorted to [0, 1, 2].
  • recordHash values carry the sha256: prefix while the underlying record hashes are stored bare.
  • recordsRoot and commitHash — the published digests for this exact manifest.
  • hashSelfConsistenthashEvidenceCommit recomputes the stored hash from the canonical fields.
  • orderIndependent — the reversed-order build produced the identical root and commit hash.

The three leaves and the two interior nodes of that commit are:

leaf0 sha256:c5de3dac039b687cc2138a0613b92e68fd70e25110be389795b911ae1e7a6c35
leaf1 sha256:3386692ce8c50d5684adf491d3b2ab9c768ae468e9ba22b786b7723dbbac8800
leaf2 sha256:ea5ad922a98e5c39a6a5bd11cfc933417ec63904dbc31a8650c7519a7e462191
n0 = node(leaf0, leaf1) sha256:65906cf888af6ec26209607575a292eb7e2f84797e8a1830be26f2d3833990a8
n1 = node(leaf2, leaf2) sha256:0d7d7ac2c105157f77216b6371f797d37e7e58ed78563eb8a0bcf2f0c6a21c65
root = node(n0, n1) sha256:681c3111295b88bd29d3f15a4ab1db830cd1c78fc984989b3590e9db3504c55a

That root is the recordsRoot in the checked output above. An independent implementation that reproduces these five intermediate digests is aligned with the reference implementations at the byte level, not merely at the API level.

verifyEvidenceCommits (TypeScript), verify_evidence_commits (Python), and VerifyEvidenceCommits (Go) return either ok: true or a single index and reason. The index is the zero-based position in the supplied array where the first failing invariant was observed. The nine reasons, in the order the checks run:

Reason Check that failed
unsupported_hash_algorithm hashAlgorithm is not sha256
unsupported_canonicalization canonicalization is not veritio-json-v1
unsupported_tree_algorithm treeAlgorithm is not veritio-merkle-v1
invalid_member_manifest missing or empty streamId, or the manifest failed normalization
previous_hash_mismatch previousCommitHash is not the last verified hash for that stream
sequence_mismatch sequence is not exactly one greater than the stream’s last
record_count_mismatch recordCount disagrees with the normalized member count
records_root_mismatch the recomputed veritio-merkle-v1 root differs from recordsRoot
hash_mismatch hash is not a string, or the recomputed commit hash differs

Two placements are worth memorizing. invalid_member_manifest covers both a structurally unusable stream key and any normalization throw, so a non-contiguous index set and an empty streamId surface under the same reason. And the previous-hash check runs before the sequence check.

That ordering decides what a mid-stream slice reports. Hand the verifier commits 5 through 9 of a stream in isolation and it fails at index 0 with previous_hash_mismatch, not sequence_mismatch: fresh stream state starts at previousHash: null, and commit 5 carries a non-null parent, so the link check fails first. The reason names a missing predecessor, which is the accurate diagnosis — the slice is not a chain, it is a fragment. Fetch from sequence: 1 or verify against a separately trusted tip.

verifyEvidenceCommits proves the commit ledger’s internal consistency: envelope algorithms, per-stream linkage, manifest shape, Merkle root, and commit hash. It deliberately does not reconcile member.recordHash against independently verified records. A fabricated commit chain over fabricated record hashes verifies ok in isolation, and so does a chain re-signed after substituting one member’s recordHash — the ledger is self-consistent, it simply no longer describes the record it claims to.

Per-record integrity is a separate proof, produced by verifyAuditRecords and verifyEvidenceEdgeRecords. End-to-end evidence verification composes both, as the reference server’s verify() does: verify the records, verify the commits, then reconcile each member.recordHash against the corresponding verified record’s sha256:-prefixed hash. Skipping the third step leaves the atomicity claim unanchored.

A commit scopes its claim to ledger atomicity — “these records were appended together, in this order, under this stream” — not to record authenticity and not to the truthfulness of what any record asserts. Like the record chain, it makes tampering detectable rather than impossible; the append boundary still needs authorization, tenant isolation, and operational response.

Continue with Evidence commits for when to cut a commit and the full tamper matrix, Hash chain for the per-record construction commits build on, and the verifier reference to interpret results across every chain type.