Skip to content
VeritioDocs

Troubleshooting and error reference

Kind
troubleshooting
For
developer · operator
Verified against
@veritio/core@0.4.7

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.

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 record

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

verified output — fail-closed catalogue
{
"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.

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

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 conflict

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

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:

verified output — governed-change guards
{
"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
}

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:crypto
import { scoreRiskSignals } from '@veritio/core'
// safe in a browser bundle: pure protocol math
import { 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.

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 throw Error. The errorName column in the catalogue above is the authoritative split.
  • Python raises. Shape guards raise TypeError; canonical JSON raises ValueError for 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.

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: true in 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.