0020 — Operational baseline: verified backups, config guards, statutory runbook¶
Status: accepted Date: 2026-08-03 Work order: E Relates to: 0001 (append-only), 0017 (dataset), 0019 (lawful basis)
Context¶
The kernel had 300 passing tests and no way to survive losing its database. It also had no written procedure for a data breach, which the DPPA requires be notified to the Authority immediately — not within a fixed window.
Two properties of this system make the ordinary answers insufficient.
Append-only means a restore must be verified, not merely attempted. There is no reconciliation path afterwards. If a restore silently loses rows or drops a constraint, the invariant is gone and nothing reports it, because every test in the suite exercises a database that already has the constraint.
Most self-hosted incidents are configuration mistakes, not attackers. A
published Postgres port or a copied .env with SEED_INGEST_ENABLED=true is
invisible to every functional test.
Decision¶
1. Backups carry a manifest, and the manifest is the deliverable¶
scripts/backup.sh writes an encrypted pg_dump and a manifest recording,
per table: row count, an md5 fingerprint over ids in deterministic order, and
the full set of constraint names per schema.
scripts/restore.sh regenerates the same manifest from the restored database
and diffs. Exit 0 only on an exact match.
Three consequences worth stating:
- Constraint names are compared. This is the most important of the three. A
dropped CHECK is worse than a failed restore. The kernel's correctness rests on
constraints — append-only grants, the
record_classpin, 0012's measured-basis sample requirement, 0019'slawful_basisrequirement — and a database missing one still starts and still serves traffic. - Fingerprints are over ids, never whole rows. jsonb key ordering is not stable across server versions; row-level hashing would produce a false mismatch on every Postgres upgrade and the check would be switched off within a month.
- The manifest query is duplicated verbatim in both scripts rather than shared. If the two ever drift, the verification is worthless — and a shared helper would let them drift while still agreeing with each other. Duplication is the safer failure mode here.
restore.sh refuses any target database whose name lacks restore, test, or
scratch, because it drops and recreates the target.
Two additions the implementation made to the above. The manifest also records
table grants, because append-only is enforced by kernel_app not holding
UPDATE or DELETE, and a grant is not a constraint. And each constraint line
carries its validated flag, because facts_lawful_basis_stated and
unit_conversion_measured_shows_sample are both NOT VALID on purpose; a
restore that quietly validated them would be a different database.
test/ops/restore.test.ts runs the real scripts inside the Postgres container
rather than on the host. pg_dump refuses to dump a server newer than itself,
so running them where the server lives is the only way to guarantee the client
and server majors match — and it means the test needs nothing installed beyond
Docker. Three of its cases tamper with the manifest and assert the verification
fails, because a comparison that cannot report a mismatch is indistinguishable
from no comparison at all, and that is the state a verification script drifts
into.
2. Configuration is asserted, not documented¶
test/ops/exposure.test.ts fails if:
- a data-tier service publishes a port
- anything binds
0.0.0.0explicitly .env.exampleomits a required variable.env.exampleshipsSEED_INGEST_ENABLED=true- no startup guard exists refusing production with seed ingest enabled
- any route resembles a bulk export
On the first of those the implementation settled on loopback rather than nothing
at all: docker-compose.yml now publishes 127.0.0.1:5433:5432, and the test
requires every data-tier port to carry an explicit loopback address. A bare
5433:5432 publishes on every interface the host has, which on most VPS images
is the open internet. Refusing to publish anything would have made local
development impossible and the rule would have been deleted rather than
followed.
The last one deserves note. We currently owe no s.16(4) disclosure-notification machinery because there is no export path — with view-only access, revoking access is sufficient; with exports, a lender becomes an independent holder and we owe a notification for every future correction, indefinitely. "Never add an export" decays as a memory and holds as a failing build.
3. Secret scanning is narrow on purpose¶
scripts/check-secrets.sh matches real credential formats and secret-shaped
assignments with real-looking values, and allowlists placeholders so
.env.example and docs stay useful.
A scanner that fires on every base64 string gets disabled within a week, and a disabled scanner is worse than none because it remains in CI implying coverage.
It runs in CI over everything git tracks, and as a pre-commit hook over what
is staged. The hook lives in .githooks/ under version control rather than in
whoever's .git directory happened to run the installer; scripts/install-hooks.sh
points core.hooksPath at it.
4. The runbook encodes statutory timing¶
docs/runbook.md §1 is the s.23 breach procedure. Three points are non-obvious
and easy to get wrong under pressure:
- Notify on belief, not proof, and immediately — containment does not wait for notification and notification does not wait for containment.
- Do not delete anything. s.36 makes unlawful destruction of personal data a criminal offence, and the append-only log is the evidence.
- The Authority decides whether data subjects are notified (s.23(2)), not us. Prepare the text; do not send it unilaterally.
§6 is a monthly verification table with a dated log, because s.20(2) requires regularly verifying that safeguards are effectively implemented. A one-off hardening pass does not satisfy it, and under s.33(2) the dated log forms part of a reasonable-care defence.
Alternatives considered¶
Managed backups from the host provider. Rejected as the only mechanism: it produces no manifest, verifies nothing, and leaves us unable to demonstrate a tested restore. Worth having in addition.
A generic secret scanner (gitleaks, trufflehog). Reasonable and worth adding
to CI later. Not sufficient alone: none of them knows that
SEED_INGEST_ENABLED=true in a production environment is a data-integrity
incident.
Down-migrations for rollback. Rejected, consistent with the append-only design. Rollback is a forward migration, and anything that would need to change record data is an incident rather than a deployment.
Consequences¶
- A restore can be demonstrated, not asserted. This is also the first thing a bank or government due-diligence review asks for.
- Adding an export endpoint now requires deleting a test and explaining why, which is the intent.
- The runbook has empty contact rows. That is a real gap, not a template placeholder — fill it before it is needed.
- Restore verification currently trusts a manifest we wrote ourselves. Once anchoring ships (work order I), completeness can be verified against published Merkle roots instead, which is independently strong. Recorded in the runbook as the intended upgrade.
NODE_ENVis now read byloadConfig, solely so production can be refused a seed-enabled configuration. Nothing else branches on it.