Troubleshooting and error reference
Veritio refuses inputs that would produce weaker evidence rather than accepting them and recording something unverifiable. That refusal is a thrown error or a returned error value with a fixed message string, and this page is the index of those strings. Find the message you are looking at, read what invariant it protected, and change the input rather than the guard.
Where a guard sits
Section titled “Where a guard sits”Every guard fires before anything durable exists. There is no partially written record, no half-built draft, no bundle on disk to clean up. The ordering is deliberate:
host input ↓ shape guards actor/target/action/date present and well formed ↓ value guards every value has a canonical JSON form ↓ scope guards scope.tenantId present, non-blank ↓ derivation ids, digests, changed paths, idempotency hash ↓ hashing canonical bytes → sha256 ↓ append sequence, previousHash, idempotency recordA guard that fires at any level aborts the whole call. Nothing below it runs. This is what makes a caught error safe to retry after correcting the input: the failed attempt consumed no sequence number and left no idempotency entry.
The verified guard catalogue
Section titled “The verified guard catalogue”The fixture below runs thirty-five bad inputs through ten public surfaces and prints the error each one raised, verbatim. It runs in CI and its output is byte-compared, so the strings on this page are the strings your application will see from @veritio/core@0.4.7.
{ "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" } ]}Read three fields per row. outcome is rejected when the guard fired; a probe that returned normally is reported as accepted, not skipped, so a guard that silently stops firing shows up as a byte diff instead of disappearing. errorName separates TypeError (a malformed input for a protocol helper) from Error (an export-bundle declaration that contradicts itself). allFailClosed: true states that all thirty-five probes were rejected in this run.
Message index
Section titled “Message index”These are the messages most likely to reach a host application, with the invariant behind each.
| Message | Raised by | Cause | Fix |
|---|---|---|---|
scope.tenantId is required |
store append, createGovernedActionDraft, list |
The event or draft input carried no tenant id, or a blank/whitespace one. Tenant scope keys the sequence, the previous-hash link, and the idempotency hash, so there is nothing to append to. | Resolve the tenant at the recorder boundary and pass it in scope. Never default it to a placeholder. |
idempotencyKey is required |
createGovernedActionDraft |
The key was absent, empty, or whitespace. It seeds both derived ids and the tenant-scoped idempotency hash. | Derive it from the host mutation’s natural key, for example billing:plan_9001:upgrade:1. |
at least one governed field must change |
createGovernedActionDraft |
Comparing before and after produced no changed path. A field is only compared when it is declared in the entity, its capture mode is not omit, and its after value is defined; equality is decided on canonical JSON, so 1 and 1.0 are the same value. |
Either skip the draft — a no-op mutation is not governed-change evidence — or declare the change explicitly, described below. |
capture mode randomized_digest is not supported by the current governed-change draft helper |
createGovernedActionDraft |
A governed field declared a capture mode the draft helper cannot build. It supports full, keyed_digest, and content_digest; omit fields are skipped before this point. |
Change the field’s capture mode in defineEntity, or omit the field from the governed set. |
metadata.changeId is reserved by Veritio |
mergeVeritioMetadata |
Caller metadata contained one of the nine reserved context keys: authSessionId, authContextId, activityEpisodeId, traceId, correlationId, causationEventId, changeId, capturePolicyId, collectionSource. The SDK owns these so read models can group evidence by them. |
Rename your key, or pass the value through the context argument so the SDK stamps it. |
recordHash must be a sha256 digest |
createEvidenceCommit |
A commit member’s recordHash did not match sha256: followed by exactly 64 lowercase hex characters. A commit binds records by digest; a malformed digest would produce a Merkle root that binds nothing. |
Copy the stored hash from the record verbatim. Do not strip the prefix or upper-case the hex. |
member indices must be contiguous from zero |
createEvidenceCommit |
After sorting by index, the members were not 0, 1, 2, …. Gaps or duplicates would make the leaf ordering — and therefore the root — ambiguous. |
Renumber the manifest from zero after any filtering step. |
duplicate commit member |
createEvidenceCommit |
Two members shared the same recordType and recordId. A record counted twice inflates recordCount and changes the root without adding evidence. |
De-duplicate by recordType plus recordId before building the manifest. |
export bundle: filters require chainScope 'filtered' |
buildExportBundle |
Filters were supplied while the bundle still claimed a full or windowed chain. The manifest’s scope is the claim a reader trusts, so it must match the selection that actually happened. | Set chainScope: 'filtered' alongside the filters, or drop the filters. |
export bundle: commits are not supported in a scoped bundle |
buildExportBundle |
Commits were supplied for a windowed or filtered bundle. A commit binds a complete set of records; a scoped bundle does not carry that set, so the commit could not be reconciled inside the bundle. |
Export commits from a full-scope bundle, or remove them from the scoped one. |
Two neighbouring guards complete the pair set: export bundle: chainScope 'filtered' requires a filters declaration is the inverse of the filters rule, and expectedPreviousHash does not match tenant chain tip rejects an append whose caller believed a stale tip.
Verification reasons
Section titled “Verification reasons”Verification does not throw. It returns a result, and a failure carries the array index of the first offending record plus a fixed reason. Six reasons exist for record chains, from verifyAuditRecords and verifyEvidenceEdgeRecords.
| Reason | What it indicates |
|---|---|
missing_tenant_scope |
The record has no event.scope.tenantId (or no edge.scope.tenantId). There is no chain to place it in. |
unsupported_hash_algorithm |
hashAlgorithm is not sha256. The verifier will not guess an algorithm. |
unsupported_canonicalization |
canonicalization is not veritio-json-v1. The bytes cannot be reproduced under a canonicalization the verifier does not implement. |
sequence_mismatch |
The record’s sequence is not exactly one greater than the last verified record for that tenant. A gap, a repeat, or a chain that starts above 1 — the signature of a deleted or reordered record. |
previous_hash_mismatch |
The sequence was right but previousHash does not point at the last verified hash for that tenant. Records were substituted or spliced from another chain. |
hash_mismatch |
The record’s own bytes were recomputed and disagree with the stored hash. Content changed without the envelope being rebuilt. |
Commit-ledger verification, from verifyEvidenceCommits, has nine reasons over per-stream chains.
| Reason | What it indicates |
|---|---|
unsupported_hash_algorithm |
hashAlgorithm is not sha256. |
unsupported_canonicalization |
canonicalization is not veritio-json-v1. |
unsupported_tree_algorithm |
treeAlgorithm is not the pinned Merkle algorithm, so the root is not comparable. |
sequence_mismatch |
The commit’s sequence is not one past the last commit for that streamId. |
previous_hash_mismatch |
previousCommitHash does not point at the previous commit for that stream. The first commit in a stream must carry null. |
invalid_member_manifest |
The member list failed normalization — empty, non-contiguous indices, a duplicate member, an unknown recordType, a malformed recordHash — or streamId is missing or empty. Untrusted JSON reaches this guard even when the compile-time type says it cannot. |
record_count_mismatch |
recordCount disagrees with the number of normalized members. |
records_root_mismatch |
The Merkle root recomputed from the members disagrees with the stored recordsRoot. The member set changed after the commit was built. |
hash_mismatch |
The commit hash recomputed from its canonical fields disagrees with the stored hash, or hash is not a string. |
Commit verification proves the ledger’s internal consistency only. It does not reconcile each member.recordHash against an independently verified record, so a fabricated commit chain over fabricated digests verifies ok in isolation. Per-record integrity comes from verifyAuditRecords and verifyEvidenceEdgeRecords; compose both.
The idempotency-conflict trap
Section titled “The idempotency-conflict trap”idempotency conflict is the guard most often mistaken for a bug. The store keys a replay on sha256(tenantId + NUL + idempotencyKey) and stores the canonical JSON of the event it accepted under that key. A second append with the same key compares canonical bytes: identical bytes return the original record, different bytes throw.
The trap is a session or request context that widens mid-stream while the key stays put.
attempt 1 key "invite:inv_123" event { …, metadata: { role: "viewer" } } → stored, sequence 4
attempt 2 key "invite:inv_123" event { …, metadata: { role: "viewer", sessionId: "ses_9" } } → same key, different canonical bytes → TypeError: idempotency conflictAnything that changes the canonical bytes triggers it: an added metadata key, a re-resolved occurredAt, a normalized actor id, a recorder that started stamping a session or episode id partway through a retry loop. None of those are “the same event” as far as the chain is concerned, and accepting the second one would let a retry silently rewrite recorded evidence.
Note the asymmetry with tenant scope. Because the tenant id is mixed into the key hash, re-scoping to a different tenant does not conflict — it lands in that tenant’s chain as a fresh append. Re-scoping within one tenant, or changing anything else about the event, does conflict. If a retry legitimately carries new information, give it a new idempotency key and record it as its own event.
The no-op governed update
Section titled “The no-op governed update”at least one governed field must change is deliberate: the draft helper will not manufacture a revision for a mutation that changed nothing it governs. The escape hatch is to declare the paths yourself. createGovernedActionDraft accepts an optional changedPaths, and when it is supplied the helper skips inference entirely.
createGovernedActionDraft({ // … before: row, after: row, changedPaths: ['/planTier'],})Use this when the change is real but invisible to the diff — a value the entity captures as omit, a re-affirmation the host must record, a governed field written outside the row you handed in. Do not use it to silence the guard on a genuine no-op: you would be asserting a change that did not happen, and the resulting revision is evidence of that assertion. Explicit paths are sorted and carried onto the revision unchanged; an empty array still fails the guard.
The governed guard set has its own checked fixture:
{ "control": { "changeId": "chg_billing_plan_plan_9001_2d4e4ac92afa4a8a", "activityId": "act_billing_plan_plan_9001_2d4e4ac92afa4a8a", "revisionId": "rev_billing_plan_plan_9001_85271b1aa8f9_172321d5", "changedPaths": [ "/planTier" ], "eventActions": [ "change.declared", "activity.recorded", "entity.revision.created" ] }, "guards": [ { "guard": "missing tenant scope", "thrown": true, "errorName": "TypeError", "message": "scope.tenantId is required" }, { "guard": "empty idempotencyKey", "thrown": true, "errorName": "TypeError", "message": "idempotencyKey is required" }, { "guard": "principal ref missing authority", "thrown": true, "errorName": "TypeError", "message": "ref.authority is required" }, { "guard": "principal ref missing id", "thrown": true, "errorName": "TypeError", "message": "ref.id is required" }, { "guard": "producer ref is not a principal", "thrown": true, "errorName": "TypeError", "message": "principal ref is required" }, { "guard": "no governed field changed", "thrown": true, "errorName": "TypeError", "message": "at least one governed field must change" } ], "allGuardsFailClosed": true}Vite, browsers, and node:crypto
Section titled “Vite, browsers, and node:crypto”Importing the @veritio/core root barrel in browser or edge code fails the bundle. The barrel reaches hashing and id generation, which import node:crypto, and Vite has no browser shim for it. The failure surfaces at build time as an unresolved node:crypto, or at runtime as a missing createHash.
The risk math is published separately for exactly this reason. It is crypto-free by construction — clamp, floor, divide, multiply only, no dependency on the core barrel — so it bundles for the browser unchanged:
// breaks a browser bundle: drags in node:cryptoimport { scoreRiskSignals } from '@veritio/core'
// safe in a browser bundle: pure protocol mathimport { scoreRiskSignals } from '@veritio/core/risk-score'Everything that hashes — record creation, appends, commits, governed drafts, assertion builders, export bundles — stays server-side. That is not only a bundling constraint. Browser-visible code must never hold storage credentials or digest keys, so the append boundary belongs on the server regardless of what your bundler could resolve.
Failure shape differs by language
Section titled “Failure shape differs by language”The guards are protocol behavior; their surface is not. Port the decision across languages, not the string.
- TypeScript throws. Most guards throw
TypeError; the export-bundle declaration guards throwError. TheerrorNamecolumn in the catalogue above is the authoritative split. - Python raises. Shape guards raise
TypeError; canonical JSON raisesValueErrorfor a non-finite number, with different wording from the TypeScript equivalent. Same rejection, different text and class. - Go returns. Helpers return
(T, error)—errors.New("idempotencyKey is required"),fmt.Errorf("capture mode %s is not supported by the current governed-change draft helper", …). A discarded error leaves a zero-value struct that looks like a draft and is not one. Check every returned error; there is nothing to catch.
Chain verification also differs in coverage today. All three SDKs verify commit ledgers and return a result value rather than raising. Record-chain verification — verifyAuditRecords and verifyEvidenceEdgeRecords — is TypeScript-only at 0.4.7. If your verification runs in Python or Go, verify records with the TypeScript verifier or an independent implementation of the documented rules.
What a caught error does not prove
Section titled “What a caught error does not prove”A fired guard proves one thing: that one input was rejected before it became evidence. It says nothing about the rest of your system.
- It does not prove your existing chain is intact. Guards validate inputs; only verification inspects stored records.
- It does not prove your host emits every event it should. Nothing in the SDK can observe a mutation that never called it.
allFailClosed: truein the fixture proves these thirty-five probes were rejected at this version. It does not enumerate every malformed input, and a guard is not a substitute for validating host data at your own boundary.- Message strings are documentation, not a stable API. They have changed between versions and can change again. Branch on the call site and the input you supplied, not on
error.message.
Continue with the verifier reference for the full verification report shape, the hash chain for what each mismatch reason implies about stored bytes, or governed actions for the mutation boundary most of these guards protect.