Verifier reference
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.
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”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:
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 = sequenceVerifier 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:
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)){ "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”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:
{ "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”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:
verifyAuditRecords(records) records are intact +verifyEvidenceCommits(commits) ledger is intact +member.recordHash === 'sha256:' + record.hash ledger describes those records =end-to-end evidence verificationLanguage coverage
Section titled “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 for the full capability split.
Export-bundle report
Section titled “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:
{ "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:
structure:manifestandfilesare real objects, paths and keys map 1:1 with no duplicates, and the threerecords/*.jsonlfiles plusverification.jsonare present.integrity: every file digest recomputes,rootHashrecomputes, and each record file’s line count matches its declared count.chains: record files are parsed back, re-run through the chain verifiers, and their verdicts must equal the embeddedverification.json.signature:validorinvalidwhen a signature is present and apublicKeywas supplied,skippedwhen present without a key,absentwhen 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”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:
{ "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.
Trust boundary
Section titled “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 or build the export fixture next.