Go-live checklist
Run this before a FerroEHR deployment holds real patient data. Every item is something you set and then read back from the running system, because a separation that was configured and never verified is a separation nobody has seen work.
None of it makes a deployment compliant with anything. It is the set of technical properties the software can hold, checked rather than assumed. The organisational half stays yours.
- 1. Role separation, verified from outside
- 2. The deployment profile is
production, and the server boots - 3. Identifier scanning is on and strict
- 4. The pseudonym namespaces are declared
- 5. Backups, one per domain
- 6. The audit trail is on, durable, and kept long enough
- 7. Schema preparation runs on its own credential
- 8. The paperwork exists
- What this checklist does not cover
1. Role separation, verified from outside
Create the five domain roles, give each login role membership of exactly one, and point each pool at its own DSN:
[db]
url = "postgres://app_ehr:***@pg:5432/ferroehr"
demographic_url = "postgres://app_demographic:***@pg:5432/ferroehr"
linkage_url = "postgres://app_linkage:***@pg:5432/ferroehr"
migrate_url = "postgres://ferroehr_migrator:***@pg:5432/ferroehr"
Then check it:
ferroehr db verify
It exits 0 only when the database carries exactly this build’s migrations and no runtime role can read a domain it does not own, and it issues no DDL doing it. A breach names the role, the object kind and the schema-qualified object. The server runs the same check at every boot and refuses to serve on a breach, so this is the early warning rather than the only one.
The statements that create the roles, and what each one may reach afterwards, are Operations → Database roles and the threat model.
Warning
A login role that is a member of two domain roles crosses the boundary that
ferroehr db verifyreports as intact: the check measures the group roles. Give each login role one membership. The exception is the clinical login role, which also needsferroehr_appfor the local Audit Record Repository, andferroehr_appholds no grant in the other two domains.
- Five domain roles exist, each
NOINHERITand a member of no other. - Each login role is a member of exactly one domain role.
-
ferroehr db verifyexits 0.
2. The deployment profile is production, and the server boots
deployment_profile = "production"
Under production the server refuses to start while a separation is open and
not accepted by name. The refusal lists every open gap with its finding and its
remedy, so one boot tells you the whole list:
deployment_profile = "production" refuses to start: this deployment has not
made the separations production asserts. Make them, or accept each one by name
in deployment_accepts (which is then stated on every boot and on /rest/status):
- shared_credential: ...
The gaps are shared_credential, shared_cluster, open_subject_namespace,
audit_off and migrate_on_runtime_credential. shared_cluster is read from
pg_control_system().system_identifier on each pool rather than from the DSN
text, so two names for one cluster do not pass it.
A gap you have decided to run with goes in deployment_accepts by name. It is
then stated on every boot and on GET /ferroehr/rest/status, so it is run
rather than hidden.
-
deployment_profile = "production"and the server starts. - Every entry in
deployment_acceptsis a decision someone recorded, not a leftover. -
GET /ferroehr/rest/statusshows the profile and an accepted-gap list you recognise.
3. Identifier scanning is on and strict
[privacy.identifier_scan]
mode = "strict"
rules = ["fi-hetu", "gb-nhs-number", "nl-bsn", "no-fodselsnummer", "se-personnummer"]
Both are the defaults, so the check is that nothing turned them off. strict
refuses a clinical write carrying a value one of the active rules claims, and
names the RM path and the rule without echoing the value. Every rule the build
ships is active unless you narrow the list.
Add patterns for the identifier kinds no build can ship a rule for: local
medical-record numbers, payer references, a postcode paired with a house
number. Each is a Rust regular expression compiled at boot, and one that does
not compile is a boot error naming it.
If you are migrating existing content, run mode = "warn" first, read what it
reports, then switch to strict. Going live in warn means the scanner
observes and nothing acts.
-
mode = "strict". - The
ruleslist covers every jurisdiction whose identifiers your content can carry. - Local identifier kinds have a
patternsentry.
4. The pseudonym namespaces are declared
[privacy]
subject_namespaces = ["your-pseudonym-service"]
Empty is the default, and empty means the subject rule is out of force:
whatever a client sends becomes the clinical subject reference. Declaring a
namespace turns the rule on. From then on EHR_STATUS.subject.external_ref
must name a listed namespace and carry a UUID, the server stamps ehr.posture,
and a database trigger refuses a non-UUID subject whichever session writes it.
The name is a deployment fact: it identifies the service that mints your
pseudonyms, and no server can invent it for you. Where FerroEHR mints them
itself, link_as_subject uses the first declared namespace.
-
subject_namespacesnames at least one namespace. - The
open_subject_namespacegap no longer appears onGET /ferroehr/rest/status.
5. Backups, one per domain
Backups leave the database with none of the grants attached, so one dump per
cluster undoes the split. Configure one per domain, each with its own
credential and its own volume. On Kubernetes that is the backup values:
backup:
enabled: true
clinical:
existingSecret: ferroehr-backup-clinical
persistentVolumeClaim: ferroehr-backup-clinical
demographic:
existingSecret: ferroehr-backup-demographic
persistentVolumeClaim: ferroehr-backup-demographic
linkage:
existingSecret: ferroehr-backup-linkage
persistentVolumeClaim: ferroehr-backup-linkage
Each credential is a read-only role with BYPASSRLS, and it cannot be the
pool’s: every tenant-scoped table carries FORCE ROW LEVEL SECURITY, and
pg_dump refuses a table it would read through a policy. The chart refuses to
render when two domains name the same claim, which is the mistake that quietly
puts the join back together.
Three things the chart does not do: it provisions no storage, it prunes no old dumps, and no network policy of ours selects the dump pods.
- Three dumps, three credentials, three volumes.
- The demographic and linkage volumes have the narrower audience.
- A restore has been rehearsed, and the rehearsal is recorded.
- Retention and pruning of the dumps is somebody’s job, and they know it.
6. The audit trail is on, durable, and kept long enough
[audit]
enabled = true
purpose_header = "x-purpose-of-use"
[audit.store]
enabled = true
retention_days = 0 # or a value at or above your jurisdiction's floor
Auditing is on by default with the local store as the sink. What a production deployment adds is a copy off the box, because a local repository shares the blast radius of the database it audits:
[audit.syslog]
enabled = true
transport = "tls"
# or, for a RESTful ARR
[audit.fhir_feed]
enabled = true
Decide fail_mode deliberately. open logs a dropped record and serves the
request; closed refuses an auditable operation with 503 when the record
cannot be taken. closed is the posture that makes “no unaudited access” true.
Retention has a jurisdictional floor. Where one of your active identifier rules
names a jurisdiction with a registered floor, a shorter retention_days is a
boot error naming the floor. The Netherlands is the one registered today, at
1830 days, from the
Besluit vaststelling bewaartermijn logging.
A deployment elsewhere sets its own floor by hand and records why.
Verify the chain works before you need it:
SELECT * FROM audit.verify_audit_chain();
-
[audit] enabled = truewith a durable sink. - A copy is forwarded off the box, to a sink the server’s own identity cannot rewrite.
-
fail_modeis a decision, not a default. -
retention_daysis0or at or above the floor that applies to you. -
audit.verify_audit_chain()returns no rows on a healthy trail.
7. Schema preparation runs on its own credential
Preparing the schema spans every schema at once: the DDL of all five migration
sets under migrate = "apply", all five _sqlx_migrations bookkeeping tables
under "verify". No least-privilege runtime role can do either, ferroehr_app
included, so [db] migrate_url names the credential that can. Unset, it falls
back to [db] url, which is the single-credential posture.
-
[db] migrate_url(ormigrate_url_file) is set. -
migrate = "verify"if the schema is applied out of band, withferroehr db migraterun under the migrator DSN first.
8. The paperwork exists
- A data protection impact assessment has been carried out and signed off. The technical half is the DPIA page; the purposes, the lawful basis and the organisational measures are yours.
- The Art. 30 record of processing is written. The template is Records of processing.
- The processor agreements are in place for the database operator and every configured peer.
- Somebody has read what the software cannot do for a data subject.
What this checklist does not cover
- Your identity provider. Every authorization stage reads claims it asserted. Its token lifetimes, binding and revocation are outside the software.
- The platform. Host hardening, network policy, image admission and secret management are Cluster hardening.
- Encryption at rest. FerroEHR stores queryable JSONB and does not encrypt clinical payload before it reaches PostgreSQL. That is the database operator’s layer.
- Clinical governance. Validation checks structure, invariants and terminology bindings. It does not know whether a recorded fact is true.