# Evidence commits

Kind

concept

For

developer · operator · governance

Verified against

@veritio/core@0.4.7

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

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

The 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”](#from-members-to-a-commit-hash)

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

Every 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](/docs/concepts/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.

```text
level 0    L0        L1        L2
             \      /            \
              \    /              \  (right sibling = itself)
            N(L0, L1)          N(L2, L2)
                    \          /
                     \        /
                    recordsRoot
```

A 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”](#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.

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

These 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:

```ts
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”](#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.

verified commit construction

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

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”](#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.

verified commit tamper output

```json
{
  "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 stored `recordsRoot`. Swapping two members’ `recordHash` values, exchanging their `index` fields, or overwriting the root itself all land here.
-   `record_count_mismatch` — members were dropped from the tail while `recordCount` still 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 empty `streamId` fails 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 the `index` fields 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 fabricated `previousCommitHash`. Note the reported `index` is `1`: 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”](#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:

```text
verify()
  audit   = verifyAuditRecords(events.jsonl)
  edges   = verifyEvidenceEdgeRecords(edges.jsonl)
  commits = verifyEvidenceCommits(commits.jsonl)
  ok      = audit.ok && edges.ok && commits.ok
```

Commits 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”](#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”](#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](/docs/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”](#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](/docs/concepts/evidence-graph/) for the edge records a commit can bind, the [hash chain](/docs/concepts/hash-chain/) for the per-record integrity a commit deliberately does not re-prove, and the [export format](/docs/reference/export-format/) for where `records/commits.jsonl` sits inside a bundle.

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

Last updated: Aug 23, 2026

[Previous  
Evidence graph](/docs/concepts/evidence-graph/)[Next  
Changes, activities, and revisions](/docs/concepts/governed-changes/)

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/)
