# Verifier reference

Kind

reference

For

developer · operator · governance

Verified against

@veritio/core@0.4.7 · vevb-1

Veritio verifiers return explicit data instead of logging or guessing recovery. A non-`ok` or non-`valid` result is a blocked integrity result; callers decide how to quarantine, alert, retry acquisition, or escalate.

There are four verifier surfaces, and they answer four different questions.

```text
verifyAuditRecords(records)        → are these event records one intact tenant chain?
verifyEvidenceEdgeRecords(records) → are these edge records one intact tenant chain?
verifyEvidenceCommits(commits)     → is this commit ledger internally consistent?
verifyExportBundle(bundle, opts)   → does this vevb-1 bundle hold together offline?
```

The first three are synchronous and return a discriminated union. The fourth is asynchronous and returns a report object. None of them make a network call.

## Record-chain verification

[Section titled “Record-chain verification”](#record-chain-verification)

`verifyAuditRecords(records)` returns `{ ok: true }` or `{ ok: false, index, reason }`. `index` is the zero-based position in the supplied array where the first failing invariant was observed. Verification stops there.

Each record is checked in a fixed order, and the order decides which reason you get when more than one invariant is broken:

```text
for each record, in the order supplied
  1  event.scope.tenantId present?        → missing_tenant_scope
  2  hashAlgorithm === 'sha256'?          → unsupported_hash_algorithm
  3  canonicalization === veritio-json-v1?→ unsupported_canonicalization
  4  sequence === tenant.sequence + 1?    → sequence_mismatch
  5  previousHash === tenant.lastHash?    → previous_hash_mismatch
  6  hashAuditRecord(record) === hash?    → hash_mismatch
  then: tenant.lastHash = hash, tenant.sequence = sequence
```

Verifier state is keyed per tenant, so records from several tenants may be interleaved in one array and each chain is walked independently. Two tenants may both hold sequence `1` without conflict.

Reason

Check that failed

Typical investigation

`missing_tenant_scope`

`event.scope.tenantId` absent

Tenant mapping or malformed persisted row

`unsupported_hash_algorithm`

algorithm is not `sha256`

Version/format mismatch or corruption

`unsupported_canonicalization`

label is not `veritio-json-v1`

Version/format mismatch or corruption

`sequence_mismatch`

next per-tenant sequence is not expected

Missing, duplicate, reordered, or cross-tenant records

`previous_hash_mismatch`

link does not equal prior verified hash

Wrong ordering, deletion, or substituted predecessor

`hash_mismatch`

canonical record bytes no longer hash to stored digest

Mutation, corruption, or incompatible serialization

The checked tamper exercise mutates one record’s metadata in place and, separately, drops the first record of the chain:

src/examples/tutorial/tamper.ts

```ts
import { verifyAuditRecords } from '@veritio/core'
import { recordTutorialChain } from './record-and-verify'

const records = await recordTutorialChain()
const changed = records.map((record, index) =>
  index === 1
    ? { ...record, event: { ...record.event, metadata: { ...record.event.metadata, role: 'admin' } } }
    : record,
)
const dropped = records.slice(1)

console.log(JSON.stringify({
  changedRecord: verifyAuditRecords(changed),
  droppedRecord: verifyAuditRecords(dropped),
}, null, 2))
```

verified output

```json
{
  "changedRecord": {
    "ok": false,
    "index": 1,
    "reason": "hash_mismatch"
  },
  "droppedRecord": {
    "ok": false,
    "index": 0,
    "reason": "sequence_mismatch"
  }
}
```

Two different attacks produce two different reasons at two different indexes. Editing `records[1]` leaves sequence and previous-hash linkage intact, so the failure surfaces at the last check as `hash_mismatch` at index `1`. Removing the head of the chain leaves every surviving record self-consistent, so the failure surfaces at the first sequence check as `sequence_mismatch` at index `0` — the supplied array starts at sequence `2`.

Do not treat the first reason as a complete forensic diagnosis. Fixing or removing one invalid record may reveal a later failure; retain the original bytes and investigate a copy.

`verifyEvidenceEdgeRecords` applies the same six checks, in the same order, with the same reason vocabulary, to the independent evidence-edge chain, reading tenant scope from `edge.scope` instead of `event.scope`. A valid event chain does not imply valid edges. Verify every chain your workflow relies on.

## Commit-ledger verification

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

`verifyEvidenceCommits(commits)` walks a commit ledger per `streamId`, and returns the same `ok`/`index`/`reason` shape with a wider reason vocabulary. Its check order differs from the record verifier in one place that matters: it compares `previousCommitHash` **before** `sequence`, so a re-parented commit reports `previous_hash_mismatch` rather than a sequence error.

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`

`streamId` missing or empty, member list empty, indices not contiguous from zero, duplicate member, unsupported `recordType`, or a `recordHash` that is not a `sha256:` digest

`previous_hash_mismatch`

`previousCommitHash` does not equal the prior verified commit hash for that stream

`sequence_mismatch`

per-stream sequence is not exactly one greater

`record_count_mismatch`

`recordCount` disagrees with the normalized member count

`records_root_mismatch`

recomputed `veritio-merkle-v1` root differs from `recordsRoot`

`hash_mismatch`

`hash` is not a string, or recomputing the commit over its canonical fields differs

The checked commit fixture applies seven independent tampers to one honest two-commit ledger and prints the reason each one produces:

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

Three results in that output are worth reading closely.

`dropped_member_middle` reports `invalid_member_manifest`, not `record_count_mismatch`. Removing `members[1]` leaves indices `[0, 2]`, and member normalization rejects a non-contiguous manifest before the count is ever compared.

`reordered_member_array` reports `hash_mismatch` while `reordered_member_indices` reports `records_root_mismatch`. Reversing the array without touching the `index` fields leaves the Merkle root identical — the root is computed over members sorted by `index` — but the commit hash covers the members array as stored, so it moves. Swapping the `index` fields instead changes what the sorted manifest is, so the root moves first.

`broken_previous_hash_link` reports at index `1`. Commit 1 is untouched and verifies; the ledger only breaks when commit 2 claims a parent that does not exist.

### What a commit result does not prove

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

`verifyEvidenceCommits` proves the ledger’s **internal** consistency: sequence linkage, previous-hash linkage, member-manifest shape, Merkle root, and commit hash. In v1 it deliberately does **not** reconcile `member.recordHash` against the records those hashes claim to describe.

The fixture’s `ledgerScopeCaveat` block demonstrates the consequence. A commit whose first member points at an unrelated record’s digest, re-signed so its own root and hash recompute cleanly, returns `{ "ok": true }` from `verifyEvidenceCommits`. Only reconciling each member against `hashAuditRecord` of the real record exposes it — `boundToItsOwnRecord: false`.

A fabricated commit chain over fabricated hashes is a valid commit chain. Compose the two layers:

```text
verifyAuditRecords(records)                     records are intact
        +
verifyEvidenceCommits(commits)                  ledger is intact
        +
member.recordHash === 'sha256:' + record.hash   ledger describes those records
        =
end-to-end evidence verification
```

## Language coverage

[Section titled “Language coverage”](#language-coverage)

Record-chain verification is TypeScript-only at this revision. `verifyAuditRecords` and `verifyEvidenceEdgeRecords` exist in `@veritio/core` and have no Python or Go counterpart.

Verifier

TypeScript

Python

Go

Audit-record chain

`verifyAuditRecords`

—

—

Evidence-edge chain

`verifyEvidenceEdgeRecords`

—

—

Commit ledger

`verifyEvidenceCommits`

`verify_evidence_commits`

`VerifyEvidenceCommits`

Export bundle

`verifyExportBundle`

—

—

The three commit verifiers share one reason vocabulary and one check order, and their agreement is pinned by cross-language fixtures. That parity is real but narrow: a Python or Go service that calls only `verify_evidence_commits` has confirmed ledger-internal consistency and nothing about the underlying records. Reconcile member digests against records verified elsewhere, or run the TypeScript verifier over the record bytes. See the [parity matrix](/docs/reference/parity-matrix/) for the full capability split.

## Export-bundle report

[Section titled “Export-bundle report”](#export-bundle-report)

`verifyExportBundle(bundle, opts)` is async and returns one fail-closed `valid` verdict plus four independent gates, a `chainScope`, and sanitized `issues`. The executable fixture’s full result and its deliberate tamper are:

verified output

```json
{
  "bundleVersion": "vevb-1",
  "files": [
    "records/audit-events.jsonl",
    "records/evidence-edges.jsonl",
    "records/commits.jsonl",
    "verification.json"
  ],
  "valid": {
    "valid": true,
    "checks": {
      "structure": true,
      "integrity": true,
      "chains": true,
      "signature": "absent"
    },
    "chainScope": "full",
    "issues": []
  },
  "tampered": {
    "valid": false,
    "integrity": false,
    "issueCount": 3
  }
}
```

Interpret the gates in order:

1.  `structure`: `manifest` and `files` are real objects, paths and keys map 1:1 with no duplicates, and the three `records/*.jsonl` files plus `verification.json` are present.
2.  `integrity`: every file digest recomputes, `rootHash` recomputes, and each record file’s line count matches its declared count.
3.  `chains`: record files are parsed back, re-run through the chain verifiers, and their verdicts must equal the embedded `verification.json`.
4.  `signature`: `valid` or `invalid` when a signature is present and a `publicKey` was supplied, `skipped` when present without a key, `absent` when unsigned.

The tampered case in the fixture changes one byte-level substring inside `records/audit-events.jsonl`. The gate that catches it is `integrity`, not `chains` — the file digest no longer matches the manifest entry, and three issues are recorded.

`signature: "absent"` drives `valid` false only when `requireSignature` is set. `signature: "skipped"` means a signature exists but was not authenticated, so do not describe the producer as verified.

Read `chainScope` before quoting a `valid` verdict. Only `full` claims nothing was removed; `windowed` and `filtered` verify exactly the subset the producer declared.

## Content failures versus misuse

[Section titled “Content failures versus misuse”](#content-failures-versus-misuse)

Verifiers separate two categories. Content problems — a broken chain, a mutated file, a bad root — are returned as data. Programmer misuse throws. `verifyExportBundle` throws exactly one error, for a `bundle` that is not an object, and `parseExportBundle` throws before the verifier is ever reached when the container is unusable.

Those strings are fixed and testable. The checked fail-closed catalogue pins all 35 of them across the SDK, including the guards that sit in front of the verifiers:

verified output

```json
{
  "guardCount": 35,
  "surfaces": [
    "createAuditEvent",
    "canonicalJson",
    "MemoryAuditStore.append",
    "MemoryAuditStore.list",
    "mergeVeritioMetadata",
    "createGovernedActionDraft",
    "createEvidenceCommit",
    "buildExportBundle",
    "parseExportBundle",
    "verifyExportBundle"
  ],
  "allFailClosed": true,
  "guards": [
    {
      "surface": "createAuditEvent",
      "case": "actor id is blank",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "actor.id is required"
    },
    {
      "surface": "createAuditEvent",
      "case": "target id is missing",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "target.id is required"
    },
    {
      "surface": "createAuditEvent",
      "case": "action is not dotted lowercase",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "action must use dotted lowercase protocol form"
    },
    {
      "surface": "createAuditEvent",
      "case": "occurredAt is not a parsable date",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "occurredAt must be a valid date"
    },
    {
      "surface": "createAuditEvent",
      "case": "metadata carries a non-finite number",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "metadata numbers must be finite"
    },
    {
      "surface": "canonicalJson",
      "case": "NaN in the hash input",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "JSON numbers must be finite"
    },
    {
      "surface": "canonicalJson",
      "case": "bigint has no canonical JSON form",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "unsupported JSON value type: bigint"
    },
    {
      "surface": "canonicalJson",
      "case": "function has no canonical JSON form",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "unsupported JSON value type: function"
    },
    {
      "surface": "MemoryAuditStore.append",
      "case": "event has no tenant scope",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "scope.tenantId is required"
    },
    {
      "surface": "MemoryAuditStore.append",
      "case": "idempotency key replayed with a different payload",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "idempotency conflict"
    },
    {
      "surface": "MemoryAuditStore.append",
      "case": "expectedPreviousHash does not match the chain tip",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "expectedPreviousHash does not match tenant chain tip"
    },
    {
      "surface": "MemoryAuditStore.list",
      "case": "negative limit",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "limit must be a non-negative integer"
    },
    {
      "surface": "MemoryAuditStore.list",
      "case": "fractional afterSequence",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "afterSequence must be a non-negative integer"
    },
    {
      "surface": "mergeVeritioMetadata",
      "case": "caller shadows a reserved context key",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "metadata.changeId is reserved by Veritio"
    },
    {
      "surface": "createGovernedActionDraft",
      "case": "keyed_digest field without a digest key",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "digestKeys.keyedDigest is required for keyed_digest fields"
    },
    {
      "surface": "createGovernedActionDraft",
      "case": "reserved capture mode randomized_digest",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "capture mode randomized_digest is not supported by the current governed-change draft helper"
    },
    {
      "surface": "createGovernedActionDraft",
      "case": "governed field value is non-finite",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "field values must be finite JSON numbers"
    },
    {
      "surface": "createGovernedActionDraft",
      "case": "governed field value is a bigint",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "unsupported field value type: bigint"
    },
    {
      "surface": "createEvidenceCommit",
      "case": "sequence is not a positive integer",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "sequence must be a positive integer"
    },
    {
      "surface": "createEvidenceCommit",
      "case": "previousCommitHash is not a sha256 digest",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "previousCommitHash must be null or sha256 digest"
    },
    {
      "surface": "createEvidenceCommit",
      "case": "member manifest is empty",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "members must not be empty"
    },
    {
      "surface": "createEvidenceCommit",
      "case": "member indices skip zero",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "member indices must be contiguous from zero"
    },
    {
      "surface": "createEvidenceCommit",
      "case": "the same record is committed twice",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "duplicate commit member"
    },
    {
      "surface": "createEvidenceCommit",
      "case": "member recordType is outside the protocol vocabulary",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "recordType must be a supported commit member record type"
    },
    {
      "surface": "createEvidenceCommit",
      "case": "member recordHash is not a sha256 digest",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "recordHash must be a sha256 digest"
    },
    {
      "surface": "buildExportBundle",
      "case": "filters declared without the filtered chain scope",
      "outcome": "rejected",
      "errorName": "Error",
      "message": "export bundle: filters require chainScope 'filtered'"
    },
    {
      "surface": "buildExportBundle",
      "case": "filtered chain scope without a filters declaration",
      "outcome": "rejected",
      "errorName": "Error",
      "message": "export bundle: chainScope 'filtered' requires a filters declaration"
    },
    {
      "surface": "buildExportBundle",
      "case": "commits carried by a scoped bundle",
      "outcome": "rejected",
      "errorName": "Error",
      "message": "export bundle: commits are not supported in a scoped bundle"
    },
    {
      "surface": "buildExportBundle",
      "case": "annex packId is not printable ASCII",
      "outcome": "rejected",
      "errorName": "Error",
      "message": "export bundle: annex packId must be printable ASCII"
    },
    {
      "surface": "buildExportBundle",
      "case": "two annex packs share one packId",
      "outcome": "rejected",
      "errorName": "Error",
      "message": "export bundle: duplicate annex packId \"gdpr_core\""
    },
    {
      "surface": "parseExportBundle",
      "case": "container text is not JSON",
      "outcome": "rejected",
      "errorName": "Error",
      "message": "export bundle: invalid JSON container"
    },
    {
      "surface": "parseExportBundle",
      "case": "container is a JSON string, not an object",
      "outcome": "rejected",
      "errorName": "Error",
      "message": "export bundle: invalid JSON container"
    },
    {
      "surface": "parseExportBundle",
      "case": "unsupported bundleVersion",
      "outcome": "rejected",
      "errorName": "Error",
      "message": "export bundle: unsupported bundleVersion \"vevb-9\""
    },
    {
      "surface": "parseExportBundle",
      "case": "container has no manifest or files",
      "outcome": "rejected",
      "errorName": "Error",
      "message": "export bundle: missing manifest or files"
    },
    {
      "surface": "verifyExportBundle",
      "case": "verifier handed a non-object",
      "outcome": "rejected",
      "errorName": "TypeError",
      "message": "verifyExportBundle: expected an ExportBundle object"
    }
  ]
}
```

The relevant rows for this page are `verifyExportBundle: expected an ExportBundle object`, and the four `parseExportBundle` guards — `invalid JSON container`, `unsupported bundleVersion`, and `missing manifest or files`. Everything else in that catalogue is indexed on the [troubleshooting reference](/docs/reference/troubleshooting/).

## Trust boundary

[Section titled “Trust boundary”](#trust-boundary)

A successful verifier proves internal consistency of the bytes and declared chain scope. It does not prove:

-   the original event was truthful or authorized;
-   the producer captured every real-world action;
-   an actor ID belongs to a particular human;
-   a filtered or windowed export contains omitted records;
-   a commit ledger’s members describe records that exist;
-   storage, retention, or access practice satisfied a legal obligation;
-   an unsigned bundle came from a claimed producer.

Verification produces compliance evidence. It is not a compliance determination, and no verifier result is legal advice.

Keep the verifier version, full report, source bytes, chain scope, and trusted key identity with operational evidence. Never reduce the result to a screenshot of a green badge.

Run the [chain tutorial](/docs/start/verify-a-chain/) or build the [export fixture](/docs/reference/export-format/) next.

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

Last updated: Aug 23, 2026

[Previous  
SDK parity matrix](/docs/reference/parity-matrix/)[Next  
Export format](/docs/reference/export-format/)

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