Skip to content
BitExactdocs
Documentation
On this page

PostgreSQL backend#

BitExact records to local files by default. Setting BITEXACT_DATABASE_URL points the same product at PostgreSQL instead:

export BITEXACT_DATABASE_URL=postgresql://bitexact@db.internal:5432/bitexact
pip install --no-index --find-links wheels --require-hashes -r requirements-postgres.lock   # in your platform bundle (installing.md)
pip install --no-index --no-deps bitexact_sdk-<version>-*.whl

Nothing above the storage layer changes. The hash chain, the bundle format, and the verifier are identical either way.

What the database is, and is not#

PostgreSQL is the control plane and the fast index. It is not the trust root. The chain is: each entry hash-links to its predecessor over RFC 8785 canonical bytes, and heads are anchored off-box (WORM object-lock, RFC 3161 timestamps). That is what makes tampering detectable.

This distinction is load-bearing because the database administrator is in the threat model. In-database immutability is defense in depth: an operator who can drop a trigger can drop the guard. What the guards buy is that no ordinary mistake and no casual statement can quietly shorten a run or repoint a link. What proves history was not edited is re-walking the chain and comparing heads against their external anchors.

An exported bundle verifies with the standalone verifier, which shares no code with the product, reads no configuration, and opens no database connection. A relying party never needs access to this database — and that is deliberate.

Requirements#

  • PostgreSQL 18, the version the test suite runs against
  • One recording service as the sole writer (agents hold no database credentials; they reach the recorder over the proxy)

Schema#

Fifteen tables, created automatically, in one transaction, on the first connection to an empty database, plus a monthly partition per month of run_entries.

TableHolds
runsone row per run: chain head, the derived index projection
run_entriesthe chain, one row per step, keyed (month, run_id, seq), partitioned by month
run_sidecarscheckpoints, anchors, custody events, recording failures
entity_ledgersthe chained ledgers: workflows, cases, refusals, roles, capture expectations
documentssuites and their saved results, saved evidence packs, and the viewer's and events' operational records
recorder_documentswhat only the recorder writes: its record of each act a peer submitted (claimed, written or refused), the runs it owes a seal, the transparency log's checkpoint and leaf index, signed purge records, its TLS key-exchange note, the collectors it found sending raw values, and each capture policy entries were recorded under
access_logthe access chain: who viewed, listed or retried what
archived_runsthe catalog of months moved out by bitexact archive
tlog_leavesthe transparency log: every signed checkpoint, in signing order
run_reportseach run's regress and fork-matrix reports
event_outboxsigned event notifications waiting to leave
event_deliveriesone delivery row per event and destination: state, attempts, lease
event_leasesnamed leases (the alert round, a dispatcher) on the database clock
capture_sourceseach collector, proxy and SDK process and when it last spoke
capture_overflowthe count of capture sources evicted past the bound

When you provision a least-privilege role, grant it on all fifteen and on the sequences access_log_id_seq and capture_sources_touch, and make it the owner of run_entries with CREATE on the schema. The service creates a month's partition of run_entries when that month's first entry arrives, and bitexact archive drops a month it has moved out; PostgreSQL lets only the owner of the partitioned table do either. A role without them records nothing in a new month: every call that month is a declared storage_unavailable gap.

The viewer connects as a role of its own, never the recorder's. bitexact db-grant-viewer ROLE, run with the recorder's DSN, lets that role read every table and write only documents, run_reports, event_outbox, event_deliveries and event_leases: its saved packs, suites and their results, reports and event deliveries. Granting takes back any wider write the role held before. Every chain table is the recorder's - the access log too, since a viewer that names a recorder hands it each access and requeue as an act - and so is recorder_documents, so a viewer connected as its role can neither append evidence nor forge or erase the recorder's record that an act was written or refused. Give that viewer BITEXACT_RECORDER: without a recorder it has no one to write its access events. On the file backend the recorder's documents live under recorder/ in the data directory, apart from every other document.

Entries are stored as text, not jsonb#

Deliberate, and worth stating plainly because it looks like a missed opportunity. PostgreSQL jsonb cannot represent a NUL character at all. A model response or tool output containing one is not hypothetical, and storing evidence as jsonb would make the recorder fail closed on exactly the payload it exists to capture.

The body column therefore holds the same JSON line the file backend writes — byte for byte, NUL-safe, surrogate-safe — so recorded evidence is identical across backends and export stays faithful. What the chain verifies is canonical bytes derived on read, so nothing is lost. Queryable fields (kind, ts, entry_hash, prev_hash) are written alongside as typed columns and indexed.

Concurrency#

Each append places and persists its link in one transaction. Writers serialize on the run's own head row (SELECT … FOR UPDATE), so runs never contend with each other, and two writers on the same run cannot both claim a step. One recorder per run is enforced across processes by a session advisory lock; a second recorder fails loudly rather than interleaving into the same chain.

Connections come from an app-side pool. A transaction pooler such as PgBouncer is not required and is not recommended until multiple service replicas approach max_connections, because session advisory locks and SET LOCAL lose their meaning behind one.

Guards#

RuleEnforcement
A recorded link cannot movetrigger: run, step, hash, prev are immutable
Entries cannot be deletedtrigger: append-only, except a sanctioned purge
Attestations cannot changetrigger: sidecars reject update and delete
A sealed run cannot be reopenedtrigger: no insert after a run_end
A legal hold outranks retentiontrigger: purge refuses a run whose chain carries a hold with no hold_release after it

Redaction stays possible because it must — destroying a value on request is a legal obligation. The guard permits the payload to change while pinning the columns that fix the run's shape, which is exactly the invariant the chain relies on: a redacted entry keeps its place and its hash, and the surviving commitment still proves what the value was.

One schema, no migrations#

There is one schema and no migration chain. Schema creation is serialized by an advisory lock, so several services starting at once against an empty database create it exactly once. A database that already holds the schema is used as it is. A database that holds only part of it was not created by this build: the service refuses to start and names the missing tables, rather than failing on its first write. Point BITEXACT_DATABASE_URL at an empty database, or move existing evidence with bitexact db-transfer. Recovery is point-in-time restore.

Retention: a hot window and an archive#

Records have to be kept for as long as the regime demands — around seven years across most of finance, twenty-five in clinical research, life insurance and paediatric health. Keeping all of that in a live database is the wrong shape: it grows without bound, it slows the queries an inspector actually runs, and it leaves decades-old evidence where an operator can reach it.

run_entries is partitioned by the month a run started, so every entry of a run lands in the same partition however long the run lives and a run can always be moved or dropped as one piece.

Beyond the hot window (BITEXACT_HOT_MONTHS, default 24) a run is exported, its bundle written to the archive, and its rows dropped:

bitexact archive --to /mnt/worm/bitexact-archive
bitexact archive --to /mnt/worm/bitexact-archive --no-drop     # write only
bitexact archive --to /mnt/worm/bitexact-archive --before 2020-01

Point --to at immutable storage — object-lock, a WORM appliance, or an archival tier. The bundles written there are the long-term evidence. They verify with the standalone verifier and no database, which is exactly why cold evidence can leave the store and still be evidence.

What stays behind is the catalog (archived_runs): one small row per archived run holding its workflow, dates, chain head, bundle location and digest. Small enough to keep for twenty-five years, and enough for an auditor's "show me that run from 2021" to end in a file rather than a shrug.

Order is not negotiable: the run is exported, the written bundle is read back and checked, the catalog records it, and only then are the rows dropped. Nothing is dropped if any run in the month failed to archive, and a legal hold refuses the drop outright — a hold outranks retention, and a bulk drop is exactly where that would otherwise be forgotten.

Backup and restore#

Use continuous archiving (pgBackRest or equivalent) for point-in-time recovery. The restore test that matters is not "does the database start" but does a bundle exported from the restored database still verify offline — run that after any restore drill. Compare restored heads against the external anchors as well: the anchors are what prove the restore did not silently roll history back.