# 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.

## Three domain separators

[Section titled “Three domain separators”](#three-domain-separators)

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

```text
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.

## Member manifest normalization

[Section titled “Member manifest normalization”](#member-manifest-normalization)

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.

## Leaf construction

[Section titled “Leaf construction”](#leaf-construction)

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

```text
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:

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

## Merkle root

[Section titled “Merkle root”](#merkle-root)

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

```text
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:

```text
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.

### One-member commits

[Section titled “One-member commits”](#one-member-commits)

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.

## Commit hash

[Section titled “Commit hash”](#commit-hash)

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

```text
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.

## Algorithm-qualified versus bare hex

[Section titled “Algorithm-qualified versus bare hex”](#algorithm-qualified-versus-bare-hex)

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.

```text
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.

## Verified construction

[Section titled “Verified construction”](#verified-construction)

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

```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

```json
{
  "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.
-   `hashSelfConsistent` — `hashEvidenceCommit` recomputes the stored hash from the canonical fields.
-   `orderIndependent` — the reversed-order build produced the identical root and commit hash.

### Reproducing the published root by hand

[Section titled “Reproducing the published root by hand”](#reproducing-the-published-root-by-hand)

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

```text
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.

## Verification reasons

[Section titled “Verification reasons”](#verification-reasons)

`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.

## What a valid result does not prove

[Section titled “What a valid result does not prove”](#what-a-valid-result-does-not-prove)

`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](/docs/concepts/evidence-commits/) for when to cut a commit and the full tamper matrix, [Hash chain](/docs/concepts/hash-chain/) for the per-record construction commits build on, and the [verifier reference](/docs/reference/verifier/) to interpret results across every chain type.

[Edit page](https://github.com/getveritio/veritio-website/edit/main/src/content/docs/docs/reference/evidence-commit-hashing.mdx)

Last updated: Aug 23, 2026

[Previous  
Risk policy](/docs/reference/risk-policy/)[Next  
Evidence vocabulary](/docs/reference/evidence-vocabulary/)

Veritio provides evidence support, not legal advice or automatic compliance.

This site uses cookieless, anonymous analytics (Umami) by default. With your consent, we also enable Google Analytics, which sets cookies and sends usage data to Google. [Privacy Policy](/legal/privacy/)
