ADR-0105: Backup, restore, and point-in-time recovery
Date: 2026-07-12
Status: Accepted
Deciders: Adrian (Soft4U BV), Claude Opus 4.8
Supersedes: none
Milestone: M1-W2 (docs/plans/2026-07-11-platform-remediation-roadmap.md, findings D1 CRITICAL, D2 HIGH)
ADR numbers 0103 (mock-flag decomposition) and 0104 (
app_env) are consumed by M0 and land via PR #283. 0105 is the next free number.
Decision context:
- Latency: Zero on the request path — no application code changes. Postgres-side cost of
archive_mode=onis one sequential 16 MB file copy per WAL segment, off the commit path (the archiver is a background process; commits do not wait on it).archive_timeout=300forces a segment switch every 5 min, so an idle cluster writes ≤ 12 mostly-empty 16 MB segments/hour (~190 MB/h worst case, pruned by retention). Measured backup wall-clock: 15 s for a 703 MB cluster (4 databases), 9.5 s for 1,519 MinIO objects. - Dependency surface: No new application dependencies. No new services. Zero new Python packages. The toolchain is 12 bash scripts using binaries that already exist:
pg_dump/pg_restore/pg_basebackup(invoked inside the existing Postgres container, so they always match the server version),gpg(host), andminio/mc(pulled on demand as a throwaway container). Nothing new runs in production. If an upstream yanks a release, nothing breaks — there is no upstream. - Debuggability: Every script prints a timestamped pass/fail line per step and exits non-zero on failure. Backups publish atomically (
<stamp>.incomplete→<stamp>), so a directory without the suffix is always complete — a crashed run cannot leave a truncated backup that sorts as "latest". Each backup carries aMANIFEST.sha256(detects bit-rot) and anaudit_digest.txt(the oracle the restore is checked against). At 3am the failure is a named check indocs/runbooks/restore.md, not a stack trace. - Reversibility: Trivially reversible. The scripts are additive and touch no application code.
archive_modeis one compose flag away fromoff(one container restart). Nothing in the running system reads anything this ADR introduces. Hours to undo: < 1. - Blast radius: Purely additive to the app (0 files in
backend/app/changed). The operational blast radius is real and is stated in Consequences → Negative:archive_mode=onintroduces a new failure mode in which a failingarchive_commandcauses Postgres to retain WAL indefinitely until the disk fills and writes stop. That is a deliberate trade — loudly stop over silently lose — but it is a new way to take the database down. - Alternative considered: Managed Postgres (RDS/Cloud SQL) with vendor-native PITR — rejected for now because no deployed environment exists (M2 owns that decision), and shipping nothing until M2 lands would leave the CRITICAL finding open for weeks. The toolchain is deliberately built so that M2 can delete most of it:
pg_run()is a single indirection that switches Postgres invocation betweendocker execand native binaries, so the same scripts run against a managed instance unchanged.
Context
Four parallel audits on 2026-07-11 produced finding D1, the most severe in the entire register:
No backups. Nothing. Anywhere. No
pg_dumpschedule, no WAL archiving, no PITR, no snapshot policy, no restore procedure, no restore test.
This was verified, not asserted: the only pg_dump in the repository was
scripts/export_contracts.sh — a schema export for documentation, not a backup.
The severity is not generic. It is severe relative to the product's own claims:
- ADR-0064 enforces
audit_eventsimmutability at the database: a BEFORE UPDATE/DELETE trigger that raises for all roles, aREVOKEof UPDATE/DELETE from the application role, andFK ... ON DELETE RESTRICTso a case with audit history cannot be cascaded away. This is a genuinely strong control. - ADR-0069 guarantees AMLR 5-year retention and ships a regulator-ready case-pack export whose entire premise is a durable audit trail.
- ADR-0021 claimed evidence bundles "benefit from existing backup, retention and access-control infrastructure." No such infrastructure existed. (M0-W4 struck that sentence.)
An immutable audit trail on an unbacked-up Docker volume is a claim, not a control.
The trigger prevents tampering. It does nothing about loss. A single
docker volume rm destroyed the exact artifact every compliance claim rested on.
Worse, docker-compose.yml puts one Postgres container in charge of four blast radii:
the application schema, Temporal's workflow history, Keycloak's identity realm, and
Langfuse. One volume, four catastrophes, zero backups.
What we found while building this that was not in the finding
Three things surfaced during implementation that make D1 worse than written:
-
There is no
audit_eventshash chain. The M1-W2 acceptance criterion says the restore should be "verified by replaying anaudit_eventshash chain." No such chain exists — the table has nohash, noprev_hash, no signature column. The criterion was unsatisfiable as written. Rather than redefine the term until it passed, we implemented what can be honestly computed (a deterministic content digest) and recorded the absence of a real chain as a tracked gap. See "Honest limits" below. -
A restore silently degrades audit immutability.
scripts/create_app_role.sql— the script any restore naturally reaches for, because without it the app cannot connect — runsGRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO trustrelay_app.ON ALL TABLESincludesaudit_events, re-granting the UPDATE and DELETE that migration 066 revoked. It exits 0. Nothing warns. The trigger still blocks the write so the system fails safe, but a three-layer control silently becomes a two-layer one. This is reproduced and asserted bypg_restore_verify.shstep 15a. -
A
pg_dumptaken astrustrelay_appwould be silently incomplete. That role is RLS-constrained (ADR-0050), so it can only see one tenant's rows — andpg_dumpwould still exit 0. A backup that succeeds, restores cleanly, and is missing most of the data is the worst possible failure. The toolchain therefore dumps as the superuser, and says so loudly in the code.
Decision
Ship a backup toolchain that works on today's docker-compose reality and lifts to a real environment unchanged, with three independent layers plus an executed restore test.
1. Logical backups — scripts/backup/pg_backup_logical.sh
pg_dump -Fc -Z6 per database, GPG-encrypted, published atomically, with a
MANIFEST.sha256 and the audit-trail digest captured at backup time.
pg_dumpall --globals-only runs first and is not optional. pg_dump does not contain
roles; roles live in the cluster. The trustrelay_app role that the entire RLS model
depends on exists only in the globals dump. Restore without it and every GRANT/REVOKE
in the dump silently fails to apply, because the grantee does not exist.
Langfuse is deliberately excluded from logical dumps (large, fast-growing, reconstructible LLM telemetry) but is still captured by the cluster-wide physical backup.
2. WAL archiving + PITR — docker-compose.yml + pg_backup_basebackup.sh + pg_wal_ship.sh
archive_mode=on, archive_timeout=300, archiving to a host bind mount outside the
Postgres volume — which is what makes the archive survive docker volume rm postgres_data. pg_basebackup provides the PITR anchor; pg_wal_ship.sh encrypts spooled
segments into the retained archive.
We did not judge PITR disproportionate, and the brief explicitly invited us to. The
reasoning: it costs four Postgres flags and one bind mount, it is cluster-wide (so it
covers Temporal's history and Keycloak for free), and it moves RPO from 24 hours to
5 minutes for precisely the artifact the compliance story depends on. Logical-only
backups would have meant "up to 24 hours of audit events, decisions, and SAR records are
simply gone" — which is not a defensible answer to a regulator, and is materially less
than ADR-0064/0069 already claim. It is verified end to end (pitr_verify.sh, 9/9).
WAL segments contain row images, i.e. the same PII and the same audit rows as the database. They are encrypted with the same key as the dumps. Treating WAL as "just transaction logs" is how an entire dataset leaks.
3. Object storage — scripts/backup/minio_backup.sh
Two layers, protecting against different things: bucket versioning (an overwrite or delete of an object becomes a recoverable delete-marker) and an encrypted off-box mirror (survives loss of the MinIO volume). Versioning alone dies with the volume; the mirror alone cannot recover a delete that happened between runs.
This script never deletes anything in MinIO. mc mirror runs without --remove, and
there is no mc rm in the file. Verified reason: the tenant branding logos live at
trust-relay-documents/{tenant-uuid}/branding/logo.png — inside the same bucket as the
case documents, interleaved under tenant/case prefixes. Any "clean up the documents
bucket" operation destroys every tenant's logo.
4. Encryption — fail-closed
Backups contain the full PII surface and the entire audit trail. If no encryption recipient is configured, the scripts refuse to run rather than write plaintext. Asymmetric GPG is preferred (the backup host holds only the public key, so a compromised backup runner cannot read backups it has already written); a symmetric passphrase is a documented dev-only fallback.
5. Retention — grandfather-father-son
30 daily / 12 weekly / 60 monthly. The 60-month tier is the AMLR Art. 77 five-year horizon. Keeping 1,825 daily full dumps to satisfy it would cost ~180 GB and be absurd.
6. The restore test is part of the backup job
backup_all.sh --with-restore-test (weekly). A restore path exercised only during an
incident is a restore path exercised for the first time during an incident.
Executed verification (2026-07-12)
Not a design. Run, against the live 703 MB / 806-audit-row / 1,519-object dev stack.
| Test | Result | What it actually proves |
|---|---|---|
pg_restore_verify.sh | 17 / 17 passed | A backup restored into a clean, empty cluster reproduces the schema (81 tables), the migration head, and the audit trail — with the content digest byte-for-byte identical (67cd657b…, 806/806 rows). All three immutability layers are proven behaviourally, not by inspection: UPDATE/DELETE as superuser raise the trigger's error; UPDATE as trustrelay_app raises permission denied (a different error — which is the oracle that tells you the GRANT layer, not the trigger, caught it); deleting a case with audit history raises the FK violation. |
pitr_verify.sh | 9 / 9 passed | Recovery to a chosen instant genuinely stops there: a row written before the target is present, a row written after it is absent. A restore that merely replayed to the end of the WAL would show both and would pass a naive "did the data come back?" check. |
minio_restore_verify.sh | 5 / 5 passed | 1,519 objects restore into a clean MinIO and are readable through the S3 API, with the aggregate SHA-256 of every object matching the live store — not a file count, which proves nothing about content. The branding logo is asserted by name. |
| Footgun regression (step 15a) | Confirmed real | Running create_app_role.sql after a restore re-granted arwd on audit_events — silently destroying immutability layer 2. reapply_immutability_grants.sql repairs it (15b). |
RPO / RTO actually achieved
Stated in docs/controls-register.md. Honestly:
| Threat | RPO | Verified? |
|---|---|---|
| Logical error (bad migration, dropped table, app bug) | ≤ 5 min (PITR) | Mechanism ✅ pitr_verify.sh. Requires archive_mode=on — see activation state below. |
Loss of the postgres_data Docker volume | ≤ 5 min (the WAL spool is a host bind mount, outside the volume) | Mechanism ✅ |
| Loss of the host disk | TOTAL LOSS — no off-host copy exists. | ⚠️ This is the largest remaining hole. It is not closed by this ADR. |
| MinIO objects | 24 h (backup interval); in-place deletes/overwrites recoverable via versioning | ✅ |
RTO (measured machine time, current data volume): Postgres app database into a clean cluster 9 s; MinIO 1,519 objects ~13 s; cluster start ~30 s. A full provision-to-serving DR drill was not timed as one continuous run — the components are measured, the end-to-end figure is not. Stated target: ≤ 1 hour, unverified.
Current activation state — read this before trusting the RPO above
archive_mode is on in docker-compose.yml but is NOT yet active on the running dev
Postgres. It is a postmaster setting and requires a container restart, and the shared dev
container was in use by another workstream; recreating it was not a risk worth taking to
save a docker compose up -d postgres.
Therefore, on the dev stack as it stands today, the real Postgres RPO is 24 hours (logical backups only), not 5 minutes. PITR is verified but not yet switched on. One command activates it:
docker compose up -d postgres # recreates with archive_mode=on
The controls register records this split honestly rather than claiming the better number.
Consequences
Positive
- The CRITICAL finding is closed for the failure modes that actually threaten a laptop-hosted PoC: volume loss, logical corruption, accidental deletion.
- The audit trail is now provably restorable — not "we have dumps," but a digest-verified round trip with all three immutability layers behaviourally asserted.
- One cluster-wide physical backup covers all four blast radii (app, Temporal, Keycloak, Langfuse) in a single artifact.
- The restore footgun is now caught by an automated test rather than discovered by an auditor. That was worth the whole exercise on its own.
- Zero new runtime dependencies, zero application code changed.
- Liftable:
pg_run()is the single seam betweendocker execand a managed Postgres.
Negative
- No off-host copy. The backups are on the same physical disk as the database they protect. A disk failure or a stolen laptop loses the database and every backup of it. This is the single largest remaining gap and this ADR does not close it. It needs a destination (S3/B2/rclone), which needs a deployed environment and a budget — M2. Until then, the honest statement is: we are protected against volume loss and human error, and not against hardware loss.
archive_mode=onintroduces a new way to take the database down. A failingarchive_commandmakes Postgres retain WAL indefinitely until the disk fills and writes stop. We chose "loudly stop" over "silently lose data," but this did not exist before. Until M2 ships alerting, thepg_stat_archiver.failed_countcheck insidepg_backup_logical.shis the only thing watching.- The private GPG key currently sits on the same host as the backups, so that host can
decrypt its own backups. Acceptable on a laptop; unacceptable deployed.
backup_bootstrap.shprints the escrow procedure but deliberately does not perform it — where the private key goes is an operational decision, not a script's. - No alerting. A backup job that stops running tells nobody.
backup_all.shexits non-zero and shouts to stderr, and until M2 that is the entire monitoring story. - The MinIO backup tars-and-encrypts the whole object set each run — it does not scale. Fine at 1,519 objects / 522 MB; wrong at 10⁶ objects. The real answer is MinIO bucket replication to a second endpoint with SSE-KMS, which needs a second endpoint (M2).
- Neo4j and Redis are not backed up. Neo4j is rebuildable from Postgres via the 20-step ETL (a rebuild, not a loss — but neither automated nor timed). Redis holds breaker state and cache, which reset acceptably.
- Backups are taken as the superuser, so the dump file is a complete, RLS-free copy of every tenant's data in one artifact. That is unavoidable (a tenant-scoped dump is an incomplete dump) but it makes the encryption key the single most sensitive secret in the system.
Neutral
- Bucket versioning is now enabled on
trust-relay-documentsandpeppol-evidence. Deletes become delete-markers; storage grows with overwrite churn. At current volumes, immaterial. - The backup toolchain is bash, not Python. Deliberate: it must run when the application cannot, including when the Python environment is the thing that is broken.
- Scripts are written for bash 3.2 (macOS ships it; no associative arrays). A backup script that only runs on the maintainer's Homebrew bash is not a backup script.
Honest limits — what this ADR does NOT claim
- There is no cryptographic hash chain on
audit_events, and this ADR does not create one. Immutability rests on the trigger + REVOKE + FK. The digest computed here proves a backup/restore round trip preserved the trail byte for byte; it does not prove the trail was never tampered with in place before the backup was taken. Only a real append-only hash chain (each row committing to its predecessor) would prove that, and a superuser who drops the trigger can still rewrite history and leave no cryptographic evidence. Tracked as a follow-up, not claimed here. The roadmap's M1-W2 acceptance criterion should be amended: it asks to "replay a hash chain" that does not exist. - A full DR drill was never timed end to end. RTO components are measured; the total is an estimate and is labelled as one.
- PITR is verified but not activated on the running dev stack (see above).
langfuse, Neo4j, and Redis have no logical backup. Stated, not hidden.
Alternatives Considered
Alternative 1: Logical backups only (no WAL archiving / PITR)
Nightly pg_dump, nothing else. Rejected. It delivers an RPO of 24 hours — up to a
full day of audit events, officer decisions, and SAR records permanently gone. ADR-0064 and
ADR-0069 already promise a durable 5-year immutable audit trail; shipping a 24-hour data-loss
window while those ADRs stand would be silently delivering less than the documentation
claims, which is the exact defect class ADR-0103 exists to eliminate. The brief explicitly
invited this option if PITR were disproportionate. It is not: it cost four Postgres flags and
one bind mount, and it is verified working.
Alternative 2: Managed Postgres with vendor-native PITR (RDS / Cloud SQL / Neon)
Delete this toolchain; let the provider handle it. Rejected for now, adopted later. It is
the right long-term answer and M2 should take it. But no deployed environment exists today,
choosing a provider is M2's decision to make, and leaving a CRITICAL finding open for the
weeks that takes is not acceptable. The toolchain is deliberately shaped so M2 can throw most
of it away — pg_run() is the only seam that needs to change.
Alternative 3: pgBackRest / WAL-G / barman
Purpose-built, battle-tested, with parallelism, incremental backups, and built-in retention.
Rejected as disproportionate right now: each adds a new binary, a new config format, and
a new operational surface to a single-container dev stack, to replace ~400 lines of bash whose
entire job is to be readable at 3am by someone who has never seen it. This is a genuine
trade and the scaling ceiling is real — the MinIO tar-and-encrypt step in particular does
not scale. Revisit at M2 alongside Alternative 2; pgBackRest is the obvious pick if we
self-host Postgres.
Alternative 4: Docker volume snapshots
docker run --rm -v postgres_data:/src alpine tar czf …. Rejected. A tarball of a
running Postgres data directory is not a consistent backup — it is a torn copy that may or
may not restore, and you find out which during the incident. It also cannot do
point-in-time recovery, and it is not portable across Postgres versions. It is fine for
preserving a broken cluster before a restore (the runbook uses it for exactly that, §1) and
for nothing else.
Alternative 5: Do nothing until M2 lands the deployment
Rejected. The finding is CRITICAL today, on a machine that today holds real case data,
real audit trails, and real PII. The probability of a laptop disk failing or a docker volume rm typo before M2 is not zero, and the consequence is the permanent loss of the artifact
every compliance claim in the product rests on.