Store conformance suite
The AuditStore interface has two methods, and both are easy to implement wrongly. A store can persist records, return them, satisfy its own unit tests, and still leak one tenant’s sequence into another, silently accept a reused idempotency key, hand out mutable references to stored evidence, or serve a record that was edited in the database after it was written. @veritio/storage/conformance exports the executable definition of what an authoritative store must do, so those failures surface as a failing test instead of as unverifiable evidence years later.
The suite is a list, not a framework
Section titled “The suite is a list, not a framework”bun add @veritio/core@0.4.7 @veritio/storage@0.4.7createAuditStoreConformanceTests(options) returns a plain array of { name, run } objects. It does not call describe, test, it, or any runner global. Each run() is an async function that throws on failure, using node:assert/strict internally, which means the suite executes under any Node-compatible or Bun runtime and adapts to whatever test runner the host already uses.
options has exactly two fields:
| Field | Purpose |
|---|---|
name |
A label for the target under test. The suite carries it but does not read it — the returned check names are fixed strings, so use name in your own describe block or report. |
createTarget() |
Builds one fresh AuditStoreConformanceTarget. May return a promise. |
The target is the adapter’s side of the contract:
| Field | Required | Purpose |
|---|---|---|
store |
yes | The AuditStore under test — the only surface the checks exercise. |
mutateStoredRecord(corruption) |
yes | Rewrites persisted bytes out of band. May return a promise. |
close() |
no | Releases pools, drops temporary tables, disconnects clients. May return a promise. |
corruption carries { tenantId, sequence, mutate }. Your implementation loads the stored record for that tenant and sequence, passes it to mutate, and writes back either the value mutate returned or the record it mutated in place — mutate(record) ?? record is the whole rule.
One target per check
Section titled “One target per check”The suite creates and destroys a target around every individual check:
for each check returned by createAuditStoreConformanceTests(options) createTarget() → fresh store + fresh corruption seam await check.run() → asserts only through the AuditStore API finally await close?.() → runs even when the check threwThis isolation is load-bearing rather than tidy. The suite reuses fixed identifiers — tenants org_conformance_a and org_conformance_b, events evt_conformance_01 through evt_conformance_03 — across every check. A target that hands back the same table, collection, or directory on each call will see sequence 1 already taken and fail for reasons that have nothing to do with the invariant under test. For live databases, createTarget() should generate a uniquely named table or collection, apply the schema, and drop it in close().
What each check proves
Section titled “What each check proves”The suite is five checks. Each isolates one invariant, and each maps to a class of production incident.
Tenant-scoped chains and deterministic listing. Two events are appended to tenant A, one to tenant B. Tenant A must produce sequences 1 and 2 with previousHash moving from null to the first record’s hash; tenant B must independently start again at sequence 1 with previousHash: null. Listing tenant A must return exactly the two records the appends returned — a deepEqual against the values append handed back, which also proves the store does not re-stamp appendedAt or drop envelope fields on the read path. The listed records are then passed to verifyAuditRecords, and paging with { afterSequence: 1, limit: 1 } must return only the second record. This is the check that catches a global sequence counter, a missing tenant predicate in the list query, and off-by-one paging.
Idempotency uniqueness. The same event is appended twice under the key invite:user-456. The second call must return the original record rather than creating a second one, and the tenant must still hold exactly one record. A third append that reuses the same key with different event bytes must reject with a message matching /idempotency conflict/. Returning a fresh record on replay and silently overwriting on conflict are both failures; so is accepting the conflicting append, which would make one key describe two different claims.
Fail-closed scope and tip checks. An event with no scope.tenantId must reject with /scope\.tenantId is required/ before anything is written. Then, after one successful append, a compare-and-append carrying a deliberately wrong expectedPreviousHash must reject with /expectedPreviousHash does not match tenant chain tip/. Both messages are matched by regex, so a custom store has to include those substrings in the errors it throws. Reusing the wording is the cheapest way to stay compatible.
Cloned reads. The check takes the record append returned, sets event.metadata.role to a different value, and overwrites hash with a sentinel. A subsequent list must still return the original metadata and the original hash, and must still verify. Any store that keeps a reference to a live in-memory object — or returns the same object it stored — fails here. Serializing on write and parsing on read, as a real database column does, satisfies this without extra work.
Fail-closed integrity. One record is corrupted through mutateStoredRecord, then list must reject with /stored audit record integrity check failed/. Not return the tampered record with a flag. Not return it silently. Reject.
The corruption seam is the point
Section titled “The corruption seam is the point”mutateStoredRecord exists because the store’s own API cannot express the threat being tested. Evidence is not attacked through append; it is attacked through a direct UPDATE, a restored backup, an accidental migration, or an operator with database access. So the test seam has to reach the same layer.
application path append() ── list() │ ▲ ▼ │ persisted bytes (row, document, file) ▲ │test-only path mutateStoredRecord()If mutateStoredRecord were implemented by calling back into the store, the check would prove nothing: the store would re-derive a consistent hash and the corruption would never exist. Implement it against the raw storage layer — a SQL UPDATE ... SET record_json = $1 WHERE tenant_id = $2 AND sequence = $3, a document replace, or a direct rewrite of the serialized row. The repository’s own live suites do exactly that for Postgres, MySQL, MariaDB, and Mongo.
The invariant this forces is worth stating plainly: an authoritative store must re-verify integrity on read, not only at append time. Append-time hashing protects nothing against an edit that happens afterwards.
A custom store, run end to end
Section titled “A custom store, run end to end”This checked fixture implements an AuditStore from scratch — one that serializes each record into a JSON string, exactly as a database column would — and runs the published suite against it. It never reimplements hashing; hashAuditRecord and hashIdempotencyKey come from @veritio/core.
import { type AuditEvent, type AuditRecord, type AuditStore, type AuditStoreAppendOptions, type AuditStoreListOptions, type EvidenceScope, HASH_ALGORITHM, canonicalJson, hashAuditRecord, hashIdempotencyKey,} from '@veritio/core'import { type AuditStoreConformanceCorruption, createAuditStoreConformanceTests,} from '@veritio/storage/conformance'
/** * Proves that a custom `AuditStore` — one this file implements, not one Veritio * ships — can be validated against the same published conformance suite the * Postgres, MySQL, MariaDB, and Mongo adapters must pass. * * `createAuditStoreConformanceTests` is the whole contract for authoritative * evidence storage, expressed as executable checks. A store only counts as * authoritative when it satisfies every one of them: * * - gapless, tenant-local sequences and previous-hash linkage, with one * tenant's chain never visible from another tenant's scope; * - idempotent replay by key, and a fail-closed `idempotency conflict` when the * same key is reused for different event bytes; * - fail-closed rejection of a missing `scope.tenantId` and of a stale * `expectedPreviousHash` compare-and-append; * - cloned reads, so a caller mutating a returned record cannot reach stored * evidence; * - re-verification on read: the suite corrupts a stored record behind the * store's back through `mutateStoredRecord`, and the store must refuse to * hand it out rather than return tampered evidence. * * That last check is why the store below serializes each record to a JSON * string in its row map: the corruption seam is the stored bytes, exactly as it * would be in a real database column, and every read re-hashes those bytes with * `hashAuditRecord` before the record escapes. * * The appended-at timestamp is pinned so the printed result is byte-stable * across runs. */const PINNED_APPENDED_AT = '2026-08-09T10:00:00.000Z'
/** One serialized evidence row, standing in for a database row. */type StoredRow = { tenantId: string sequence: number idempotencyKeyHash: string eventCanonical: string recordJson: string}
/** * A custom store plus the raw rows behind it. The conformance target needs the * row handle to simulate out-of-band tampering; application code never does. */export type SerializedAuditStore = AuditStore & { rows: StoredRow[] }
/** * Extracts the tenant id an append must be scoped to, failing closed before any * write when scope is missing. Tenant scope is what keeps one tenant's chain * from ever being appended to or read by another. */function requireTenantId(event: AuditEvent): string { const tenantId = event.scope?.tenantId if (typeof tenantId !== 'string' || tenantId.length === 0) { throw new TypeError('scope.tenantId is required') } return tenantId}
/** * Parses a stored row back into a record and re-verifies it before returning * it. Integrity is checked on every read, not only at append time, so a record * edited directly in the backing store fails closed instead of being served as * evidence. Parsing also makes the returned record a clone, so callers can * never mutate stored bytes through a reference. */function readStoredRow(row: StoredRow, expectedTenantId: string): AuditRecord { const record = JSON.parse(row.recordJson) as AuditRecord if (record.event.scope?.tenantId !== expectedTenantId) { throw new TypeError('stored audit record tenant mismatch') } if (hashAuditRecord(record) !== record.hash) { throw new TypeError('stored audit record integrity check failed') } return record}
/** * Builds the in-process custom `AuditStore` under test. It owns tenant-local * sequencing, idempotency uniqueness, and compare-and-append against the tenant * chain tip; hashing itself is never reimplemented, only delegated to * `hashAuditRecord` / `hashIdempotencyKey` from `@veritio/core`. */export function createSerializedAuditStore(): SerializedAuditStore { const rows: StoredRow[] = []
return { rows,
/** * Appends one event to the tenant-local chain. Replaying an idempotency key * with identical event bytes returns the original record; reusing it for * different bytes, or appending against a stale chain tip, fails closed. */ async append(event: AuditEvent, options: AuditStoreAppendOptions = {}): Promise<AuditRecord> { const tenantId = requireTenantId(event) const idempotencyKeyHash = hashIdempotencyKey(tenantId, options.idempotencyKey ?? event.id) const eventCanonical = canonicalJson(event)
const existing = rows.find( (row) => row.tenantId === tenantId && row.idempotencyKeyHash === idempotencyKeyHash, ) if (existing) { if (existing.eventCanonical !== eventCanonical) { throw new TypeError('idempotency conflict') } return readStoredRow(existing, tenantId) }
const tipRow = rows .filter((row) => row.tenantId === tenantId) .sort((left, right) => right.sequence - left.sequence)[0] const tip = tipRow ? readStoredRow(tipRow, tenantId) : undefined const previousHash = tip?.hash ?? null if (options.expectedPreviousHash !== undefined && options.expectedPreviousHash !== previousHash) { throw new TypeError('expectedPreviousHash does not match tenant chain tip') }
const recordWithoutHash: Omit<AuditRecord, 'hash'> = { event, sequence: (tip?.sequence ?? 0) + 1, previousHash, hashAlgorithm: HASH_ALGORITHM, canonicalization: 'veritio-json-v1', appendedAt: PINNED_APPENDED_AT, idempotencyKeyHash, } const record: AuditRecord = { ...recordWithoutHash, hash: hashAuditRecord(recordWithoutHash) } const row: StoredRow = { tenantId, sequence: record.sequence, idempotencyKeyHash, eventCanonical, recordJson: JSON.stringify(record), } rows.push(row) return readStoredRow(row, tenantId) },
/** * Lists one tenant's records in sequence order. Tenant scope is mandatory, * and every row is re-verified on the way out. */ async list( scope: EvidenceScope & { tenantId: string }, options: AuditStoreListOptions = {}, ): Promise<AuditRecord[]> { if (typeof scope.tenantId !== 'string' || scope.tenantId.length === 0) { throw new TypeError('scope.tenantId is required') } const afterSequence = options.afterSequence ?? 0 const matching = rows .filter((row) => row.tenantId === scope.tenantId && row.sequence > afterSequence) .sort((left, right) => left.sequence - right.sequence) const limited = options.limit === undefined ? matching : matching.slice(0, options.limit) return limited.map((row) => readStoredRow(row, scope.tenantId)) }, }}
/** * Rewrites stored bytes behind the store's back so the suite can prove reads * fail closed on tampering. This is a test seam only: it is deliberately not * reachable through the `AuditStore` interface. */function mutateStoredRow(store: SerializedAuditStore, corruption: AuditStoreConformanceCorruption): void { const index = store.rows.findIndex( (row) => row.tenantId === corruption.tenantId && row.sequence === corruption.sequence, ) if (index === -1) { throw new TypeError('stored audit record not found') } const row = store.rows[index]! const record = JSON.parse(row.recordJson) as AuditRecord const nextRecord = corruption.mutate(record) ?? record store.rows[index] = { ...row, recordJson: JSON.stringify(nextRecord) }}
/** One conformance check name paired with whether the custom store satisfied it. */export type ConformanceCheckResult = { name: string; ok: boolean; error?: string }
/** * Runs every published conformance check against a fresh instance of the custom * store and reports each check by name. Each check gets its own target so state * from one tenant-chain scenario can never mask a failure in the next. */export async function runStoreConformanceSuite(): Promise<ConformanceCheckResult[]> { const checks = createAuditStoreConformanceTests({ name: 'serialized-in-process-store', createTarget() { const store = createSerializedAuditStore() return { store, mutateStoredRecord(corruption) { mutateStoredRow(store, corruption) }, } }, })
const results: ConformanceCheckResult[] = [] for (const check of checks) { try { await check.run() results.push({ name: check.name, ok: true }) } catch (error) { results.push({ name: check.name, ok: false, error: (error instanceof Error ? error.message : String(error)).split('\n')[0], }) } } return results}
if (import.meta.main) { const checks = await runStoreConformanceSuite() const output = { store: 'serialized-in-process-store', checkCount: checks.length, checks, conformant: checks.every((check) => check.ok), } console.log(JSON.stringify(output, null, 2))}Run it from the website checkout:
bun src/examples/storage/conformance-suite.tsThe build executes the fixture and requires this exact output:
{ "store": "serialized-in-process-store", "checkCount": 5, "checks": [ { "name": "appends tenant-scoped chains and lists records deterministically", "ok": true }, { "name": "returns idempotent records and rejects conflicting idempotency keys", "ok": true }, { "name": "fails closed for missing tenant scope and expected tip mismatches", "ok": true }, { "name": "returns cloned records so callers cannot mutate stored evidence", "ok": true }, { "name": "fails closed when stored record integrity is corrupted", "ok": true } ], "conformant": true}checkCount: 5 is the current size of the contract. Treat that number as a floor rather than a constant: a store pinned to @veritio/storage@0.4.7 proves the five invariants that release defines, and a later release can add checks a passing store has not yet seen.
Wiring it into a host runner
Section titled “Wiring it into a host runner”Because the suite returns data, adapting it to a runner is a loop. With bun:test:
import { describe, test } from 'bun:test'import { createPostgresAuditStore } from '@veritio/storage'import { createAuditStoreConformanceTests } from '@veritio/storage/conformance'
describe('postgres AuditStore conformance', () => { for (const conformanceTest of createAuditStoreConformanceTests({ name: 'postgres', async createTarget() { const harness = await createPostgresHarness() // host-owned: pool, unique table, schema return { store: createPostgresAuditStore({ client: harness.executor, tableName: harness.tableName }), async mutateStoredRecord({ tenantId, sequence, mutate }) { const record = await harness.readRecordJson(tenantId, sequence) await harness.writeRecordJson(tenantId, sequence, mutate(record) ?? record) }, close: () => harness.close(), } }, })) { test(conformanceTest.name, conformanceTest.run) }})The same loop works with node:test, Vitest, or Jest — only the describe/test import changes. The harness, not @veritio/storage, owns connection strings, credentials, containers, schema application, and cleanup. Keep environment-variable reads in the test bootstrap; the storage package does not read them.
Gate the live suite on the presence of its connection string so contributors without containers still run the in-memory checks, which is how the upstream repository skips its Postgres, Neon, MySQL, MariaDB, and Mongo suites when those URLs are absent.
What is out of scope
Section titled “What is out of scope”Derived tiers do not run this suite. The object archive (R2, S3, MinIO) and the ClickHouse read model are not AuditStore implementations and must never be. Object storage cannot perform a transactional compare-and-append; ClickHouse has no synchronous unique constraint or transactional tip check. Both are eventually consistent, both are fed from an authoritative store, and neither may own a sequence number or answer a definitive verification or DSAR request. Running conformance against them would either fail or, worse, encourage an adapter to fake the guarantees. A Redis tenant-tip cache is in the same category: validate it as a cache beside a durable store, not as a store.
Evidence commits are not required. AuditStore is append and list. The suite never calls a commit API, never constructs an EvidenceCommit, and never asks a target for one. A store that only appends and lists audit records is fully conformant. Commit support is a separate capability — the file store implements it — and its own hashing rules are verified independently.
What a green suite does and does not prove
Section titled “What a green suite does and does not prove”A passing run means the store satisfies the tenant-ordering, idempotency, fail-closed, cloning, and read-integrity invariants under the suite’s single-process, low-volume scenarios. It supports an argument that the store is trustworthy as an evidence authority.
It does not prove concurrency safety: the checks are sequential, so a store whose sequence assignment races under parallel writers can still pass. It does not prove durability or crash safety — an in-memory fake passes every check, as the fixture above demonstrates. It does not exercise backup, restore, retention, or access control. And it says nothing about whether your application recorded the right events in the first place.
Pair conformance with a concurrency test against your real backend, a restart test, and the operational controls around the append boundary.
Continue with Storage overview to see which boundaries are authoritative and which are derived, Postgres storage for a transactional adapter that already passes this suite, or the hash chain concept for the invariants the integrity checks are defending.