# Store conformance suite

Kind

guide

For

developer · operator

Verified against

@veritio/core@0.4.7 · @veritio/storage@0.4.7

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”](#the-suite-is-a-list-not-a-framework)

Terminal window

```sh
bun add @veritio/core@0.4.7 @veritio/storage@0.4.7
```

`createAuditStoreConformanceTests(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”](#one-target-per-check)

The suite creates and destroys a target around every individual check:

```text
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 threw
```

This 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”](#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”](#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.

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

Test seam only

`mutateStoredRecord` must not be reachable from the `AuditStore` interface or from application code. It belongs in test harness code beside the connection setup, never in the adapter you ship.

## A custom store, run end to end

[Section titled “A custom store, run end to end”](#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`.

src/examples/storage/conformance-suite.ts

```ts
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:

Terminal window

```sh
bun src/examples/storage/conformance-suite.ts
```

The build executes the fixture and requires this exact output:

verified output

```json
{
  "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”](#wiring-it-into-a-host-runner)

Because the suite returns data, adapting it to a runner is a loop. With `bun:test`:

```ts
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”](#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”](#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](/docs/storage/overview/) to see which boundaries are authoritative and which are derived, [Postgres storage](/docs/storage/postgres/) for a transactional adapter that already passes this suite, or the [hash chain](/docs/concepts/hash-chain/) concept for the invariants the integrity checks are defending.

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

Last updated: Aug 23, 2026

[Previous  
Transactional outbox](/docs/storage/outbox/)[Next  
Self-hosting](/docs/storage/self-hosting/)

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