Introduction
FerroEHR is a pure-Rust openEHR Clinical Data Repository (CDR): a headless, API-first server that stores and queries structured health records through a vendor-neutral REST API and the Archetype Query Language. This book is the user-facing guide — how to run it, configure it, talk to its API, query it, and load the templates that give your data shape. If you build clinical applications, operate healthcare infrastructure, or are evaluating an openEHR back end, you are in the right place.
The name comes from ferrum, Latin for iron: Rust is iron oxide, so the project went straight to the element. Say it “FER-ro-E-H-R”.
What openEHR gives you
openEHR separates clinical knowledge from software. The structure and meaning of clinical data — a blood-pressure reading, a medication order, a discharge summary — live in shared, computable models called archetypes and templates, authored by clinicians and modellers rather than baked into application code. Applications then store and retrieve that data through a standard API, against a shared Reference Model, so the same record is portable across every conformant system.
FerroEHR implements that standard natively. It speaks the openEHR REST API (ITS-REST Release-1.1.0), executes Archetype Query Language (AQL 1.1), and holds data as canonical openEHR compositions with full, indelible version history. There is no proprietary data format in the middle: what you commit is what you query and what you read back.
What makes this implementation different
- Conformance you can run yourself. The openEHR conformance catalogue is executed by a committed runner against a live server, over both canonical JSON and canonical XML, and the profile verdicts are computed from the per-case outcomes. The run records live in the repository, and every number on the Conformance page is derived from them at build time, never hand-typed.
- The openEHR specifications, generated directly from the official machine-readable models: the Reference Model, the Archetype Model (1.4 and 2.4), the serialization schemas, and the REST API contract (Release-1.1.0). openEHR’s own published terminology (3.1) ships embedded, byte-identical to upstream. A specification update is a regeneration.
- Two selectable specification generations. One configuration key,
spec_profile, chooses the whole generation set the server runs:development(Reference Model 1.2.0 with BASE 1.3.0 and LANG 1.1.0 — the default) orstable(the latest released generations, Reference Model 1.1.0 with BASE 1.2.0 and LANG 1.0.0). Seespec_profile. - One self-contained binary. No JVM, no language runtime, and a pure-Rust TLS stack, so there is nothing to provision beside PostgreSQL. The container image is shell-less and runs as non-root.
- PostgreSQL 18-native storage. Clinical documents are decomposed into an indexed node model with time-bounded, versioned rows; canonical openEHR JSON is stored verbatim so storage and API never disagree.
How the system is layered
FerroEHR is built in two layers. A specification layer is generated deterministically from openEHR’s published models: the Reference Model types, canonical JSON/XML serialization, the REST contract, and the AQL front end. On top of it sits the application: the server, the PostgreSQL-native storage, the AQL execution engine, validation, and security. The System architecture chapter walks through this in user terms; if you are new to openEHR itself, start with the openEHR primer.
Where to go next
- Wondering why this exists? Why FerroEHR exists is the project’s position: what openEHR is worth, what we commit to, what the licence lets you do, how building a product on it works, and why contributing back pays.
- Just want to try it? Getting started takes you from
docker compose upto a stored composition and an AQL result in a few minutes. - Deploying it? Installation covers Docker Compose, Kubernetes/Helm, building from source, and the configuration reference; Operations covers running it afterwards.
- Integrating an application? Using the API and Querying with AQL are the core reference for client developers.
- Modelling clinical data? Templates & validation explains how templates drive what the server will accept.
- Reviewing it for a deployment? Security and the Threat model state what is enforced and what is yours to enforce; the FerroEHR Viewer is the optional web UI over the same public API.
Note
FerroEHR began as a fork of EHRbase (by vitasystems and the Peter L. Reichertz Institute) and records that lineage in the labelled import commit at the root of its history, but it is an independent, from-scratch Rust implementation with no EHRbase code in this tree, and it is not affiliated with or endorsed by the EHRbase project. FerroEHR’s own code is source-available under the Business Source License 1.1; vendored openEHR material keeps its upstream terms: Apache-2.0 for the machine-readable artifacts, CC-BY-SA 3.0 for the specification text, and CC-BY-SA 3.0 and 4.0 for the clinical models (see Licensing & legal).
Why FerroEHR exists
This chapter is the project’s position: what makes openEHR worth implementing, what this implementation commits to, and what it offers the organisations that run it and build on it. Read it if you are deciding whether to depend on FerroEHR, or whether to contribute to it.
- openEHR is worth building for
- A specification is only as strong as the implementations you can run
- What we commit to
- Running FerroEHR commercially
- What we ask in return
- What contributing back can look like
- What this is not
openEHR is worth building for
openEHR does something almost nothing else in health IT does: it separates clinical knowledge from software, and then writes both down. What a blood pressure, a medication order or a discharge summary means lives in archetypes and templates authored by clinicians and modellers: published and computable. The Reference Model underneath them is specified. So is the query language, the REST interface, and the serialization, down to the shape of the JSON on the wire.
The consequence is determinism. Given the same template and the same composition, two conformant systems store the same record and answer the same AQL query the same way. A clinical record stops being one application’s private state and becomes data that outlives the application, the vendor and the procurement cycle. For a record that has to stay readable in twenty years, by software nobody has written yet, that is the whole game.
A specification is only as strong as the implementations you can run
A standard becomes real when there is something you can start with one command, read the source of, check against the specification yourself, and run for research, teaching or a non-profit deployment without a fee. Without that, an excellent specification stays an idea that only well-funded organisations can act on.
That is the gap this project set out to close: one complete, openly developed, source-available openEHR CDR whose conformance is measured and published.
What we commit to
- One licence for all of our own code, with no open-core tier. Role- and attribute-based access control, IHE ATNA audit, per-version digital signatures, change events and the viewer are in one repository under the Business Source License 1.1. Nothing is held back to be sold back to you. (Vendored openEHR material keeps its own upstream terms, and the spec crates that embed it say so in their own metadata; see Licensing & legal.)
- Every claim checkable. Conformance is executed by a runner against a live server, and the run records, per-case results and measured performance are committed to the repository. If a number appears on this site, the record it came from is in the tree, and a change that moves a verdict cannot land quietly.
- The specification is the authority. The normative openEHR text is vendored in the repository and cited decision by decision. Where we find it silent or self-contradictory, the finding is filed in public and reported upstream.
- The specification layer as reusable libraries. The generated openEHR model, the canonical codecs, the REST contract, the ADL engine and the AQL parser are published on crates.io as eight standalone crates, so the next Rust project does not have to re-model openEHR to get started. The five generated model crates are under the Apache License 2.0, the licence of the openEHR artifacts they are generated from, and need no licence conversation at all.
- Maintenance in the open. Public roadmap, public issue tracker, changelog-driven releases, signed artifacts, and a security policy with a private reporting channel.
- A compliance posture you can read before you buy. FerroEHR aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture, and an EHDS conformity self-assessment is on its roadmap. The compliance overview states which controls ship today, which are planned and under which issue number, and which obligations stay with the organisation running the software. It claims no certification and no conformity, because a product cannot hold either on its own.
Running FerroEHR commercially
FerroEHR is source-available under the Business Source License 1.1, which is not an OSI-approved open-source licence. One licence covers the whole repository, and no feature is held back for a paid tier.
| What you are doing | What you need | Why |
|---|---|---|
| Reading, building, modifying or redistributing the source | Free | The licence grants it without a fee and without asking anyone. |
| Development, testing, evaluation, prototyping | Free | All non-production use is granted. |
| Production use for Non-Commercial Purposes | Free | Personal use, academic or scientific research, teaching, and use by a non-profit organisation or public body that is not in the course of a business, does not deliver a service for payment, and is not for commercial advantage. |
| A hospital, clinic or care provider running it for its patients | Commercial licence | Delivering health care, or any other service for payment, is production use outside the grant. |
| A vendor or integrator, or any company running it in production | Commercial licence | Production use in the course of a business is outside the grant. |
| Offering it, or a work derived from it, to third parties as a hosted, managed or embedded service | Commercial licence | Excluded from the grant in every case, whoever you are. |
| Selling, sublicensing or otherwise distributing it for a fee | Commercial licence | Excluded from the grant in every case, whoever you are. |
The last two rows hold whatever else you are: they need a commercial licence even for an organisation the rows above would otherwise leave free. Each version becomes Apache 2.0 four years after it is published, so what ships today opens up on a published schedule. Licensing & legal carries the full picture, including every vendored third-party tree.
Building products on FerroEHR is welcome, and it is how standards reach
patients. Companies and care providers running FerroEHR are wanted here, and
the commercial licence is the normal path for them, on terms meant to make
those deployments and products possible on a sustainable footing. It is also
how the shared, conformant foundation gets maintained by the people who build
on it instead of every vendor rebuilding one in private. A commercial licence
is arranged with Cadasto B.V., the Licensor, which handles the business side
of FerroEHR: write to info@cadasto.com or use
https://www.cadasto.com/contact/. Technical questions go to the maintainer
named in
MAINTAINERS.md.
Talk to us early.
What we ask in return
Contribute back.
The licence does not oblige you. The arithmetic does:
- A private fork is the expensive option. Fork it and you inherit the whole maintenance surface: specification releases, security advisories, database upgrades, re-running conformance, and re-merging your changes at every release, forever. Upstream the same change and it is maintained once, by everyone who runs it.
- A defect found once should be fixed everywhere. In clinical software the validation gap you patched privately is still live in every other deployment of the same code. Sharing the fix is the difference between one organisation being safe and all of them being safe.
- Interoperability is a property of the population, not of any single implementation. Every conformance case contributed, and every ambiguity resolved in the open, makes it likelier that your system and the next one actually agree about a record.
When the same fix is made privately in five places, the standard is no stronger and five teams have paid for it. Nobody chose that outcome, and it is an easy one to avoid.
What contributing back can look like
You do not have to write Rust to make this project better:
- A bug report with the request that reproduces it, the most valuable thing most users will ever send.
- A conformance case for behaviour the catalogue does not cover yet, so the next release cannot regress it.
- A specification finding: the ambiguity you had to resolve in order to ship. We adjudicate those against the vendored text and report the genuine ones upstream to the openEHR Foundation.
- Documentation: a correction, deployment experience from your environment, or the paragraph that would have saved you two days.
- Measurement from your own hardware and workload. The instruments that produce our published numbers ship in the repository and run against any server, including one that is not FerroEHR.
- Code: fixes, features, connectors, packaging for your platform.
- Sponsorship, if your organisation depends on this and cannot spare engineering time.
Contributing is the practical starting point. Security issues have their own private channel; please use it rather than the public tracker.
What this is not
This is not an argument against commercial or proprietary software. A product with FerroEHR inside it, or a care provider running it for its patients, is a good outcome, and we would rather those succeed than not exist at all; a commercial licence is how they and this foundation support each other.
It is also not a claim to be the only good openEHR CDR. FerroEHR began as a fork of EHRbase and records that lineage in the labelled import commit at the root of its history; today it is an independent Rust implementation with none of that code left in the tree. FerroEHR is an independent implementation of the openEHR® specifications and is not affiliated with or endorsed by the openEHR Foundation.
And it is not a large team. The project is maintained by one person today, with the machine gates (conformance, the fidelity suites, the CI guards) standing in for review capacity a bigger group would have. The threat model and the repository’s own governance and maintainer documents say so plainly.
The same honesty covers how the code gets written: substantial parts of FerroEHR are built with AI coding tools, directed and reviewed by the maintainer, with every change held to the machine-enforced gates described above. The full statement — what that means, what bounds it, and what you can verify instead of trusting it — is the repository’s AI statement.
Maintained by Ruben Talstra and the FerroEHR contributors. If your organisation runs FerroEHR, or is thinking about building a product on it, we would like to hear about it early: open an issue or say hello on the tracker.
Getting started
This chapter takes you from nothing to a running server with a template loaded, a clinical composition stored, and an AQL query returning results in a few minutes, using Docker Compose. It is the fastest way to see FerroEHR work end to end and to get a feel for the API before reading the reference chapters. Everything here uses the built-in development credentials; do not use them outside local evaluation.
Warning
The steps below enable Basic auth with the throwaway user
ferroehr/ferroehr, leave role-based access control off (so that one user reaches every enabled surface, admin API included), and use a permissive CORS policy. This is a development configuration only. See Security and the configuration reference before exposing a server.
1. Start the stack
You need Docker with the Compose plugin (2.23.1 or newer). Download
docker-compose.yml (attached to every
release) into an
empty directory and start it:
docker compose up
This pulls and starts two services: the server (ferroehr) on port 8080,
and a preconfigured PostgreSQL 18 database. The server runs its schema
migrations automatically on first boot, so the database is ready as soon as it
reports healthy. Nothing else is needed: the server’s configuration travels
inside the Compose file, which also ships one Basic-auth user (ferroehr /
ferroehr, holding both the ADMIN and USER roles) so the API authenticates
out of the box.
Published ports bind 127.0.0.1 by default, so the stack is reachable from
this machine and not from the network. Three things are optional and stay down
until you ask for them: the viewer
(docker compose --profile viewer up, then http://localhost:3000), a
SeaweedFS S3 gateway for multimedia (--profile s3), and a ready-made Keycloak
identity provider for bearer-token auth (a second downloadable overlay,
docker-compose.keycloak.yml). See Docker Compose
for all of them.
2. Probe the status endpoint
The status endpoint is public and confirms the server is up:
curl http://localhost:8080/ferroehr/rest/status
It answers a small JSON document: status, server_version,
openehr_rest_api_version, a timestamp, the licence in force and the
declared deployment profile. All clinical API routes live under
the base path /ferroehr/rest/openehr/v1. Interactive OpenAPI documentation is
served at http://localhost:8080/ferroehr/rest/swagger-ui; sign in with the
API credential when the browser asks (the UI is served to authenticated users by
default, see rest.swagger_ui). If you have no
server yet, use the hosted sandbox: https://sandbox.ferroehr.eu opens the
viewer over a live CDR, and the same server’s Swagger UI is at
https://sandbox.ferroehr.eu/ferroehr/rest/swagger-ui. Both take the public
demo credentials ferroehr / ferroehr. The sandbox is a shared demo server
that resets every night, so treat anything you write there as temporary and
expect it to slow down under heavy use.
What is already in the sandbox
The nightly reset reloads a fixed demo dataset, so the server always answers with something to look at:
- 8 EHRs, each with a mixed record and its own patient. The sandbox
resolves patients through FerroPIX, the FerroHEALTH patient identity
source, as the compose quickstart does: the patients are
sandbox-patient-01…-08in the synthetic hospital domainurn:oid:2.999.1.1, and eachEHR_STATUSnames its patient by the pseudonym FerroPIX minted in the clinical domainurn:oid:2.999.7.1.POST /query/cohortwithsubjectsnamed in the hospital domain finds their EHRs through FerroPIX. Two of them carry a secondEHR_STATUSversion: patient 07 is not queryable and so never appears in AQL results, patient 08 is not modifiable and refuses writes with a409. - 183 compositions across 17 ADL 1.4 templates: sixteen from the
openEHR CKM (vital signs, problem and medicines lists, lab results,
referrals, an International Patient Summary, several statutory case-report
forms) and one terminology-binding template whose coded text resolves
against the terminology server running beside the CDR. Six compositions
have more than one version, so
LATEST_VERSIONandALL_VERSIONSdiffer on real data. - 233 ADL 2 archetypes and 5 ADL 2 templates, browsable under
extensions/v1/definition/archetype/adl2andopenehr/v1/definition/template/adl2. Two of the templates are ADL 2 source templates whose slots the server flattens against that archetype library, and the compositions committed from them came out of the server’s own…/examplegenerator. - 11 demographic parties (persons, roles, party relationships) and a FOLDER directory in every EHR whose items reference real compositions.
- 5 stored AQL queries under
eu.ferroehr.sandbox, all of which return rows:composition_index,blood_pressure,case_reports,composition_versions, andpatient_record(takes an$ehr_idparameter). Run one withGET /query/eu.ferroehr.sandbox::composition_index, or open the viewer’s query screen.
There are also three always-on, unauthenticated health endpoints: /health,
/health/liveness and /health/readiness; the last one reports each
dependency it checked. See Operations → Health probes.
3. Create an EHR
An EHR is the container for one subject’s records. Create one with a POST
(no body needed):
curl -u ferroehr:ferroehr -X POST -i \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr
The -i flag shows the response headers. On success you get 201 Created; the
new EHR’s identifier is in the ETag header (as the weak form
W/"<ehr_id>"), and Location points at the created resource. Copy the UUID;
the examples below refer to it as EHR_ID.
By default the response body is empty. Add -H 'Prefer: return=representation'
to have the server return the full EHR object instead, or
-H 'Prefer: return=identifier' for just the uid; either way
Preference-Applied echoes what the server honoured.
4. Upload a template
Before you can store a composition, the server needs the Operational Template
(OPT 1.4) that the composition conforms to. Templates are XML documents;
upload one with Content-Type: application/xml:
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/xml' \
--data-binary @my-template.opt \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4
A successful upload returns 201 Created. If you do not have a template to
hand, the openEHR community publishes example OPTs (for instance the
Vital Signs templates used in the openEHR training material), and the
international Clinical Knowledge Manager is the
source for the archetypes they are built from. List what is loaded, and inspect
a template’s derived WebTemplate (a JSON description convenient for building
forms), with:
# List templates
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4
# Fetch the WebTemplate for one template
curl -u ferroehr:ferroehr \
-H 'Accept: application/openehr.wt+json' \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4/my_template_id
See Templates & validation for the full template lifecycle and the WebTemplate/FLAT/STRUCTURED formats.
5. Commit a composition
A composition is one clinical document, stored inside an EHR and validated
against its template. Post the composition JSON (its archetype_details name
the template it belongs to):
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
-H 'Prefer: return=representation' \
--data-binary @my-composition.json \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition
On success you get 201 Created and (because of Prefer: return=representation)
the stored composition in the body, now carrying a server-assigned version
identifier in its uid. If the composition does not conform to its template you
get 422 Unprocessable Entity with the validation errors; a malformed request
gets 400 Bad Request. The composition walkthrough in
Resource walkthroughs covers update and delete,
which use the If-Match header for optimistic concurrency.
6. Query with AQL
Now query across the data with the Archetype Query Language. The simplest query lists the EHR ids the server holds:
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
-d '{"q":"SELECT e/ehr_id/value FROM EHR e"}' \
http://localhost:8080/ferroehr/rest/openehr/v1/query/aql
The response is a RESULT_SET: a columns array describing each selected value
and a rows array of result tuples. To pull values out of the compositions you
committed, select by their archetype path, for example every systolic blood
pressure above 140:
curl -u ferroehr:ferroehr -H 'Content-Type: application/json' -d '{
"q": "SELECT o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic FROM EHR e CONTAINS COMPOSITION c CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2] WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140"
}' http://localhost:8080/ferroehr/rest/openehr/v1/query/aql
Querying with AQL is the full language guide — parameters, stored queries, version scope, terminology, pagination, and the supported feature set.
7. Explore the API interactively
Open http://localhost:8080/ferroehr/rest/swagger-ui (signing in with the API
credential when the browser asks) to browse and try every endpoint from your
browser. The UI’s spec selector carries one entry per API
family: the standardised openEHR groups (EHR, Query, Definition, Demographic,
Admin) and the server’s own extensions (status & management, terminology, party
relationships, messaging, event subscriptions, audit, SMART discovery), plus FerroEHR: Complete surface last, which
is the whole server in one document. Every entry is filtered from that same
document, which the server generates from its own handlers, so nothing here can
drift from the routes it actually serves. When authentication is enabled the
“Authorize” dialog shows the one scheme the server is configured for (HTTP
Bearer/JWT when OIDC is set up, otherwise HTTP Basic). The hosted sandbox runs
the same UI at https://sandbox.ferroehr.eu/ferroehr/rest/swagger-ui if you
want to try it before running anything locally — and
https://sandbox.ferroehr.eu itself opens the viewer
on that server, with the demo credentials ferroehr / ferroehr.
Next steps
- Installation — running it for real (Compose, Kubernetes, or from source) and the configuration reference.
- Using the API — the per-resource reference with headers, status codes, and versioning.
- Operations — migrations, probes, observability and upgrades, plus the admin & messaging APIs.
- Concepts — the openEHR model and how FerroEHR is built, if the terms above were unfamiliar.
Installation
FerroEHR is one self-contained binary that connects to a PostgreSQL 18 database. There is no application server to install and no language runtime to provision: the binary links a pure-Rust TLS stack and needs no OpenSSL and no JVM, so all you choose is how to run it and where the database lives. This part covers the three paths and the full configuration surface.
- Try it in Codespaces: one click boots the published images in a GitHub Codespace in your browser, with the Swagger UI and the viewer forwarded. Nothing to install. Made for evaluating FerroEHR; a real deployment uses one of the paths below.
- Docker Compose: the fastest way to run the server plus a preconfigured PostgreSQL 18, for development and evaluation. One downloadable file that pulls the published images and needs no configuration. Optional viewer, OIDC, and observability overlays.
- Kubernetes & Helm: the production path, a hardened non-root workload that connects to an externally managed PostgreSQL 18. Its companion, Cluster hardening, covers what the chart cannot do for you.
- From source: building the binary yourself with the pinned Rust toolchain, and running the test suite.
- Configuration reference: how configuration loads and what every key means, split by area: server, database and telemetry, authentication and access, integrations, audit and subject proxy, and the CLI and production checklist.
What every path has in common
One configuration file, with environment overrides on top. The server reads
a single ferroehr.toml covering every subsystem; FERROEHR__* environment
variables override individual keys, and repeatable --set key=value flags
override those. Anything the environment grammar cannot spell (the Basic-auth
user store, which is an array of tables) is file-only. ferroehr config default writes an annotated template with every key at its default, and
ferroehr config check validates the result without touching the database.
The schema is the binary’s, and who applies it is a choice. By default
(db.migrate = "apply") the server applies its embedded migrations at boot, so
an empty database self-provisions. Setting db.migrate = "verify" makes the
server issue no DDL at all (it checks the schema and refuses to start if it is
not this build’s) which lets the runtime role hold no DDL rights. Something
else then runs ferroehr db migrate under a migrator role first. That check
still reads every schema’s migration state, so a least-privilege deployment
also sets db.migrate_url to the credential that can. Both postures are laid
out in Operations → Applying migrations.
A deployment declares what it may hold. The top-level
deployment_profile key is sandbox by default: the server starts whatever
separations are missing and names every one of them on the banner, in the log
and on GET /management/status. Set it to production and the server
refuses to start while a separation is open and not accepted by name in
deployment_accepts. Read
deployment_profile before you point a
deployment at real patient data.
Choosing a specification generation
One top-level key, spec_profile, selects which openEHR specification
generation set the deployment runs: development (the default: Reference
Model 1.2.0 with BASE 1.3.0 and LANG 1.1.0) or stable (the latest released
generations: Reference Model 1.1.0 with BASE 1.2.0 and LANG 1.0.0). It is a
single coupled choice, and stable → development is always safe while the
reverse is not, so decide it before you commit clinical data. The full
semantics, defaults, and the direction contract are in
spec_profile.
Note
A Clinical Data Repository stores PHI. In production the database must be an externally managed, backed-up, point-in-time-recoverable PostgreSQL 18, never a throwaway sidecar. The Kubernetes chart deliberately ships no in-cluster database for this reason.
Try it in Codespaces
The fastest way to try FerroEHR is a GitHub Codespace: one click boots the published images in your browser, with nothing installed on your machine.
What you get
Creating a Codespace on the FerroEHR repository starts a container that pulls
the published quickstart images and runs docker compose up for you:
- the FerroEHR server with a preconfigured PostgreSQL 18,
- the viewer,
- the Swagger UI for the full REST API.
The stack boots automatically. When the terminal prints FerroEHR is up.,
open the PORTS panel and follow the forwarded ports:
| Port | What it serves |
|---|---|
8080 | the REST API, with the Swagger UI at /ferroehr/rest/swagger-ui |
3000 | the viewer |
Sign in with the quickstart credentials, ferroehr / ferroehr. From there
the Getting started walkthrough applies unchanged:
create an EHR, upload a template, commit a composition, and query it back
with AQL, either from the Swagger UI or with curl in the Codespace
terminal.
What it is, and what it is not
The Codespace is a tester sandbox and always runs the published release
images pinned in the standalone docker-compose.yml. It does not build the
checkout you opened it on: the COMPOSE_FILE environment variable inside the
container pins every docker compose command to that file, so the
repository’s development override (which switches to from-source builds) does
not apply. To develop FerroEHR itself, use a local checkout as described
under Repository development.
The Codespace runs on your own GitHub account. The smallest machine type (2 cores, 8 GB) is enough, and GitHub’s free monthly Codespaces allowance covers a long evaluation. Stop or delete the Codespace when you are done; a stopped Codespace restarts the stack automatically on resume.
The hosted sandbox
A public demo runs at https://sandbox.ferroehr.eu as the second
zero-install path: no GitHub account needed, point any REST client at it
with the demo credentials ferroehr / ferroehr. The Swagger UI is at
https://sandbox.ferroehr.eu/ferroehr/rest/swagger-ui. So everyone knows
what it runs on and what to expect from it:
| Compute | one dedicated Hetzner Cloud CX33 (4 shared vCPU, 8 GB RAM, 80 GB NVMe SSD; Nuremberg, eu-central; €8.49/month net), running the published container images behind a Caddy proxy that terminates TLS |
| Database | a second, dedicated CX33 running the published ferroehr-postgres image (PostgreSQL 18), reachable only over a Hetzner private network — no public database port |
| Data durability | none by design: every night around midnight UTC the whole store is wiped and fresh demo data is seeded |
The server does not scale to zero, so there is no cold start; it is still a single small machine shared by every visitor. It is a demo, never a place for real data.
The sandbox image is pinned to the latest release tag, so it always runs a released FerroEHR rather than a development snapshot.
Create, change and delete whatever you like: the nightly reset returns the sandbox to a small seeded corpus (a handful of demo EHRs with example compositions from published CKM templates), so nothing you do needs cleaning up and nothing you store survives the night.
If the stack is not up
The boot log is in the terminal that ran start-stack.sh. To restart the
stack by hand:
bash .devcontainer/start-stack.sh
docker compose ps shows the three services; the server is healthy when
curl http://localhost:8080/health answers 200.
Docker Compose
Docker Compose is the quickest way to run FerroEHR together with a
preconfigured PostgreSQL 18, for local development and evaluation. The
quickstart is one file and zero configuration: download
docker-compose.yml, run docker compose up, and the published images are
pulled and started: no repository checkout, no bind mounts, no environment
variables. This chapter describes the three published images, the services, the
authentication posture the quickstart ships with, the optional profiles and
overlays, and the variables that tune them. For a step-by-step first run, see
Getting started.
Note
This chapter applies equally to the standalone
docker-compose.ymldownloaded into an empty directory and to a baredocker compose upin a repository checkout — both run the published images. Building from source is an explicit opt-in (-f docker-compose.yml -f docker-compose.dev.yml) — see Repository development below.
- The three images
- Bringing up the stack
- Container engines: Docker and Podman
- The isolation posture
- The quickstart’s authentication posture
- The patient identity source (FerroPIX)
- The local EHR index
- The OIDC variant (Keycloak overlay)
- The terminology overlay (FerroTERM)
- Optional services (Compose profiles)
- Port already in use?
- Connecting to the database
- Variables the compose files read
- Repository development
- Build provenance
- Observability overlay
- Next
Note
The quickstart file carries the server configuration inline (a Compose
configsentry withcontent:), which requires Docker Compose 2.23.1 or newer. Check withdocker compose version.
The three images
FerroEHR publishes three container images to GHCR:
| Image | Contents |
|---|---|
ghcr.io/ferrohealth/ferroehr | The ferroehr server binary on a distroless, non-root, shell-less multi-arch base (amd64 + arm64). Configured by a mounted TOML file and/or FERROEHR__* environment variables. |
ghcr.io/ferrohealth/ferroehr-postgres | postgres:18.6 (with Debian security updates applied at image build) plus init scripts that pre-create the application login role, the eight NOLOGIN group roles (ferroehr_migrator, ferroehr_clinical, ferroehr_clinical_reader, and the five domain roles ferroehr_clinical, ferroehr_party, ferroehr_clinical_reader, ferroehr_party_reader, ferroehr_linkage), the database, the schemas (clinical, ext, audit) and the one extension the schema needs, btree_gist, so the app role never needs superuser. |
ghcr.io/ferrohealth/ferroehr-viewer | The viewer, a standalone web application that talks to the CDR strictly over ITS-REST. Optional; see the viewer profile below. |
Each image is published under several tags:
| Tag | Published from |
|---|---|
X.Y.Z, X.Y | every release |
latest | the newest release |
main | every push to the default branch |
sha-<commit> | every push, for exact pinning |
The quickstart Compose file pins the exact release version it shipped with
on all three images, so a downloaded file always runs one known-good, mutually
compatible set; a CI guard (scripts/checks/compose-image-tags.sh) fails the
build whenever those pinned tags disagree with the version being released, so
they cannot be forgotten at a cut. To run something else, set the image
variables in the table below.
The role the server connects as in the quickstart owns the database and is a
member of ferroehr_migrator and ferroehr_clinical, which is what lets it apply
migrations at boot, and of ferroehr_clinical and ferroehr_party, so the
schema separation is exercised on one credential. That single credential is
deliberate: one container with one DSN cannot demonstrate the credential
separation honestly.
A least-privilege deployment sets db.migrate = "verify", runs
ferroehr db migrate out of band under the migrator DSN, and serves on a
narrow runtime credential. It also has to name the credential that prepares
the schema, in [db] migrate_url: preparation reads all five
_sqlx_migrations bookkeeping tables even under verify, which no
least-privilege role can do, ferroehr_clinical included. Unset, migrate_url
falls back to [db] url, which is what the quickstart runs. See
Operations → Which credential prepares the
schema and
Applying migrations.
The PostgreSQL image is init-scripts only: it creates roles, schemas and
btree_gist, and bakes in no migration state. That extension is the only one
the schema needs, and the server installs it itself at boot
(CREATE EXTENSION IF NOT EXISTS btree_gist), so a plain PostgreSQL 18 with a
superuser-capable migration credential serves just as well. The server owns the schema content
and applies its migrations idempotently at every boot, so a fresh database
self-provisions and a restart is a no-op.
Note
PostgreSQL init scripts run only when the data volume is empty. If you see startup notices like
skipping role creation (no CREATEROLE privilege)orroles absent, your volume predates the image’s role setup (or you are running a plainpostgresimage): either recreate the volume (docker compose down -v, which destroys data) or create the group roles once by hand as a superuser (Operations has the statements). The server runs fine either way: the grants are a defense-in-depth layer, not a functional requirement.
Bringing up the stack
Download docker-compose.yml (attached to every
release) into an
empty directory and start it:
docker compose up
This pulls and starts the core services (no profile needed): the database and the server below, and the patient identity source described in The patient identity source (FerroPIX).
ferroehr-postgres: the database image, with a named data volume, apg_isreadyhealthcheck,pg_stat_statementspreloaded, and a modest tuning floor (shared buffers, WAL size, work memory, lz4-compressed WAL full-page images — which cut per-commit WAL volume roughly in half — and a larger/dev/shmthan Docker’s default, which otherwise starves parallel workers).ferroehr: the server, which waits for the database to report healthy (depends_on: condition: service_healthy), then boots, migrates, and serves on port 8080. Its healthcheck is the binary’s ownhealthchecksubcommand, because the image has no shell.
The API is then at http://localhost:8080/ferroehr/rest/openehr/v1, with
Swagger UI at http://localhost:8080/ferroehr/rest/swagger-ui (the browser asks
for the API credential; rest.swagger_ui chooses who may read it).
Important
Every published port binds the loopback interface by default (
127.0.0.1:HOST:CONTAINER), and that is a security property rather than a style choice: a published port is DNAT’d by rules Docker inserts ahead of the host firewall’s own chains, so aufw deny 8080does not stop a port published on0.0.0.0. To reach the stack from another machine, name the interface explicitly (FERROEHR_BIND_HOST=0.0.0.0 docker compose up, or a single address). The database publishes no host port at all — see Connecting to the database.
Note
Third-party base images are pinned by digest (
name:tag@sha256:…), not by a mutable tag, so a pull always resolves the exact same image. Each service also declares a CPU and memory ceiling (deploy.resources.limits), so a local stack cannot exhaust the host; the server’s and the database’s are overridable for constrained machines.
Warning
PostgreSQL 18’s official image stores data in a major-version subdirectory, so the data volume mounts at
/var/lib/postgresql(the parent), not the pre-18/var/lib/postgresql/data. The bundled Compose file already does this correctly; keep the convention if you adapt it.
Container engines: Docker and Podman
The quickstart runs on Docker and on Podman. Both were verified first-hand on
2026-08-24: the core stack, the viewer and s3 profiles, and both
overlays boot to healthy under podman compose exactly as under
docker compose, with the same commands:
podman compose up -d --wait
Podman-specific notes:
podman composedelegates to an external Compose provider. With Docker’s own Compose CLI installed (the common case on a machine that has ever run Docker Desktop) you get identical Compose behaviour; with the Pythonpodman-composepackage, flag coverage differs — prefer the Docker Compose provider.- The default
podman machineon macOS ships with 2048 MiB of memory. That is enough for the quickstart and both overlays, but not for building the server image from source inside the VM (a single spec-crate compile at release optimization holds more than that). If you want the build-from-source developer path under Podman, resize the machine first:podman machine set --memory 8192. - Building from source under Podman (Buildah) is not part of the verified quickstart surface; the supported quickstart path is the published images.
The repository’s own CI and measurement lanes run Docker; the compose-driven
scripts (scripts/conformance.sh, the deployment probe, the UI journey
battery) are exercised against Docker only.
The isolation posture
Every service in every committed compose file drops all Linux capabilities
and adds back only what its entrypoint provably needs, sets
no-new-privileges:true, bounds its file descriptors, restarts unless stopped,
and keeps Docker’s default seccomp profile (the posture is the absence of an
unconfined override, so that absence is what a CI guard checks). No service is
privileged and none mounts the Docker daemon socket.
Most services additionally run a read-only root filesystem with a tmpfs for the
paths that must be writable. Two do not, and each says why in place: the S3
gateway writes its volume store, and the server container hits a real mutual
exclusion rather than an oversight: Compose refuses an inline config in a
read-only service, and that inline config is what makes this file standalone. A
deployment that mounts its configuration from a file instead can add
read_only: true and a tmpfs at /tmp. On Kubernetes the conflict does not
arise: the configuration arrives as a mounted ConfigMap and the chart sets
readOnlyRootFilesystem: true.
The quickstart’s authentication posture
The server configuration travels inside the Compose file and is written for evaluation, not for production:
- Basic auth with one user:
ferroehr/ferroehr(stored as an Argon2id hash), carrying theADMINandUSERroles. - RBAC disabled: so any authenticated caller may use every enabled
surface, including the admin API. The
ADMIN/USER/READONLYseparation is switched on by setting[authz.rbac] enabled = trueand giving each user an explicitroleslist; see Security. - Admin API enabled: so the optional viewer’s panels work.
- Management introspection enabled, with
infoandmetricsatprivate,prometheusatpublic, andenvandloggersatadmin_only. - Permissive CORS: any origin may call the API from a browser.
- No TLS: plain HTTP on port 8080.
- The
sandboxdeployment profile, declared in the inline configuration: the binary’s default, stated so the file says what it runs. The server names every separation it has not made on the boot banner, in the log and onGET /management/status, and the viewer raises a notice saying the deployment must not hold real patient data. That is the quickstart telling the truth about itself, not a fault. Seedeployment_profile.
Warning
These are development credentials and development defaults. Before exposing a server, replace the user store (or point it at an identity provider), turn RBAC on, restrict CORS, close the admin API, and terminate TLS in front of the server. The configuration reference and Security cover each of these.
To change any of it without editing the Compose file, add FERROEHR__*
variables to the ferroehr service’s environment: block; the environment
layer wins over the inline file. The Basic-auth user store is the one setting
the environment cannot express (it is an array of tables), so replacing the
users means editing the inline config or mounting your own TOML file.
The patient identity source (FerroPIX)
FerroEHR keeps no patient-to-record cross-reference of its own by default. It
asks an IHE patient identity source over PIXm (ITI-83) which pseudonym the
clinical domain holds for a patient
([ehr_index]). The quickstart runs
FerroPIX as that source, pinned by tag and image-index digest (CI runs the
config check of every pinned image over its inline configuration), with four
services beside the server:
identity-issuer: a development OpenID Provider (NAV’smock-oauth2-server) that signs any client credentials request, athttp://localhost:8180/quickstart. It is not for anything real.ferropix: the PIX Manager, athttp://localhost:8090/fhir. Its configuration declares the pseudonymisation domainurn:oid:2.999.7.1with FerroEHR’s clientferroehras its consumer, so it mints a pseudonym the first time FerroEHR asks, and a synthetic identifier domainurn:oid:2.999.1.1fed by the clientquickstart-source.ferropix-postgresandferropix-key: FerroPIX’s own database, and a one-shot job that writes its sealing key into a volume, so the file carries no key.
The server’s [ehr_index] names the endpoint and the grant, and
[privacy] subject_namespaces declares the clinical domain. The server reads
FerroPIX’s CapabilityStatement at boot and does not start until it answers.
The issuer, FerroPIX and FerroEHR share the issuer’s network namespace. A
server accepts a plain-http issuer only on loopback
(RFC 8414 §2), and the
mock issuer writes the host it was asked on into every token, so all three
reach it at http://localhost:8180. The issuer service therefore publishes the
ports of all three and answers to the names ferroehr and ferropix on the
compose network.
To register a synthetic patient and ask for the pseudonym by hand:
SRC=$(curl -s -d grant_type=client_credentials -d client_id=quickstart-source \
-d client_secret=any -d 'scope=system/Patient.cud' \
http://localhost:8180/quickstart/token | jq -r .access_token)
curl -s -X PUT -H "Authorization: Bearer $SRC" \
-H 'Content-Type: application/fhir+json' \
'http://localhost:8090/fhir/Patient?identifier=urn:oid:2.999.1.1%7CH-1' \
-d '{"resourceType":"Patient","identifier":[{"system":"urn:oid:2.999.1.1","value":"H-1"}],"name":[{"family":"Synthetic"}]}'
Any conformant PIX Manager can take FerroPIX’s place: point pixm_url,
assigning_authority and [ehr_index.oauth] at it.
The local EHR index
The other supported posture keeps the subject-to-EHR cross-reference in
FerroEHR ([ehr_index]): EHR_STATUS.subject
names a patient by its own namespace and identifier, and
GET /ehr?subject_id=…&subject_namespace=… finds the EHR by them. One
variable selects it, with no copy of the configuration to make:
FERROEHR_EHR_INDEX=local docker compose up
The server then runs the file’s ferroehr-config-local block, which differs
from ferroehr-config in [ehr_index] and [privacy] alone: the local map,
and no pseudonym namespace, so a subject reference is stored as the client
sends it:
curl -u ferroehr:ferroehr -X POST -i -H 'Content-Type: application/json' \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr -d '{
"_type": "EHR_STATUS",
"archetype_node_id": "openEHR-EHR-EHR_STATUS.generic.v1",
"archetype_details": {"_type": "ARCHETYPED", "rm_version": "1.1.0",
"archetype_id": {"_type": "ARCHETYPE_ID", "value": "openEHR-EHR-EHR_STATUS.generic.v1"}},
"name": {"_type": "DV_TEXT", "value": "EHR Status"},
"subject": {"_type": "PARTY_SELF", "external_ref": {"_type": "PARTY_REF",
"namespace": "benchmark", "type": "PERSON",
"id": {"_type": "GENERIC_ID", "value": "1793131381", "scheme": "benchmark"}}},
"is_queryable": true, "is_modifiable": true}'
curl -u ferroehr:ferroehr -i \
'http://localhost:8080/ferroehr/rest/openehr/v1/ehr?subject_id=1793131381&subject_namespace=benchmark'
The identity services still start, and nothing asks them. Start without the variable to return to FerroPIX; the EHRs created on the local index keep their subject references, and the database refuses no stored row.
The OIDC variant (Keycloak overlay)
A second standalone file adds a ready-made identity provider, so you can
exercise bearer-token authentication without registering a client anywhere.
Download docker-compose.keycloak.yml from the same release, beside the base
file, and stack the two:
docker compose -f docker-compose.yml -f docker-compose.keycloak.yml up
That starts Keycloak with a small demo realm (ferroehr) defined inline and
points the server’s bearer validation at it. Basic auth from the base file keeps
working, so the server then advertises Basic, Bearer.
The server accepts a token issuer only over HTTPS, or plain HTTP on a loopback
host, so Keycloak serves the issuer over TLS at
https://keycloak.localhost:8443/auth/realms/ferroehr. The containers resolve
keycloak.localhost through a network alias on the compose network, and a
browser resolves every *.localhost name to the loopback address, where the
published port lands, so both sides use one issuer URL with no hosts entry. On
the first up, a one-shot keycloak-tls service generates a development CA
and a certificate for that name into the keycloak-tls volume and discards
the CA’s private key; the server ([auth] ca_file) and the viewer
([auth.oidc.tls] trust_roots_file) trust that CA beside the public ones.
docker compose down --volumes deletes the volume, and the next up
generates a new CA. Keycloak also answers plain HTTP on port 8081 for the token
call below, and the tokens it mints there name the HTTPS issuer. Keycloak’s
access tokens carry typ: JWT, so the overlay sets
FERROEHR__AUTH__TOKEN_PROFILE=jwt.
Start the viewer too and sign in at http://localhost:3000 as any user below:
docker compose -f docker-compose.yml -f docker-compose.keycloak.yml --profile viewer up
The viewer takes the authorization-code flow with PKCE through the
ferroehr-viewer client and calls the server with the user’s token. Your
browser does not trust the development CA, so it warns once on the Keycloak
page: accept the warning, or trust the CA, which
docker compose cp keycloak:/opt/keycloak/conf/tls/ca/ca.crt . copies out. A
browser that does not resolve *.localhost needs 127.0.0.1 keycloak.localhost
in its hosts file.
Every route checks SMART on openEHR scopes, so the realm puts them in every
token: user/composition-*.cruds, user/template-*.cruds and
user/aql-*.cruds grant a user every resource family, the service client gets
their system/ twins, and __ferroehr.platform is the product scope the ADMIN
and DEMOGRAPHIC APIs need. The roles below then decide.
The overlay also turns RBAC on, deliberately: an identity provider whose roles decide nothing is a login screen, not authorization. So the realm ships four users whose roles are load-bearing:
| User | Password | Realm roles | What it demonstrates |
|---|---|---|---|
ferroehr | ferroehr | ADMIN, USER | the full surface, admin API included |
clinician | clinician | USER | the clinical API without the admin routes |
auditor | auditor | USER, READONLY | reads succeed, every write is refused 403 |
nobody | nobody | none | an authenticated caller with no grant |
Two confidential clients are defined, and both put ferroehr, the audience the
server checks, in every access token:
| Client | Secret | Grants |
|---|---|---|
ferroehr-viewer | ferroehr-viewer-quickstart-secret | the authorization-code flow with PKCE (S256), redirecting only to the viewer’s origin on port 3000, and the password grant so a token can be fetched by curl |
ferroehr-service | ferroehr-service-quickstart-secret | the client-credentials grant only |
Fetch a user’s token and call the API with it:
TOKEN=$(curl -s -d client_id=ferroehr-viewer -d client_secret=ferroehr-viewer-quickstart-secret \
-d username=ferroehr -d password=ferroehr -d grant_type=password \
http://localhost:8081/auth/realms/ferroehr/protocol/openid-connect/token \
| jq -r .access_token)
curl -H "Authorization: Bearer $TOKEN" -X POST -i \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr
A service with no user behind it takes the client-credentials grant with
ferroehr-service. Its service account holds USER, so the token reaches the
clinical API and the admin routes refuse it with 403. Its system/aql-*
grant counts because the overlay names the client in [smart] backend_clients
(FERROEHR__SMART__BACKEND_CLIENTS):
TOKEN=$(curl -s -d client_id=ferroehr-service -d client_secret=ferroehr-service-quickstart-secret \
-d grant_type=client_credentials \
http://localhost:8081/auth/realms/ferroehr/protocol/openid-connect/token \
| jq -r .access_token)
Warning
The realm, the client secret, the user passwords and the CA are demo values, served by a Keycloak in
start-devmode. For a real deployment, drop this overlay and point[auth](orFERROEHR__AUTH__ISSUERandFERROEHR__AUTH__AUDIENCE) at your own issuer over HTTPS.
The terminology overlay (FerroTERM)
FerroTERM is the Ferro family’s
FHIR terminology server: R4, R4B and R5 endpoints over a precomputed index, no
JVM, no database, one distroless image. The overlay
docker-compose.terminology.yml starts it beside the CDR and switches the CDR’s
terminology server on, so archetype value-set bindings resolve at commit and
AQL TERMINOLOGY() expands through it. It also switches on the read-only
/terminology/* extension API (FERROEHR__TERMINOLOGY__API_ENABLED, off by
default): a term, subsumption or value-set call there for a terminology the
bundled openEHR terminology does not carry is answered by FerroTERM, while
listing and describing terminologies stays with the bundle:
docker compose -f docker-compose.yml -f docker-compose.terminology.yml up
curl 'http://localhost:8090/r4b/metadata?mode=terminology'
It is an overlay rather than a profile for the same reason as the Keycloak and
observability ones: enabling it has to change the CDR’s environment, which a
profile cannot do. FerroTERM’s image carries its own health probe (the binary’s
healthcheck subcommand, since the distroless image has no shell), and the CDR
is declared to start only once that probe reports healthy, so docker compose up --wait returns with both serving.
Out of the box FerroTERM serves the licence-free shaped seed this repository
ships (two code systems and two value sets under the reserved example.test
domain, the content the conformance lane binds to), mounted read-only straight
out of the ferroehr image. No licensed terminology is distributed with
FerroEHR or FerroTERM. To serve a release you hold a licence for, build its
index once into the named volume and tell the server to open it:
FERROTERM_RF2=/path/to/SnomedCT_Release.zip \
docker compose -f docker-compose.yml -f docker-compose.terminology.yml \
--profile terminology-build run --rm ferroterm-build
FERROTERM_INDEX=/data/index \
docker compose -f docker-compose.yml -f docker-compose.terminology.yml up
The archive is mounted read-only and unpacked to a tmpfs that dies with the build container. FerroTERM’s loading page covers the other code systems and their build flags.
Three things the operator owns:
- The code-system licence. SNOMED CT needs an Affiliate Licence (free in Member countries, see snomed.org/get-snomed); LOINC, ICD and RxNorm have their own terms. Every surface that shows SNOMED CT content carries the notice its licence prescribes (clause 8.3.1) and the release version and date.
- Who can reach the server. The overlay publishes FerroTERM on the loopback
interface at port 8090 (
FERROEHR_TERMINOLOGY_PORT) so you can query it while developing. FerroTERM has no authentication and no rate limit of its own; a deployment that serves other machines puts a reverse proxy with both in front, or removes theports:block and lets the CDR be the only caller, which is what the hosted sandbox does. - FerroTERM’s licence. BUSL-1.1 from the same Licensor as FerroEHR: non-commercial production use is free, any other production use needs a commercial licence. Its README says which is which.
The CDR keeps its shipped fail-open posture: a binding the server cannot resolve
is accepted. FERROEHR__TERMINOLOGY__SERVER__FAIL_ON_ERROR=true turns an
unreachable server into a 422 refusal instead; the
terminology page
explains both. A code the server resolves as outside its value set is refused
with 422 under either posture.
Optional services (Compose profiles)
The core services are profile-less and start on every up. Further services
sit behind a Compose
profile and stay down until
you ask for them:
-
ferroehr-viewer(--profile viewer): the viewer on port 3000, pointed at the server inside the Compose network. Start the stack with it:docker compose --profile viewer up # → http://localhost:3000 (log in with ferroehr / ferroehr) -
seaweedfsandseaweedfs-init(--profile s3): an S3 gateway for largeDV_MULTIMEDIAexternalization (development and test only). Point the server at it and bring the profile up:export FERROEHR__MULTIMEDIA__ENABLED=true export FERROEHR__MULTIMEDIA__ENDPOINT=http://seaweedfs:8333 export FERROEHR__MULTIMEDIA__BUCKET=openehr-multimedia export FERROEHR__MULTIMEDIA__ALLOW_HTTP=true # dev only; production S3 is HTTPS docker compose --profile s3 up -d --wait ferroehr seaweedfsThe compose file passes the whole
FERROEHR__MULTIMEDIA__*set through from your shell, so there is no file to edit, andseaweedfs-initcreates the bucket by issuing an idempotentCreateBucketagainst the gateway, which ships with none, and answers an S3 write into a missing bucket with403 AccessDenied, a reply that reads as a credentials problem and is not one. It reads the same bucket variable the server does, so the two cannot drift.To turn it off again,
unsetthose variables (or setFERROEHR__MULTIMEDIA__ENABLED=false) and re-up: an unset variable is removed from the container’s environment rather than passed as empty, so the server falls back to its own default ofenabled = false.Confirm the server took them with
curl -s -u ferroehr:ferroehr http://localhost:8080/management/env | jq .multimedia("enabled": trueand a non-emptyendpointmean the wiring is right). In production, point the multimedia settings at a real, credentialed, HTTPS S3 endpoint instead; see S3 multimedia. -
ferroehr-backup-clinical,ferroehr-backup-partyandferroehr-backup-linkage(--profile backup): onepg_dumpjob per pseudonymisation domain, run on demand rather than started with the stack:docker compose --profile backup run --rm ferroehr-backup-clinical docker compose --profile backup run --rm ferroehr-backup-party docker compose --profile backup run --rm ferroehr-backup-linkageEach writes a timestamped custom-format dump into its own host directory (
FERROEHR_BACKUP_CLINICAL_DIR,FERROEHR_BACKUP_PARTY_DIR,FERROEHR_BACKUP_LINKAGE_DIR, defaulting to./backups/clinical,./backups/demographicand./backups/linkage). SetFERROEHR_BACKUP_USER="$(id -u):$(id -g)"and the files land owned by you; unset, the job runs as root inside the container with one capability,DAC_OVERRIDE, which is what writing a directory it does not own actually requires.Three dumps rather than one is the point: a single whole-database dump holds the clinical record, the identities of its subjects and the map between them, and whoever reads that file re-identifies every record in it. This stack runs one login credential, so it separates the artefacts and not the authority to produce them; Operations → Dump each domain separately is the production procedure, including why a backup credential needs
BYPASSRLS.
Two things that used to be profiles of this file are not any more. Both have to change the server’s configuration to be useful, which a profile cannot do, so each is a separate file instead:
- OIDC / Keycloak is the
docker-compose.keycloak.ymloverlay above (and, in a repository checkout, akeycloakprofile of the development override described below). - A terminology server is the
docker-compose.terminology.ymloverlay above, FerroTERM beside the CDR; the repository’s conformance stack additionally runs its own seeded server for the acceptance lane, see Terminology servers.
Port already in use?
Every published port is a variable, so a conflict never requires editing any
file. When docker compose up refuses with port is already allocated (or
address already in use), pick a free port and pass it:
FERROEHR_PORT=8081 docker compose up
The same works for the other ports (FERROEHR_VIEWER_PORT,
FERROEHR_PIX_PORT, FERROEHR_ISSUER_PORT, FERROEHR_S3_PORT; PostgreSQL publishes no host port — see
Connecting to the database). The defaults stay
fixed on purpose: every URL in this book assumes localhost:8080, and
Docker’s automatic ephemeral-port allocation exists only when a mapping
omits the host port entirely
— a server that silently moved ports would break every printed URL, so here
you always choose the port and always know it.
Note
On Windows the refusal reads differently:
Ports are not available: exposing port TCP 127.0.0.1:8080 … bind: Only one usage of each socket address … is normally permittedwhen another program holds the port, or… An attempt was made to access a socket in a way forbidden by its access permissionswhen the port sits in a Windows reserved range. The remedy is the same variable; to see the reserved ranges, runnetsh interface ipv4 show excludedportrange protocol=tcp. (The classic Windows collision — a natively installed PostgreSQL service holding 5432 — no longer affects the quickstart, which publishes no database port.)
Connecting to the database
The quickstart publishes no PostgreSQL host port: the server reaches the
database over the compose network, so nothing needs one, and a published
5432 collided with natively installed PostgreSQL (on Windows the installer
registers an auto-started postgresql-x64-* service). For a psql session,
no port is needed at all:
docker compose exec ferroehr-postgres psql -U postgres -d ferroehr
For a GUI client on the host (pgAdmin, DBeaver), add the db-publish
overlay, downloaded from the same release beside the base file:
docker compose -f docker-compose.yml -f docker-compose.db-publish.yml up
It publishes 127.0.0.1:5432 (retune with FERROEHR_DB_PORT, widen with
FERROEHR_BIND_HOST — the warning above applies).
Variables the compose files read
Set these in your shell (or an .env file) to retune without editing anything:
| Variable | Default | Effect |
|---|---|---|
FERROEHR_IMAGE | ghcr.io/ferrohealth/ferroehr:<release> | Server image to run. |
FERROEHR_POSTGRES_IMAGE | ghcr.io/ferrohealth/ferroehr-postgres:<release> | Database image to run. |
FERROEHR_VIEWER_IMAGE | ghcr.io/ferrohealth/ferroehr-viewer:<release> | Viewer image (the viewer profile). |
FERROEHR_BIND_HOST | 127.0.0.1 | Host interface every published port binds. |
FERROEHR_PORT | 8080 | Host port mapped to the server. |
FERROEHR_VIEWER_PORT | 3000 | Host port mapped to the viewer. |
FERROEHR_PIX_PORT | 8090 | Host port mapped to FerroPIX, the patient identity source. |
FERROEHR_ISSUER_PORT | 8180 | Host port mapped to the development OpenID Provider. |
FERROPIX_POSTGRES_PASSWORD | postgres | FerroPIX’s database superuser password (init only). |
FERROEHR_DB_PORT | not published | Host port for PostgreSQL, read only by the db-publish overlay (default 5432 there). |
FERROEHR_S3_PORT | 8333 | Host port mapped to the S3 gateway (the s3 profile). |
FERROEHR_BACKUP_CLINICAL_DIR | ./backups/clinical | Host directory the clinical dump job writes to (the backup profile). |
FERROEHR_BACKUP_PARTY_DIR | ./backups/party | Host directory the party dump job writes to. |
FERROEHR_BACKUP_LINKAGE_DIR | ./backups/linkage | Host directory the linkage dump job writes to. |
FERROEHR_BACKUP_USER | 0:0 (root in the container) | uid:gid the dump jobs run as. Set it to $(id -u):$(id -g) and the files land owned by you. |
FERROEHR_CPUS / FERROEHR_MEM | 4 / 4G | Server container resource ceiling. |
FERROEHR_DB_CPUS / FERROEHR_DB_MEM | 4 / 4G | Database container resource ceiling. |
PG_INIT_USER / PG_INIT_PASSWORD / PG_INIT_DB | ferroehr | App role, password, and database created by the DB image’s init script. |
POSTGRES_PASSWORD | postgres | Bootstrap superuser password (init only). |
FERROEHR__TELEMETRY__FORMAT | pretty | Log rendering for docker compose logs. Set json for log collectors. |
FERROEHR__TELEMETRY__FILTER | info | Log level filter. |
FERROEHR__DB__MAX_CONNECTIONS | 10 | Server connection-pool ceiling. |
FERROEHR__SERVER__MAX_CONCURRENT_REQUESTS | 256 | Concurrent-request bound (503 with Retry-After past it). |
FERROEHR__SIGNING__ENABLED | true | Version signing. |
A commercial licence token is not a passthrough variable: mount the signed
token file into the server container read-only and name its path with
FERROEHR__LICENCE__FILE (the compose file carries a commented example beside
FERROEHR__SIGNING__ENABLED). Unset, the server runs under the embedded
non-commercial grant and reports which on GET /management/status.
The Keycloak overlay adds KEYCLOAK_PORT (default 8081),
KEYCLOAK_HOSTNAME, KEYCLOAK_ADMIN_USER and KEYCLOAK_ADMIN_PASSWORD (both
admin); the observability overlay adds GRAFANA_PORT (default 3000). The
FERROEHR__MULTIMEDIA__* set is declared with no defaults on purpose, so an
unset key reaches the server as absent rather than as an empty string.
The server container is passed FERROEHR__DB__URL (assembled from the database
variables), and its configuration file is the inline quickstart config,
delivered to /etc/ferroehr/ferroehr.toml, which FERROEHR_CONFIG names.
Any other setting from the configuration reference can be
added under the ferroehr service’s environment: block and takes precedence
over the file. Every variable uses the same FERROEHR__… grammar as the rest of
that reference; a single-underscore spelling is rejected at startup, not
ignored.
Repository development
A bare docker compose up in a checkout runs the same published-images
quickstart as the downloaded file. Building from the current sources is an
explicit opt-in: the docker-compose.dev.yml overlay, passed as a
second file,
switches the stack to the from-source developer posture:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
That builds the server, database and viewer images from the current sources
(the :local tags) instead of pulling published ones, and replaces the inline
quickstart configuration with docker/ferroehr.dev.toml: three Basic users
(ferroehr, ferroehr-admin, ferroehr-readonly, all with password
ferroehr), RBAC enabled so the role separation is exercised, and trust for
the development Keycloak realm. The override also defines a keycloak profile
that imports the full development realm
(docker compose --profile keycloak up).
Downloaders of the standalone quickstart file never see any of this; it is
purely a convenience for working on FerroEHR itself, and it only ever
applies when its -f is passed — nothing merges it in silently.
Tip
A cold from-source build compiles at two parallel rustc jobs by default, and a release-optimization compile can hold several gigabytes per job. If the build gets OOM-killed on a memory-constrained host (observed on a 24 GB machine that was also running another stack), rerun it serially — slower, but it completes:
CARGO_BUILD_JOBS=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml build
Build provenance
Images built by CI (and any docker compose build you drive from a checkout)
embed a build SHA reported at /management/info and on the
ferroehr_build_info metric. The build does not read .git; instead the SHA is
passed as the standard REVISION build argument, the same value that fills the
org.opencontainers.image.revision image label (CI uses the commit SHA; the
project’s own scripts export git rev-parse --short=12 HEAD). The argument is
declared by the files that carry build: blocks (the development override and
the conformance stack) not by the pull-only quickstart file. When no value is
supplied the identity falls back to the workspace version with an unknown
SHA; the build never fails for lack of it.
Observability overlay
A further standalone file adds a full local telemetry stack: an OTLP
collector, Prometheus, Tempo, Loki and Grafana with a provisioned
service-overview dashboard. Like the Keycloak overlay it travels inline, so
downloading it beside docker-compose.yml is enough:
docker compose -f docker-compose.yml -f docker-compose.observability.yml up
# Grafana → http://localhost:3000
The overlay reconfigures the server for that stack: it exports traces and
metrics over OTLP/gRPC (FERROEHR__TELEMETRY__OTLP__ENDPOINT with
FERROEHR__TELEMETRY__OTLP__METRICS=true), switches stdout to JSON lines
(FERROEHR__TELEMETRY__FORMAT=json), and enables the management surface on its own
internal port 9464 with info, metrics and prometheus reachable there. That
port is only published on the Compose network; Grafana’s 3000 is the sole
published port.
Metrics are pushed, not scraped, and that is a property of the bundled
image rather than a preference: grafana/otel-lgtm runs Prometheus with a
config file carrying no scrape_configs at all, and receives metrics over OTLP
from its own collector. A scrape job dropped into that image is read by nothing,
which is what an earlier version of this overlay did: every metric panel was
empty with no error anywhere. Since one meter provider feeds both readers, the
push carries every metric family the scrape endpoint would, and
/management/prometheus stays reachable on the Compose network for anyone who
wants to compare the two.
Grafana’s port 3000 is the same one the viewer would use, but the two
never collide by accident: the viewer only starts when you ask for the
viewer profile. If you want both at once, move one of them
(FERROEHR_VIEWER_PORT=3001 or GRAFANA_PORT=3001).
This is the easiest way to see the server’s metrics and traces without wiring up a collector by hand. See Operations for what the server exports and how to consume it in production.
Next
- Configuration reference — every setting you can pass.
- Kubernetes & Helm — the production deployment.
- Returning to the original state: resetting the stack to the configuration and empty database it shipped with.
Kubernetes & Helm
The ferroehr Helm chart deploys FerroEHR as a hardened, production-shaped
Kubernetes workload (non-root, read-only root filesystem, a NetworkPolicy that
admits its serving port only, its own user namespace) against an external
PostgreSQL 18. This chapter
covers installing it, verifying what you installed, the database role model it
expects, the security posture it pins, the health probes, the optional
integrations, and upgrades.
- Installing
- Configuration: one key, one file
- Database roles — who runs migrations
- Secrets and mounted config
- Security posture
- Health probes
- Authentication is required, and the chart says so before it installs
- Integrations
- Any server setting is reachable
- The common switches
- FerroEHR Viewer (a second workload, off by default)
- FerroPIX (the patient identity source, on by default)
- FerroTERM (a terminology server beside the CDR, off by default)
- FerroBRIDGE (the FHIR bridge, off by default)
- Per-domain backups (one CronJob per domain, off by default)
- Staying available while things move
- Upgrades
Important
The chart requires Kubernetes 1.36 or newer (
kubeVersion: ">=1.36.0-0"). That is a compatibility floor, not a support opinion: 1.36 is the release where the newest field the chart renders (hostUsers, which gives every pod its own user namespace) became stable, so nothing has to be version-gated into silence. Your nodes must be Linux with containerd 2.0 or newer, or CRI-O 1.25 or newer; without that support the pod does not start, which is the loud failure rather than a silent downgrade. SethostUsers: trueto opt out and share the host’s user namespace. What the user namespace buys is in Cluster hardening.
Important
There is no in-chart PostgreSQL. A CDR stores PHI, so its database must be an externally managed, backed-up, point-in-time-recoverable PostgreSQL 18 (a managed service or an operator-run cluster). The chart carries only the connection string, preferably from an existing Secret.
Installing
The chart is published to GHCR as an OCI artifact, beside the images it deploys. Create a Secret holding the app-role connection string, then install:
kubectl create namespace ferroehr
kubectl -n ferroehr create secret generic ferroehr-db \
--from-literal=FERROEHR__DB__URL='postgres://ferroehr_clinical:***@pg-host:5432/ferroehr?sslmode=verify-full'
helm install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
--version 21.0.0-rc3 -n ferroehr \
--set config.deployment_profile=sandbox \
--set database.existingSecret=ferroehr-db \
--set image.tag=5.6.0-rc3 \
--set ferropix.enabled=false --set config.ehr_index.provider=local
config.deployment_profile=sandbox names the posture of a trial install,
which must not hold real patient data; the chart has no default and refuses to
render without one. A deployment holding patient data starts from the
production overlay instead. The last line opts in to
the in-instance EHR index, for a CDR run on its own.
By default the chart runs FerroPIX, the IHE patient identity source, beside the
CDR and points the EHR index at it, which needs FerroPIX’s own Secrets and an
issuer: see FerroPIX.
With neither, the render is refused and names both choices, as the server’s
boot would be.
Important
helm repo adddoes not work for this chart, and never will. There is no HTTP chart repository and noindex.yaml: OCI is the only publication path, so that there is exactly one place a chart version can exist. Every command takes theoci://reference above. Helm has treated OCI registries as first-class since 3.8, so the cost of this choice is real but narrow: a client older than Helm 3.8 cannot install this chart at all.
helm show, helm pull, helm template and helm upgrade all take the same
reference. To read the chart’s metadata without installing it:
helm show chart oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3
Pin two versions, not one
The chart version and the image tag move independently, and this is the thing
people get wrong first. --version selects the chart (its templates and
values schema); image.tag selects the server binary. The chart’s appVersion
is only the default for the second, and it names the release the chart was cut
against.
| Selects | Pin with | Line | |
|---|---|---|---|
| Chart version | templates, values schema, defaults | --version 21.0.0-rc3 | SemVer over the chart’s own contract |
| Image tag | the server binary | --set image.tag=5.6.0-rc3 (or image.digest) | the application’s SemVer line |
Always pin the image to an immutable version or, better, a @sha256 digest,
never latest. Pin the two deliberately: the config tree is passed through to
the server, so a key the chart’s defaults carry and your chosen image does not know
is a boot refusal (unknown configuration key …), which presents as
CrashLoopBackOff. A chart version is never republished with different content
(the publish lane refuses to overwrite one) so a pinned chart version is a fixed
artifact.
Note
A published chart version being immutable is a property of that refusal, not of the registry: an OCI tag is mutable, and
helm pushover an existing one would silently replace it. That is why a correction always ships as a new chart version, and why the6.0.3→6.0.4bump exists: the chart’sappVersionmoved to a new release while its own version had already been published.
Verifying what you installed
Everything published here is signed keyless through Sigstore, bound to this repository’s build identity, so every claim is checkable rather than asserted. Two different artifacts answer two different questions, and you can ask both.
Who signed this chart: a cosign signature over the chart’s digest:
cosign verify ghcr.io/ferrohealth/charts/ferroehr:<chart-version> \
--certificate-identity-regexp '^https://github\.com/FerroHEALTH/FerroEHR/\.github/workflows/build-chart\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Both flags matter. Without them cosign verify would accept a signature from
any identity in the transparency log; with them you are requiring that this
repository’s chart-publishing workflow, authenticated by GitHub’s OIDC issuer,
is what signed the bytes you pulled.
What it was built from: a SLSA build provenance attestation:
# the chart
gh attestation verify oci://ghcr.io/ferrohealth/charts/ferroehr:<chart-version> -R FerroHEALTH/FerroEHR
# the image it deploys
gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:<tag> -R FerroHEALTH/FerroEHR
The publish lane reads both back from the registry before it reports success, so a run that produced only one of them fails instead of going green.
Important
Both commands verify what the publishing lanes produce now. A published artifact is never replaced, so if one you pinned answers
HTTP 404: Not Foundit carries no attestation, which is the honest state rather than a verification failure, and the fix is to pin a current version. Image signing landed during the3.17.4cycle, so image tags from before it have nothing to verify.
Note
helm install --verifyandhelm verifydo not apply: they check a PGP.provfile, and this chart ships none. That is deliberate: a.provneeds a long-lived private key in CI, which is the exact thing this project’s publishing lanes are built to avoid (the crates.io lane uses OIDC Trusted Publishing and holds no token at all). The two keyless commands above are what replace it: nothing to leak, and the same trust root as the images.
Your values file is checked before anything is applied
The chart ships a values.schema.json, so helm install, helm upgrade, helm lint and helm template refuse a values file that misspells one of the
chart’s own keys, gets a type wrong, or names a value outside the permitted set,
rather than rendering and silently ignoring it:
Error: values don't meet the specifications of the schema(s) in the following chart(s):
ferroehr:
- at '/image/pullPolicy': value must be one of 'Always', 'IfNotPresent', 'Never'
The config: tree is deliberately exempt. Those keys are the server’s
(see the configuration reference), the binary validates them
at boot, and copying that vocabulary into the chart’s schema would fork it: a
new configuration key would then be rejected by the chart until someone
remembered to widen the schema. So a mistake under config: is reported when the
pod starts, not when the chart renders; --skip-schema-validation disables the
check entirely if you ever need to bypass it.
The chart is also listed on Artifact
Hub, which renders the
chart’s metadata plus a security report over the four images the chart’s own
metadata lists: the server, the optional viewer, the backup jobs’ pg_dump
image, and FerroTERM.
Warning
Between releases the chart’s
configdefaults track development and can be ahead ofappVersion’s image. Check the pairing before you install, with the image itself as the authority:helm template ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3 \ -s templates/configmap.yaml --set database.existingSecret=ferroehr-db \ | sed -n '/ferroehr.toml/,$p' | sed '1d;s/^ //' > /tmp/ferroehr.toml docker run --rm -v /tmp/ferroehr.toml:/etc/ferroehr/ferroehr.toml:ro \ -e FERROEHR_CONFIG=/etc/ferroehr/ferroehr.toml \ -e FERROEHR__DB__URL=postgres://u:p@db:5432/ferroehr \ --entrypoint /usr/local/bin/ferroehr ghcr.io/ferrohealth/ferroehr:<tag> config checkExit 0 means the image accepts the rendered configuration. A reported unknown key means the image is older than the chart’s defaults: use a newer tag, or set that key to
nullfor this deployment. A published chart is checked this way before it is published: the publish lane refuses to ship a chart whose defaults its ownappVersionimage rejects, and it repeats the check against every values overlay the chart carries, so this matters mainly when you install from a checkout ofmain, or pin an image older than the chart.
That install alone boots but answers 401 to everything, deliberately:
config.auth.enabled is on and no mechanism is configured yet, and a server that
authenticates nothing is not a safe default. Add a config.auth.issuer (with
config.auth.audience) or a config.auth.basic.users entry before expecting a
request to succeed. Every route also checks the SMART on openEHR scopes of the
caller: a Basic user’s scopes, a token’s scope claim.
Configuration: one key, one file
The chart carries the server’s whole configuration under one key, config,
rendered into a ferroehr.toml ConfigMap mounted at
/etc/ferroehr/ferroehr.toml. Its keys are therefore exactly the TOML keys of
the configuration reference (config.server.listen,
config.authz.rbac.enabled, config.spec_profile, and so on) so anything
that reference documents can be set without waiting for a bespoke chart key.
Secret-bearing keys are the one exception: a credential never reaches that ConfigMap. A ConfigMap is not a sensitive object: it is readable with namespace read, quoted wholesale into issues and support tickets, collected by backup tooling that skips Secrets, and not covered by Secret encryption at rest. The chart therefore classifies every key it renders and takes one of two actions.
A secret the chart routes: signing.key_passphrase,
multimedia.secret_access_key, a Basic user’s password_hash, a terminology
client_secret, and the three URL-shaped ones (db.url, events.url,
audit.fhir_feed.url) has a secrets: key of its own, so
a value under config: is a mistake and fails the render, naming the key that
belongs there:
Error: execution error at (ferroehr/templates/deployment.yaml:…):
refusing to render a secret into the ConfigMap (a ConfigMap is not a sensitive object …):
- config.signing.key_passphrase: set `secrets.signingKeyPassphrase` instead
A secret the chart cannot route moves the whole rendered ferroehr.toml into
the chart’s Secret, and no ConfigMap is created at all, the safe direction,
taken automatically. No key reaches that branch today: every credential the
server models now has either a *_file sibling or a Secret-borne environment
route. The branch exists for the next one that does not, so that a secret key
added upstream fails safe instead of landing in a ConfigMap. When it is taken, the
install notes say which object your release used, and the configuration is read
with:
kubectl -n ferroehr get secret ferroehr-config \
-o jsonpath='{.data.ferroehr\.toml}' | base64 -d
Classification is by name shape, not by a list of today’s keys: any key whose
name carries password, passphrase, secret, credential, private_key,
api_key or a trailing token is treated as a credential unless it ends in
_file, _path or _dir (those hold a path), and the four URL-shaped secrets are
matched by path, because url carries no shape a classifier can see. That is what
makes a secret key added to the server’s configuration tree in a future release
move to the Secret rather than leak silently. extraEnv is the escape hatch for
anything neither config: nor secrets: surfaces.
The deployment profile and the access log
The chart’s values declare three keys, so a rendered ferroehr.toml always
says what it runs:
config.deployment_profile: no default; the chart refuses to render until it namessandboxorproduction, so an installation that follows a quickstart instead of the instructions for use does not end up in the sandbox posture by omission (EHDS Annex II 1.2).sandboxmust not hold real patient data: the server names every separation it has not made on its boot banner and on/management/status, and reports the profile onGET /ferroehr/rest/status. A deployment holding patient data setsproduction, which refuses to start while a separation is missing and not named inconfig.deployment_accepts. The chart defaults leave several open: one DSN for every domain, an emptyconfig.privacy.subject_namespaces, the server’sopendefaults forconfig.audit.fail_modeandconfig.authz.rbac.ehr_access_default,config.db.migrate: applyon the runtime credential, and a plaintext listener behind the ingress; seedeployment_profile. The chart’s production overlay closes them and accepts the plaintext listener by name.config.audit.store.retention_years: unset. Set it in place ofconfig.audit.store.retention_dayswhen the horizon must be exact in calendar years, such as3for a German SGB V § 309 controller withconfig.audit.store.sgb_v_309_controller: true.config.audit.categories: an emptytemplatesandarchetypesmap, under which the category map FerroEHR ships applies: it classifies the published CKM templates and archetypes of one priority category and records anything else asunclassified. Map your own templates and root archetypes, which replaces the shipped map; the keys are template ids and archetype ids, so quote a template id that contains spaces:
config:
audit:
categories:
templates:
"International Patient Summary": [patient-summary]
archetypes:
openEHR-EHR-COMPOSITION.report-result.v1: [test-results]
The Audit page has the category vocabulary and the retention floors and ceilings the server enforces at boot.
The production overlay
The chart ships values-production.yaml beside values.yaml. Layered over
the defaults, it runs config.deployment_profile: production with every
separation that profile checks made, except one it accepts by name:
| Gap | In the overlay |
|---|---|
shared_credential | the clinical, party and linkage domains each read their own Secret (database.existingSecret, database.party.existingSecret, database.linkage.existingSecret), and the audit domain reads database.audit.existingSecret |
shared_cluster | yours to make: the server compares the cluster each domain pool reaches, so the separation is four DSNs on four PostgreSQL clusters. Each cluster is prepared on its own migrator: the migration Job applies the schema in every cluster, and the server verifies it there, on database.<domain>.migrateExistingSecret (database.migrateExistingSecret for the clinical cluster). The linkage cluster also receives the party migration set, schema only, because the linkage set names a function the party set creates |
migrate_on_runtime_credential | config.db.migrate: verify, with migrations.job.enabled preparing the schema on database.migrateExistingSecret |
open_subject_namespace | config.privacy.subject_namespaces declared |
audit_off, audit_fails_open | the audit trail on with its local store, config.audit.fail_mode: closed |
no_category_map | the shipped category map (the default), or config.audit.categories templates and archetypes of your own |
open_ehr_access_default | config.authz.rbac.ehr_access_default: restricted |
auth_off | OIDC authentication on |
rbac_off | the role gate on (config.authz.rbac.enabled: true, the chart’s default) |
plaintext_broker | no AMQP integration on |
plaintext_audit_feed, plaintext_object_store | the audit FHIR feed and the multimedia store off; an https:// URL for either when you turn it on |
plaintext_listener | accepted by name: TLS ends at the Ingress (ingress.tls), the pod listens in plain HTTP, and the NetworkPolicy admits only the ingress controller’s namespace (networkPolicy.ingressFrom, with networkPolicy.ingressAllowAll: false) |
The database hop is TLS with the server’s certificate checked: end every DSN in
?sslmode=verify-full&sslrootcert=/etc/ferroehr-db-ca/ca.crt, and put the CA
your database certificates chain to in a ConfigMap under the key ca.crt. The
overlay names it ferroehr-db-ca (database.caConfigMap), and the chart mounts
it read-only at /etc/ferroehr-db-ca in the server, the migration Job and the
backup CronJobs. A CA the image already trusts needs no ConfigMap; leave
database.caConfigMap empty then.
Every Secret name, the CA ConfigMap name, the identity provider, the pseudonym namespace, the host, the TLS Secret and the ingress controller’s namespace in it are examples. Replace them in a values file of your own and pass both:
helm pull oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3 --untar
helm install ferroehr ./ferroehr -n ferroehr \
-f ./ferroehr/values-production.yaml -f my-site-values.yaml
A name you forget to replace fails loudly: a Secret that does not exist keeps
the pod from starting, and the server refuses to start under production
while a separation is open, naming each gap and its remedy. The chart’s
validation renders the overlay and asserts each separation above, and its
boot check has the server evaluate the posture from the rendered
configuration. Neither reaches a database. The deployment probe
(scripts/deploy-probe-k8s.sh --production-only) installs the overlay on a
real cluster against four PostgreSQL clusters that refuse a connection without
encryption: it observes the migration Job prepare all four and the server start
under production with plaintext_listener as its only gap, both over
sslmode=verify-full against the CA the overlay mounts, and the server refuse
all four domains on one cluster by naming shared_cluster. Against your own databases, shared_cluster is first
judged when the pod starts.
Database roles — who runs migrations
The chart expects a PostgreSQL role model in which the runtime pod is never a superuser. Four roles cover the single-domain posture:
| Role | Purpose |
|---|---|
| owner | owns the database (provisioning only) |
ferroehr_migrator | runs the append-only schema migrations |
ferroehr_clinical | day-to-day reads and writes; the running pod connects as this |
ferroehr_clinical_reader | read-only, for replicas and reporting |
Migrations are DDL, so whoever applies them can rewrite the schema. Two flows,
and config.db.migrate is where you choose:
- (a) The pod migrates itself (
config.db.migrate: apply, the default). Simplest for single-tenant or small deployments, and the runtime DSN must then be a member offerroehr_migrator, so the serving process holds DDL rights on the clinical schema for its whole life. - (b) A separate migration step (
config.db.migrate: verify) under a migrator DSN, with the pods on a narrow runtime DSN. The server issues no DDL at all and refuses to boot against a database that has not been migrated to its build, so the two versions cannot race the schema. Recommended for production. It needsdatabase.migrateExistingSecretas well, for the reason the paragraph further down gives:verifyreads all five bookkeeping tables, which a narrow runtime role cannot do.
Set migrations.job.enabled and the chart runs (b) for you as a
pre-install,pre-upgrade hook Job that runs ferroehr db migrate. Helm creates
it before the Deployment and waits for it, so a failed migration fails the release
rather than rolling pods against a schema that was never applied. The Job
authenticates from its own Secret (deliberately a different credential from
database.existingSecret) and rendering is refused if you enable it without one:
database:
existingSecret: ferroehr-db # postgres://ferroehr_clinical:…
migrateExistingSecret: ferroehr-db-migrator # postgres://ferroehr_migrator:…
migrations:
job:
enabled: true
existingSecret: ferroehr-db-migrator # the same credential, its own Secret mount
config:
db:
migrate: verify
The demographic and linkage domains take their own credentials the same way, and that is what turns the schema separation into a credential separation. One more goes with them, because no runtime credential can prepare the schema:
database:
existingSecret: ferroehr-db # postgres://ferroehr_clinical:…
party:
existingSecret: ferroehr-db-party # postgres://ferroehr_party:…
linkage:
existingSecret: ferroehr-db-linkage # postgres://ferroehr_linkage:…
migrateExistingSecret: ferroehr-db-migrator # postgres://ferroehr_migrator:…
All four are mounted as files, so no DSN enters the pod’s environment. Unset
the demographic and linkage ones and those two pools share the first, which is
the schema-only posture, still separated and still verified at boot. Creating
the five domain
roles is a database step the chart cannot do for you;
Operations has
the statements and what ferroehr db verify reports.
database.migrateExistingSecret is what the pod prepares the schema with, and
it is required as soon as database.existingSecret is anything narrower than a
credential reaching every schema. Preparation spans them all at once: the DDL
of all five migration sets under config.db.migrate: apply, all five
_sqlx_migrations bookkeeping tables under verify. So verify is not the
exception, and this is not only about the domain roles. ferroehr_clinical holds
one pseudonymisation domain, ferroehr_clinical holds the clinical schemas, and
neither can read the demographic or linkage bookkeeping at all; without
this value the pod is refused on the first set it cannot read and does not
start. It is normally the same credential
migrations.job.existingSecret carries; the Job needs its own copy because it
runs before the Deployment exists. Unset, preparation falls back to
database.existingSecret, which is what a single-credential install has
always done.
database.migrateExistingSecret reaches one database. A domain whose DSN
reaches another database or cluster is prepared there, on that database’s own
migrator, which database.<domain>.migrateExistingSecret names:
database:
linkage:
existingSecret: ferroehr-db-linkage # postgres://ferroehr_linkage:…@linkage-db/…
migrateExistingSecret: ferroehr-db-linkage-migrate # postgres://ferroehr_migrator:…@linkage-db/…
The migration Job mounts it and applies the schema in that database; the server
mounts it and verifies the schema there under config.db.migrate: verify. The
domain’s own role cannot do either, because it holds one domain and cannot read
the ext set’s bookkeeping. The server refuses to start when the two DSNs of
one domain reach different databases. A database that holds the linkage domain
without the party domain also receives the party migration set, its schema and
grants and never a row, because the linkage set names a function the party set
creates.
Give the migrator DSN a short lock_timeout
(?options=-c%20lock_timeout%3D5s) so DDL blocked behind live traffic fails
fast instead of queueing; migrations.job.activeDeadlineSeconds is the hard
ceiling on the step either way. migrations.runByMigratorRole remains an
informational marker surfaced in the install notes, which also tell you when
the Job is enabled but config.db.migrate is still apply, a combination
that buys nothing, because the server would migrate itself anyway.
You can check the posture from outside the cluster at any time: ferroehr db verify exits 0 only when the database carries exactly that build’s migrations,
and issues no DDL doing it.
Secrets and mounted config
Some material is file-shaped rather than a value: ABAC policy files, ATNA TLS
certificates, terminology-server client certificates, a JWKS blob, the PGP
signing key, and a commercial licence token. Supply these under config.files,
whose entries the chart mounts read-only from a Secret at /etc/ferroehr/<key>
(and which is deliberately not part of the rendered TOML); point the matching
in-TOML *_file / *_path key at the mounted path. A commercial licence goes
the same way: put the token the licensor issued under config.files as
licence.asc and set config.licence.file to /etc/ferroehr/licence.asc;
GET /management/status then reports licence.use = "commercial". Without
it the pod runs under the non-commercial grant every build embeds (see
Licensing & legal). Secret-bearing scalar values go under
secrets:: signingKeyPassphrase, eventsUrl,
auditFhirFeedUrl, basicUserPasswordHashes,
multimediaAccessKeyId, multimediaSecretAccessKey,
terminologyClientSecret, and the database DSN comes from
database.existingSecret (key database.existingSecretKey, default
FERROEHR__DB__URL). None of these ever reach the ConfigMap, and all but two are
delivered as mounted files rather than environment values.
How a secret reaches the process differs by whether the configuration key has a
*_file sibling, and the difference is a security one: an environment variable
is readable through /proc/<pid>/environ and is inherited by every child
process, so the OWASP Kubernetes Security Cheat
Sheet
asks for a read-only volume instead.
| Secret | How the chart delivers it |
|---|---|
| the database DSN | mounted at /etc/ferroehr-secrets/db.url, reached through FERROEHR__DB__URL_FILE, projected from database.existingSecret when you supply one, so the credential that reaches patient data never enters the pod’s environment |
secrets.signingKeyPassphrase | mounted at /etc/ferroehr-secrets/signing.key_passphrase |
secrets.multimediaSecretAccessKey | mounted at /etc/ferroehr-secrets/multimedia.secret_access_key |
secrets.basicUserPasswordHashes (per user-id) | mounted at /etc/ferroehr-secrets/auth.basic.users.<user>.password_hash; the chart injects the matching password_hash_file |
secrets.terminologyClientSecret | mounted at /etc/ferroehr-secrets/terminology.server.oauth.client_secret; the chart injects the matching client_secret_file into config.terminology.server.oauth, which must declare the grant |
secrets.eventsUrl | mounted at /etc/ferroehr-secrets/events.url |
secrets.auditFhirFeedUrl | env; audit.fhir_feed.url is the only credential-bearing key with no *_file sibling |
secrets.multimediaAccessKeyId | env; an access key id is not secret (it is reported unredacted by the management surface’s env endpoint) |
The mount is read-only, 0440, owned root:65532 so the non-root process reads
it through the group bit, and it is deliberately not a subPath mount,
because a subPath-mounted Secret never receives updates and a rotation would
not propagate.
Note
A Basic user’s Argon2id hash is delivered as a mounted file like the others: put it in
secrets.basicUserPasswordHashesunder the user-id, declare only theuser, itsscopesand itsrolesunderconfig.auth.basic.users. Settingpassword_hashunderconfig:is refused, and the error names the key that carries it. An Argon2id hash is not a plaintext password, but it is an offline cracking target, which is what the boot-time OWASP parameter floor exists to make expensive.
Security posture
The chart pins the following, and its render gate holds it: the Restricted fields are asserted per container for every workload in the render, the two isolation settings are asserted to agree across a release’s workloads, and the golden renders pin the exact bytes so a changed default fails a diff.
| Field | Value |
|---|---|
runAsNonRoot | true (uid/gid 65532, the distroless nonroot user) |
readOnlyRootFilesystem | true (a writable emptyDir is mounted at /tmp) |
allowPrivilegeEscalation | false |
capabilities.drop | [ALL] |
seccompProfile.type | RuntimeDefault (pod and container) |
hostUsers | false; the pod gets its own user namespace |
supplementalGroupsPolicy | Strict; only the groups the manifest names |
| ServiceAccount token | not mounted (the workload never calls the K8s API) |
enableServiceLinks | false (see below; not a preference) |
| NetworkPolicy | ports narrowed to the API (and management) port; sources admitted unless you narrow them with networkPolicy.ingressFrom; set networkPolicy.ingressAllowAll: false to have the chart refuse the open state (see §Ingress) |
The whole set satisfies the Pod Security Restricted profile, and the deployment probe harness reads it back off a running pod rather than off the rendered manifest: the container runtime’s own spec for the security context, the API server for admission, the EndpointSlice for readiness.
Satisfying the profile and enforcing it are different things, and only one of them is yours to do. Enforcement comes from a label on the namespace, which a chart cannot set for a namespace it does not own, so label it, or the posture above is a convention nothing checks and nothing fails when a future change regresses it:
kubectl label --overwrite namespace ferroehr \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest
With the label in place the API server refuses a non-compliant pod outright (Pod Security Admission), which is a stronger guarantee than any check the chart can make about itself. The install notes print this as a prerequisite for the same reason.
enableServiceLinks: false is load-bearing, not hygiene. The kubelet injects a
set of Service link environment
variables
for every Service in the namespace, and for a Service named ferroehr* those
land inside the server’s reserved FERROEHR_ namespace, whose strict boot-time
sweep rejects unknown variables and refuses to start. Leaving service links on
makes every install crash-loop.
Egress restriction is opt-in (networkPolicy.egress.enabled) because egress
targets (the database, broker, terminology server) are deployment-specific;
when you enable it the chart always admits DNS and you add rules for the rest.
In the default posture the server’s only outbound traffic is DNS and PostgreSQL,
so those two rules suffice; each integration you switch on adds a target, and a
blocked one can fail silently. The full destination table is in
Namespaces, network & policy.
Two limits worth stating plainly. First, with networkPolicy.ingressFrom empty
the rendered ingress rule carries no from selector, and a rule without from
admits every source, other namespaces included. Only the port list is
narrowed in that state, so set ingressFrom to your ingress controller for a PHI
workload, and set networkPolicy.ingressAllowAll: false if an open ingress rule
must never render at all; the full treatment is
§Ingress.
The viewer’s own policy carries the same pair under viewer.networkPolicy.
Second, a NetworkPolicy is only as real as the CNI that implements it:
on a cluster whose network plugin does not enforce NetworkPolicy the object is
documentation rather than a control, and nothing in Kubernetes reports that.
Confirm it by attempting a connection the policy should refuse.
The database-side controls (the server certificate behind sslmode=verify-full,
pgaudit, at-rest encryption, WAL archiving / PITR) belong to whoever provisions
PostgreSQL; the chart mounts the CA (database.caConfigMap) but cannot enforce
the rest. See
Operations.
TLS at the pod
The production overlay ends TLS at the Ingress and accepts plaintext_listener
by name. To close that gap instead, the server terminates TLS itself
(config.server.tls), and every client the chart wires to it follows: the
viewer and FerroBRIDGE call https://<release>:<service.port>, the kubelet
probes over HTTPS, the Service declares appProtocol: https, and a
ServiceMonitor that scrapes the API port uses https too.
The clients check the server’s certificate against a CA you name in
service.tls.caExistingSecret. The certificate must carry the Service name
(the release’s full name, ferroehr for a release called ferroehr) as a DNS
SAN, which no public CA issues, so the chart refuses to render a TLS listener
beside an enabled viewer, FerroBRIDGE or API-port scrape while that value is
empty. Name a Secret that holds the CA certificate alone: every pod and the
ServiceMonitor that read it then read no private key, and a ServiceMonitor in
another namespace reads its own copy there. A cert-manager Certificate
writes the key pair and the CA into one Secret (tls.crt, tls.key,
ca.crt); copy ca.crt out of it (trust-manager’s Bundle does this) rather
than naming that Secret. The Ingress in front of the TLS listener must reach
it over HTTPS too, and how a controller is told so is controller-specific, so
the chart refuses to render the Ingress with server TLS on until
ingress.backendTlsAnnotations names it:
config:
deployment_accepts: [] # nothing left to accept for the listener
server:
tls:
certificate_file: /etc/ferroehr-tls/tls.crt
key_file: /etc/ferroehr-tls/tls.key
reload_interval_s: 60
extraVolumes:
- name: server-tls
secret:
secretName: ferroehr-server-tls
defaultMode: 0440
items:
- { key: tls.crt, path: tls.crt }
- { key: tls.key, path: tls.key }
extraVolumeMounts:
- { name: server-tls, mountPath: /etc/ferroehr-tls, readOnly: true }
service:
tls:
caExistingSecret: ferroehr-server-ca # ca.crt alone
ingress:
backendTlsAnnotations: # ingress-nginx
nginx.ingress.kubernetes.io/backend-protocol: HTTPS
When cert-manager renews the certificate, the kubelet updates the mounted
files and the server serves the new pair within
config.server.tls.reload_interval_s (60 seconds above; unset, only SIGHUP
reads the files again), with no restart; a renewed pair that fails to load is logged and the old one stays in
service. A rotated client CA (client_ca_file, for client_auth) is picked up
on the same interval, and the connections already open keep the trust anchor
they were verified against.
The viewer reads the mounted CA through its own cdr.tls.trust_roots_file,
beside the image’s roots. FerroBRIDGE reads it through SSL_CERT_DIR, which
the chart sets to the image’s system roots plus the mounted CA, so its other
peers that verify through the platform verifier stay trusted. For an OIDC
issuer whose certificate comes from a private CA, name a Secret holding that
CA in viewer.auth.oidc.caExistingSecret (key viewer.auth.oidc.caExistingSecretKey,
ca.crt by default): the chart mounts it and points the viewer’s
[auth.oidc.tls] trust_roots_file at it. For the server’s own [auth]
issuer behind a private CA, name the Secret in auth.caExistingSecret (key
auth.caExistingSecretKey, ca.crt by default): the chart mounts it and points
the server’s [auth] ca_file at it. The ServiceMonitor reads the Secret from its own namespace, and
the prometheus.io/scheme: https pod annotation follows the listener when
metrics.enabled is set. With config.server.tls.client_auth: required the
kubelet probes cannot present a client certificate, so the chart refuses to
render it; keep it at none or optional.
config.management.port stays plain HTTP on its own listener.
Health probes
All three probes use the always-on, unauthenticated, PHI-free health routes on the main HTTP port. They need no configuration at all: no management surface, no access level, nothing to forget:
| Probe | Route | Contract |
|---|---|---|
| liveness | /health/liveness | 200 while the process is up; touches no dependency |
| readiness | /health/readiness | 200 (up or degraded) or 503 (down, booting or draining): checks the database ping, migrations applied, the audit sender and the event outbox, each bounded at one second |
| startup | /health/liveness | the same constant, with a long failure threshold, so a slow first boot is not killed mid-migration |
That split is deliberate: a database outage must fail readiness (the pod stops receiving traffic) and never liveness (which would restart the container in a loop).
Note
There is no
execprobe option. One existed and was removed: it ran the binary’shealthchecksubcommand, which then asked the openEHR status document rather than a health route, so readiness never touched the database and a pod with a dead database reported Ready and took clinical traffic. The subcommand now asks/health/readiness, and thehttpGetprobes above remain what the chart runs.
Warning
Migrations run only at boot, so a replaced or wiped database leaves the pods NotReady until something migrates it. Readiness reports the
migrationsindicatordown(its detail on/management/health:core schema tables missing (migrations not applied)) whiledbreadsup: the pod reaches PostgreSQL, finds no schema, and does not migrate again, because migration is a startup step. Liveness keeps passing (correctly: the process is healthy), so the kubelet never restarts the container and the Deployment sits with no ready replicas and no error in the logs after the first one:kubectl -n ferroehr get pods # Running, not READY curl -s http://ferroehr:8080/health/readiness # 503, "migrations" down, "db" up kubectl -n ferroehr rollout restart deploy/ferroehrThe check re-tests the schema on every probe, so a pod recovers on its own within one
probes.readiness.periodSecondsof the schema existing; an untouched pod returns to ready, with no restart, once anything else has migrated. What needs the restart is the case where the only thing that would migrate is the pod itself.That is what makes the interaction with flow (b) load-bearing: when migrations run out of band, the migration step must complete before the Deployment rolls, or the first pods sit unready waiting for a schema.
migrations.job.enableddoes exactly that, as a hook Helm waits on. Durable storage is the other half: anemptyDir-backed or otherwise disposable PostgreSQL puts a clinical repository one node eviction away from this state.One recovery path does not self-heal, and it is worth knowing before you try it: restoring or dropping part of the schema set. A restore that brings back some of a domain’s relations without its migration bookkeeping makes the next migration run fail permanently with
relation "version" already exists: the pod crash-loops, and restarting it retries the same failure. Recreate the whole database rather than one schema.
The management surface is independent of the probes and stays ops-only
(/management/info, /prometheus, /metrics, /env, /loggers,
/flamegraph). Unlike the bare binary, the chart ships
config.management.enabled: true with every endpoint off, so nothing is
exposed until you opt one in. Set
config.management.endpoints.prometheus: public plus metrics.enabled=true
to add the prometheus.io/* scrape annotations; set config.management.port
to serve the surface on its own internal listener, so /management is never
reachable on the clinical API port. The health probes stay on the main port
regardless of all of this, and the install notes warn you if you add the scrape
annotations while the endpoint is still off.
Those annotations are honoured by a Prometheus that discovers targets in its own
scrape configuration. An operator-managed Prometheus
(kube-prometheus-stack) ignores them and selects targets through
ServiceMonitor
objects instead, so set metrics.serviceMonitor.enabled=true there;
metrics.serviceMonitor.labels is where the label your Prometheus’
serviceMonitorSelector matches on goes. It needs the monitoring.coreos.com
CRDs installed first, or the install fails on an unknown kind.
Authentication is required, and the chart says so before it installs
config.auth.enabled defaults to true, and the server requires at least one
mechanism with it: a 401 challenge has to name a scheme the server actually
implements (RFC 9110 §11.6.1),
so it exits rather than run as an openEHR API that can only refuse every request.
The chart therefore refuses to render a values file that enables
authentication without configuring one, so helm install stops with an
actionable error instead of reporting success and crash-looping:
Error: execution error at (ferroehr/templates/deployment.yaml:...):
config.auth.enabled is true but no authentication mechanism is configured...
Pick one:
config:
auth:
issuer: https://keycloak.example.org/realms/ferroehr
audience: [ferroehr]
or Basic auth, whose password hashes go in secrets.basicUserPasswordHashes
while the user, its scopes and its roles stay under config.auth.basic.users.
Warning
config.auth.enabled: falsemakes the chart render, and serves every request unauthenticated. On a repository holding patient data that is a development-only choice; the chart makes you state it explicitly rather than reaching it by omission.
Integrations
Any server setting is reachable
The chart renders its config tree verbatim into ferroehr.toml. So
config.<the.toml.path> sets any key in the
configuration reference, including keys this page and
values.yaml never mention. There is no allow-list to extend and no chart
release to wait for:
# values.yaml — [query] plan_cache_capacity, which values.yaml never names
config:
query:
plan_cache_capacity: 512
helm upgrade ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
--version 21.0.0-rc3 -n ferroehr --reuse-values \
--set config.query.plan_cache_capacity=512
Turning something off is the same edit in reverse: remove the key (or set the
integration’s enabled back to false) and upgrade. The tables below are a
curated starting point for the switches most deployments want; they are not
the boundary of what the chart supports.
Two things make this safe to rely on:
- A typo is a boot refusal, not a silent default. The server sweeps its configuration strictly and rejects an unknown key with a did-you-mean, so a misspelled path fails loudly at startup instead of quietly doing nothing. Check before you deploy with the schema and boot gates described under Check your values before you deploy them.
- Credentials do not belong in this tree.
configbecomes a mounted file; secrets have their own routes (secrets.*,existingSecret, and the*_filekey siblings); see Secrets and mounted config.
The common switches
Every switch below lives in the chart’s config tree, so its key is the
TOML key from the configuration reference. Most are off
by default; the ones that ship on are marked, and enabling any of the others
is an explicit, auditable decision:
| Integration | Chart key | Default | Notes |
|---|---|---|---|
| Specification generation | config.spec_profile | development | One coupled choice; stable runs the released generations. |
| ADMIN API | config.admin.enabled | off | Physical, irreversible delete. Gate behind admin RBAC. |
| Terminology extension API | config.terminology.api_enabled | off | 404 when off. |
| Event-subscription API | config.events.admin_api | off | Admin CRUD over event filters. |
| Bearer tokens | config.auth.issuer | unset | With config.auth.audience; the issuer’s metadata and key set are read at boot, asymmetric algorithms only. |
| RBAC | config.authz.rbac.enabled | on | The coarse role gate (active while config.auth.enabled). |
| ABAC | config.authz.abac.enabled | off | Cedar (policies via a config.files mount) or a remote policy decision point. |
| Eventing → AMQP | config.events.enabled | off | Envelopes are PHI-free by design. Use config.events.tls: true; URL via secrets.eventsUrl. |
| S3 multimedia | config.multimedia.enabled | off | ⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket; keys via secrets.multimediaAccessKeyId and secrets.multimediaSecretAccessKey. |
| Terminology server | config.terminology.server.enabled | off | The one FHIR terminology server, at config.terminology.server.url. To run FerroTERM in the cluster instead, use terminology.enabled (FerroTERM), which sets these keys for you. |
| ATNA audit trail | config.audit.enabled | on | On with the local store only; forwarding (config.audit.syslog, config.audit.fhir_feed) is opt-in per sink. |
| Version signing | config.signing.enabled | on (config.signing.mode: digest) | pgp mode needs a config.files key plus secrets.signingKeyPassphrase, and fails closed at boot without a usable key. |
| OTLP telemetry | config.telemetry.otlp.endpoint | unset | Setting the endpoint is all it takes; without a config.telemetry.otlp table nothing is exported. With networkPolicy.egress.enabled, add a rule for the collector, since a blocked exporter drops spans without an error. |
Full detail on each is in Beyond the core, Security, and Operations.
The usage report is on by default, and
usageReport.enabled=false switches it off for FerroEHR, for FerroTERM and for
FerroBRIDGE. Each of those takes its own terminology.usageReport.enabled or
bridge.usageReport.enabled when you want it to differ. FerroPIX runs from its
own chart, which cannot read this chart’s switch: set
ferropix.usageReport.enabled=false as well, or the chart refuses to render.
FerroEHR Viewer (a second workload, off by default)
viewer.enabled renders a second Deployment and Service for the viewer
beside the CDR, from its own image
(viewer.image.repository, tagged appVersion by default so the two move
together). It is off by default: the viewer is a separate product surface with
its own attack surface, and a CDR is complete without it.
Three properties are worth knowing before you switch it on:
- It reaches the CDR strictly over the REST API, which
viewer.networkPolicy.enabled(on by default) enforces rather than assumes: the viewer’s egress admits the CDR Service, DNS and outbound HTTPS for an identity provider, and nothing else. It holds no database credential. - It is a human-facing web UI, so
viewer.ingress.enabledis the normal way to reach it, andviewer.auth.oidc.enabledwith an issuer, client id andviewer.auth.oidc.publicBaseUrlis how you keep a person who should not see PHI out of it. Its client secret comes fromviewer.existingSecret, mounted as a file exactly as the server’s DSN is. viewer.replicaCountis 1 deliberately. The viewer holds session state in process, so a second replica needs sticky sessions at the ingress or users are logged out on a reroute.
The viewer carries the same security context as the server, and the chart’s
render gate holds it to the same Restricted profile and the same pod-isolation
settings: a release whose two workloads disagreed about hostUsers would be a
posture nobody could state in one sentence. The deployment probe harness then
reads that back off the running viewer container, exactly as it does for the
server: its uid, its empty capability bounding set, its read-only root and its
seccomp filter come from the container runtime’s own spec, its admission from an
enforce=restricted namespace, and its login page from an HTTP request made
inside the cluster through the viewer Service, so the second workload is never
vouched for by the first. What that run does not establish is stated in its
own record: the viewer’s OIDC path, the screens behind a session, and whether a
CNI enforces the viewer’s NetworkPolicy. Its screens are documented in the
viewer chapter.
FerroPIX (the patient identity source, on by default)
The CDR keeps no patient-to-record cross-reference of its own by default: it
asks an IHE patient identity source over PIXm (ITI-83) which pseudonym the
clinical domain holds for a patient
([ehr_index]). The chart takes
FerroPIX as the chart dependency
ferropix (oci://ghcr.io/ferrohealth/charts, pinned in Chart.lock), so
its Deployment, Service, NetworkPolicy and configuration are its own chart’s,
and every key under ferropix: is that chart’s value. With
ferropix.enabled (the default) the chart writes
config.ehr_index.provider = "ihe" and config.ehr_index.pixm_url at the
FerroPIX Service, and refuses a values file that sets either too. With the
egress policy on, it adds the rule to FerroPIX’s pods.
You supply FerroPIX’s PostgreSQL URL and sealing key, its issuer, the pseudonymisation domain whose consumer is the CDR’s client, and the CDR’s side of the same facts:
ferropix:
database: {existingSecret: ferropix-db}
dataKey: {existingSecret: ferropix-data-key}
config:
public_base_url: https://pix.example.org
auth:
audience: [https://pix.example.org/fhir]
issuer: [{issuer: https://idp.example.org/realms/pix}]
trust:
issuer: [{issuer: https://idp.example.org/realms/pix, clients: [hospital-source]}]
pseudonym_domain:
- {namespace: urn:oid:2.999.7.1, name: Clinical domain, consumer_client_id: ferroehr}
config:
privacy:
subject_namespaces: [urn:oid:2.999.7.1]
ehr_index:
assigning_authority: urn:oid:2.999.7.1
oauth:
token_endpoint: https://idp.example.org/realms/pix/protocol/openid-connect/token
client_id: ferroehr
scope: system/Patient.rs
secrets:
ehrIndexClientSecret: <the client secret>
secrets.ehrIndexClientSecret is mounted at
/etc/ferroehr-secrets/ehr_index.oauth.client_secret, and the chart injects
the matching client_secret_file; the production overlay reads it from a
Secret you own through extraEnv instead. Another PIX Manager:
ferropix.enabled: false and config.ehr_index.pixm_url. A CDR run on its
own: ferropix.enabled: false and config.ehr_index.provider: local.
The chart pins FerroPIX 0.0.12 by digest (ferropix.image) with the
FerroPIX chart built for it. Since FerroPIX 0.0.8, public_base_url is a
top-level key and the clients that may act as a Patient Identity Source sit
in the [[trust.issuer]] table, apart from the [[auth.issuer]] entry that
names the issuer; FerroPIX refuses the older server.public_base_url and auth.issuer[].clients, so an
upgrade from a chart that pinned FerroPIX 0.0.7 moves those two keys in your
values.
The restricted-profile gate holds FerroPIX’s pods to the Pod Security
Standards restricted profile like every other pod in the release. FerroPIX’s
chart sets the two properties this chart adds for its own pods itself:
enableServiceLinks: false, and a user namespace per pod unless FerroPIX’s
own hostUsers value, under ferropix:, is true.
FerroTERM (a terminology server beside the CDR, off by default)
FerroTERM is the Ferro family’s
FHIR terminology server: R4, R4B and R5 endpoints over a precomputed index, no
JVM, no database, one distroless image. terminology.enabled renders it as a
third workload beside the CDR and the viewer, and points the CDR at it, so
archetype value-set bindings resolve at commit and AQL TERMINOLOGY() expands
through it. The CDR’s own /terminology/* extension routes are a separate
switch: they answer only with config.terminology.api_enabled=true, which is
false by default, and return 404 otherwise whether or not FerroTERM is
running. It is the Helm equivalent of the
compose terminology overlay.
helm upgrade --install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
--version 21.0.0-rc3 -n ferroehr --reuse-values \
--set terminology.enabled=true
That renders a Deployment, a ClusterIP Service, its own ServiceAccount, a
ConfigMap holding the code systems, and a NetworkPolicy. It also writes three
things into the CDR’s ferroehr.toml: config.terminology.server.enabled,
config.terminology.server.fail_on_error from terminology.failOnError, and
config.terminology.server.url at the Service address.
Because the chart writes those three, a values file that contradicts any of them
is refused at render rather than silently overwritten. Three shapes are refused
while terminology.wireCdr is on:
config.terminology.server.url— two answers to “which server resolves a binding” is how resolution ends up pointed somewhere nobody chose.config.terminology.server.enabled: false— the renderedferroehr.tomlwould say the opposite of your values file.config.terminology.server.fail_on_errordisagreeing withterminology.failOnError— same reason, for the posture that decides whether an unresolvable binding is a422.
Set terminology.wireCdr=false to keep your own server and run FerroTERM
unwired. The CDR asks one terminology server, so a server of your own and the
in-cluster FerroTERM are an either-or.
No Ingress is rendered, and no value renders one. The CDR is the only
caller, which is the posture the hosted sandbox
runs and what the SNOMED CT Affiliate Licence asks of a public deployment:
clause 2.2.4 permits a public system to serve SNOMED-encoded data provided users
cannot extract a substantial portion of the release and no fee is charged, and
clause 2.7 requires measures so the release cannot be downloaded except by
authorised users. A published FHIR terminology endpoint answers $expand and
$lookup to every client that reaches it, and FerroTERM has neither
authentication nor a rate limit of its own. Its NetworkPolicy is therefore the
one in this chart that narrows sources by default: ingress admits the CDR’s pods
on the terminology port and nothing else, egress admits DNS.
terminology.networkPolicy.extraIngressFrom adds a peer you decide to admit.
No boolean opens this port the way networkPolicy.ingressAllowAll opens the
CDR’s — and a peer you add is admitted exactly as written, which includes
admitting everything: a namespaceSelector: {} selects every namespace in the
cluster (networking/v1: “if present but empty, it selects all namespaces”). The
values schema therefore refuses an empty peer, and an empty podSelector or
namespaceSelector inside one, so reaching that state takes labels you chose
rather than a blank.
Out of the box it serves the shaped seed, which is synthetic content under
the reserved example.test domain: two code systems and two value sets, the
same content the conformance lane binds to. The chart carries those files and
mounts them as a ConfigMap, so a fresh install from the registry serves
terminology with no repository checkout.
terminology.codeSystems.existingConfigMap swaps in a ConfigMap of your own FHIR
JSON. No licensed terminology ships with FerroEHR or FerroTERM. One caveat with
a ConfigMap of your own: the pod’s rollout annotation hashes the chart’s own seed
files and the NAME you gave, not the contents of an object the chart does not
own, so editing that ConfigMap updates the volume while the running pods keep
serving what they read at boot. Restart the workload after such an edit
(kubectl rollout restart deployment/ferroehr-terminology).
A real release is an index you build off-cluster. Run
ferroterm-build --rf2 <release.zip> --out <dir> on a machine that holds the
archive, put the output on a PersistentVolume, and name that claim in
terminology.index.persistentVolumeClaim. The chart mounts it read-only at
terminology.index.mountPath and sets FERROTERM_INDEX; with no claim named,
the variable is absent and the seed is served alone. The chart provisions no
storage and renders no build Job, for the same reason it provisions no database:
unpacking a licensed archive is a step whose licence terms are yours, not a step
a chart should start on its own.
Naming a claim also switches this Deployment to strategy: Recreate. A rolling
update creates the replacement pod before the outgoing one goes away, and a
ReadWriteOnce volume can only be attached by one node, so the new pod would
sit Pending on the attachment while kubectl reported a progressing rollout.
Recreate stops the old pods first, which costs a short terminology outage at
every upgrade and finishes. If your index volume is ReadOnlyMany, override it
with terminology.strategy={type: RollingUpdate}. FerroTERM’s
loading page covers the
other code systems and their build flags. Size the memory limit for the edition
before you mount it: terminology.resources ships a 1536Mi limit, and the same
FerroTERM page states the resident size of each edition, which the limit must
cover.
The fail posture is the CDR’s shipped one. terminology.failOnError
defaults to false, so a binding the server cannot resolve is accepted; true
turns an unreachable terminology server into a 422 refusal. The
terminology page
explains both. A code the server resolves as outside its value set is refused
with 422 under either posture.
FerroTERM’s licence. BUSL-1.1 from the same Licensor as FerroEHR: non-commercial production use is free, any other production use needs a commercial licence. Its README says which is which. The licence for whatever code systems you load is separate and yours to hold; SNOMED CT needs an Affiliate Licence (free in Member countries, see snomed.org/get-snomed), and every surface showing its content carries the notice clause 8.3.1 prescribes plus the release version and date.
More than one replica needs two other keys. terminology.replicaCount is a
throughput choice — each replica loads its own copy of the index, so watch the
memory limit — and above 1 the pods spread one per node by default, the same
soft constraint the CDR gets. Turn terminology.podDisruptionBudget.enabled on
with it: without a budget a node drain may take every terminology replica at
once, and the CDR’s own budget does not cover these pods. It is off by default
because the shipped replica count is 1 and a budget over a single pod blocks the
drain it exists to survive.
FerroTERM carries the same security context as the CDR and the viewer, and the
chart’s render gate holds all three to the same Restricted profile. The
deployment probe harness reads this workload back off a running cluster too: its
applied runtime posture, the shaped seed reaching the process from the ConfigMap
volume, a refusal on the terminology port from a pod the policy does not name,
and the CDR resolving a bound code through the Service. Its image is
pinned by digest in terminology.image.digest and moves on FerroTERM’s own
release line, so it does not follow appVersion the way the viewer image does.
Because that digest is set, terminology.image.tag alone deploys nothing: the
digest wins, and a tag pointing anywhere else is refused at render rather than
ignored. Clear the digest to deploy by tag.
FerroBRIDGE (the FHIR bridge, off by default)
FerroBRIDGE maps between FHIR R4
and openEHR with FHIRconnect mappings and reaches FerroEHR over ITS-REST.
FerroEHR itself serves no FHIR API for clinical data, so FerroBRIDGE is the
component that does. bridge.enabled renders it as its own workload in the same
release: a Deployment, a ClusterIP Service, its own ServiceAccount, a ConfigMap
holding its configuration file, and a NetworkPolicy.
The chart writes three sections of that file for you:
[cdr]:base_urlis this release’s CDR Service with its REST base path (http://<release>:<service.port><config.rest.base_path>), and[cdr.credentials]names a file mounted from your Secret.[terminology]: withterminology.enabledon,base_urlis the chart’s FerroTERM Service at/r4andwire_versionisr4. With it off the section is absent, and you may set your own underbridge.config.[mappings]:directoryis where the mapping files below are mounted.
The CDR credential comes from a Secret you create. The chart never takes it
as a value. The Secret carries the keys user and password, for a user of the
CDR’s config.auth.basic.users whose scopes grant what FerroBRIDGE calls. The
password is mounted as a file and named by its _file key, so it is in neither
the ConfigMap nor the environment. FerroBRIDGE does not treat the username as a
secret, and the chart passes it as FERROBRIDGE__CDR__CREDENTIALS__USER.
FerroBRIDGE reads an environment value as TOML when it parses as TOML, so a
username made only of digits arrives as a number and is refused at boot. A static
bearer token is no longer a choice: machine-to-machine authentication is HTTP
Basic or the OAuth 2.0 client credentials grant.
kubectl -n ferroehr create secret generic ferroehr-bridge-cdr \
--from-literal=user=bridge --from-file=password=./bridge-password
The mapping files come from a ConfigMap you create, named in
bridge.mappings.existingConfigMap, with each key listed in
bridge.mappings.files. The chart mounts every key on its own: a whole
ConfigMap volume also carries the kubelet’s ..data snapshot directories, and
FerroBRIDGE 0.0.5 reads those as a second and third copy of every file and
refuses the start with a duplicate mapping name. FerroBRIDGE fetches each
template a mapping context names from the CDR at boot, so upload the templates
first; a template the CDR does not hold stops the start. Without a mapping set
FerroBRIDGE still starts, and $tofhir and $toopenehr answer 503.
bridge:
enabled: true
cdr:
existingSecret: ferroehr-bridge-cdr
mappings:
existingConfigMap: ferroehr-bridge-mappings
files:
- vital_signs.context.yml
- blood_pressure.yml
networkPolicy:
ingressFrom:
- podSelector:
matchLabels:
app.kubernetes.io/name: my-fhir-client
It stays inside the cluster until FerroBRIDGE authenticates its callers.
FerroBRIDGE has no inbound authentication yet
(FerroBRIDGE#400), and
whoever reaches its port acts on the CDR with the credential above. So the chart
renders no Ingress for it and no value renders one, the Service is always
ClusterIP, and the NetworkPolicy admits only the peers you list in
bridge.networkPolicy.ingressFrom. An empty list is refused at render, and the
values schema refuses a peer that selects everything (an empty peer, or an empty
podSelector or namespaceSelector inside one). There is no key that admits
every source. Egress admits this release’s CDR, FerroTERM when it runs, and
DNS; bridge.networkPolicy.extraEgress adds a rule for an upstream you name in
bridge.config. When you narrow the CDR’s own networkPolicy.ingressFrom, the
chart adds FerroBRIDGE’s pods to it, and FerroTERM’s policy admits them too.
Anything else goes in bridge.config, rendered into the same file with
the keys of FerroBRIDGE’s
configuration reference.
FerroBRIDGE refuses an unknown key at boot. The render refuses a key the chart
writes (cdr.base_url, cdr.credentials, server.listen, terminology while
FerroTERM runs, mappings.directory while the chart mounts the mappings) and
any secret-shaped key, such as a password, a token or cdm.url: set its
_file sibling and mount the file with bridge.extraVolumes and
bridge.extraVolumeMounts. FerroBRIDGE’s FHIR facade ([facade]) keeps its
identity map in a file it writes, and the root filesystem is read-only, so
turning the facade on also takes a PersistentVolumeClaim of yours mounted with
those two keys and named in facade.identity_store, with
bridge.strategy={type: Recreate} when the claim is ReadWriteOnce. Back that
volume up with the CDR’s database.
Probes use FerroBRIDGE’s own endpoints: liveness /health/liveness, readiness
/health/readiness, which answers 503 while the CDR or FerroTERM is
unreachable. FerroBRIDGE carries the same security context as the other
workloads and the release-wide hostUsers setting, and the render gate holds
it to the same Restricted profile. Its image is pinned by digest in
bridge.image.digest and moves on FerroBRIDGE’s own release line, never with
appVersion; a tag other than the pin is refused while the digest is set. The
deployment probe harness boots it on a cluster and reads back its runtime
posture, its admission, the credential being used for the template fetch, a
refusal on its port from a pod outside ingressFrom, and its readiness from a
pod inside it. FerroBRIDGE’s own deployment documentation is at
https://ferrobridge.eu/docs/operate/container.html.
Per-domain backups (one CronJob per domain, off by default)
backup.enabled renders one CronJob per pseudonymisation domain:
backup.clinical.schedule dumps the clinical schemas, backup.party.schedule
dumps the identities, backup.linkage.schedule dumps the party-to-EHR map, and
each writes to its own existing claim (backup.clinical.persistentVolumeClaim,
backup.party.persistentVolumeClaim,
backup.linkage.persistentVolumeClaim).
The image is backup.image.repository, which carries pg_dump.
A fourth job renders for the audit domain when database.audit.existingSecret
gives it a database of its own. While the trail shares the clinical database
the clinical dump names its schema and nothing more is needed; once it is
somewhere else, that dump cannot reach it and nothing else would take it, so
backup.audit.schedule, backup.audit.existingSecret and
backup.audit.persistentVolumeClaim become required the same way the other
three are, and the clinical dump stops naming the audit schema.
The chart refuses to render rather than let the separation collapse quietly: a missing claim is an error naming the value, and naming the same claim for any two domains is an error too, because one volume holding two of them — the clinical record, the identities, or the map that joins them — is the state per-domain backups exist to prevent.
Each job needs its own backup credential, named by
backup.clinical.existingSecret, backup.party.existingSecret,
backup.linkage.existingSecret and, when it renders,
backup.audit.existingSecret, and
the render is refused without them. It cannot be the pool’s credential: each
domain’s runtime role is revoked from the other domains, so a dump taken through
one would be silently partial. Give each domain a read-only role on that
domain’s schema alone. Every job reads its DSN from a mounted file, never an
environment variable.
The chart provisions no storage. Create the claims yourself, and give them
different access control; that part no chart can do for you. The restore
procedure, and the ferroehr db verify gate that judges it, are on the
Operating page.
Staying available while things move
Five defaults keep the API serving through the events that routinely interrupt it. None needs configuring; each is listed because the reason matters when you tune it.
Replicas land on different nodes. With nothing telling the scheduler
otherwise, two replicas can share one node and one node failure is a total
outage. The chart ships a soft spread constraint (maxSkew: 1 over
kubernetes.io/hostname, whenUnsatisfiable: ScheduleAnyway) so replicas
prefer separate nodes but a single-node or full cluster still schedules them
rather than leaving a pod Pending. Setting topologySpreadConstraints
replaces it wholesale, so give the complete constraint including its own
labelSelector; add a topology.kubernetes.io/zone entry if your cluster spans
zones.
A terminating pod stops receiving requests before it shuts down. Deleting a
pod removes it from the EndpointSlice and sends SIGTERM concurrently, and
the removal still has to propagate to every node. preStopSleepSeconds (default
5) holds the container for that window first. It uses the native sleep hook
action rather than an exec hook, because the image ships no shell to run one.
A node drain does not hang on unhealthy pods. The PodDisruptionBudget sets
podDisruptionBudget.unhealthyPodEvictionPolicy: AlwaysAllow, the
documented recommendation.
The API default, IfHealthyBudget, makes a drain wait for pods to become
healthy, which never completes when they are unhealthy because of the drain.
A migration interrupted by a drain is not counted as a failure. The
migration Job carries a podFailurePolicy that ignores pod failures caused by
disruption, so ordinary cluster maintenance during a release cannot exhaust
migrations.job.backoffLimit and fail the upgrade with no migration error
anywhere in the logs.
A reused connection is not closed under a client. The server closes a
kept-alive connection only after config.server.idle_timeout_ms (120 s by
default) with no request, and counts config.server.header_read_timeout_ms
from the first byte of each request. Keep the idle limit longer than the idle
timeout of your clients (90 s for a Go client) and of the ingress controller or
load balancer in front, so they close an idle connection first. A client that
sends a request into a connection the server is closing gets no answer, and a
client that never retries a POST reports the commit as failed.
Two more are available and off by default. service.trafficDistribution: PreferSameZone keeps traffic inside the caller’s zone, for lower latency and
inter-zone cost, at the price of even load, so measure before setting it. And
autoscaling.behavior passes scaling policies straight through; the documented
defaults already scale up immediately and wait out a five-minute stabilization
window before scaling down, so change it only to be more conservative.
Upgrades
Migrations are append-only (a schema change is a new file, never an edit to
an applied one) so a rolling upgrade stays compatible with the previous schema
during the window where both versions run: additive DDL first, destructive
changes in a later release once all pods are on the new version. Keep
replicaCount at two or more (or autoscaling.enabled) and the default
PodDisruptionBudget so upgrades and node drains never fully interrupt the API;
the default terminationGracePeriodSeconds covers the binary’s shutdown drain.
Roll back by re-pinning the prior image tag or digest.
A change anywhere under config, config.files or secrets changes the
checksum/config pod annotation, so helm upgrade rolls the pods for a
configuration-only change too, including a rotated secret or an edited ABAC
policy, both of which are read at boot and would otherwise reach the volume while
every running pod kept using the old value.
helm uninstall removes everything the chart created; the chart declares no
PersistentVolumeClaim, so nothing is left behind; your database, and the Secret
holding its DSN, are yours and survive.
Preview an upgrade against what you have installed with
helm diff, or render the new chart version and read it:
helm template ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3 \
-n ferroehr -f my-values.yaml | less
Working from a checkout instead, deploy/helm/validate.sh runs the chart’s full
render gate: the helm-version pin, lint, YAML validity, the structural
Restricted-profile and selector-immutability gates, the secret-leak gate, the
values-schema probes, and the golden-render diff. It is the same gate CI runs on
every change to the chart, so a local run and a pull request agree by
construction.
Check your values before you deploy them
A render that lints is not a deployment that boots. validate.sh never runs the
server, so it cannot see a configuration the server refuses: a missing
authentication mechanism, an HS* algorithm, a password hash that is not a
real Argon2id PHC string, SMART enabled without its public base URL. Every one of those renders perfectly and crash-loops the pod. The
script prints exactly which properties it does not check, on success as well as
on failure, so a green run is not mistaken for a working deployment.
The check that closes that gap runs the image against your rendered configuration:
FERROEHR_IMAGE=ghcr.io/ferrohealth/ferroehr:5.6.0-rc3 \
deploy/helm/ci/boot-check.sh my-values.yaml
It renders the chart, mounts the ConfigMap and the Secret exactly as the
Deployment does (with their real values, not placeholders) replays the
declared environment, and runs ferroehr config check inside the image. Point
FERROEHR_IMAGE at the tag you intend to deploy: the answer is specific to that
image, since a key your values carry may be newer than the server that has to
read it.
It validates configuration only; it opens no socket, so it cannot tell you the issuer resolves, the broker is reachable or the database accepts the DSN. CI runs the same script over every values overlay the chart ships.
Cluster hardening
The Kubernetes chapter documents the chart. This section is the other half: an audit of this deployment against the OWASP Kubernetes Security Cheat Sheet, split by who can actually apply each control.
That split is the point. A workload chart controls its pod security context, its resource bounds, its NetworkPolicy, its ServiceAccount and how it consumes secrets. It cannot patch a node, set an API-server flag, configure etcd, authenticate a kubelet, or install an admission controller. For those, the honest deliverable is not a setting but a statement of what you owe and what happens if you do not, because a deployment that inherits an unchecked assumption is not secured by the chart’s own hardening.
Important
Several controls here are ones no application-level hardening can compensate for. If the kubelet accepts anonymous requests, or anything untrusted can read etcd, then every control this project ships (non-root, read-only rootfs, dropped capabilities, RBAC, ABAC, the audit trail) is bypassable, because the attacker is beneath the layer they operate in. Those are marked where they appear.
Claims about what the chart renders are checked against the chart’s own render gates. Claims about what a cluster then applies come from the deployment probe harness, which reads each answer from the layer that actually decides it (the container runtime’s own spec for the security context, the API server for admission, the EndpointSlice for readiness) and which declares in its own record what it did not exercise, so silence is never read as coverage.
The five pages
| Page | Covers |
|---|---|
| The cluster: hosts, control plane, access | node and OS hardening, the supported version window, rolling upgrades, advisories, etcd, the port surface, cluster API access, cluster RBAC, kubelet authentication |
| Images: build, provenance, scanning | the distroless image and what it costs you during an incident, keyless signing and the identity to trust, copyable admission policies, scanning after release, the full supply-chain map |
| The workload: security context & admission | the applied security context, user namespaces, AppArmor, Restricted-profile compliance versus enforcement, sandboxing, kernel modules |
| Namespaces, network & policy | namespace scoping, one instance per organisation, the service-mesh decision, which admission engine, resource bounds in four layers, deny-by-default egress |
| Secrets, detection & response | Secrets at rest and exactly what ours contain, runtime detection on a shell-less image, per-replica alerting, breach containment and credential rotation, the two log streams, managed control planes |
The ownership map
| Cheat-sheet control | Owner | Where |
|---|---|---|
| Host hardening, OS patching, node firewall | operator | cluster |
| Supported Kubernetes version window | operator (chart states the floor) | cluster |
| Rolling upgrades rather than mutating containers | chart | cluster |
| Kubernetes security advisories | operator | cluster |
| Kubernetes Dashboard | operator (we ship none) | cluster |
| etcd access + encryption at rest | operator | cluster |
| Control-plane and kubelet ports | operator | cluster |
| The workload’s own port surface | chart | cluster |
| Cluster API access control, MFA | operator | cluster |
Cluster RBAC (Node,RBAC, NodeRestriction) | operator | cluster |
| The workload’s own RBAC | chart, deliberately none | cluster |
| Kubelet authentication/authorization | operator | cluster |
| Minimal, current, authorized images | chart/CI | images |
| Image provenance at admission | operator (we publish the attestations) | images |
| Continuous scanning of published images | CI | images |
| Supply chain | CI, with two gaps | images |
| Pod/container security context | chart | workload |
| Pod Security Admission enforcement | operator (one kubectl label) | workload |
| Container sandboxing | neither; a recorded decision | workload |
| Kernel-module loading | satisfied by the chart | workload |
| Namespace isolation | chart (namespace-scoped by construction) | network |
| Service mesh | neither; a recorded decision | network |
| Centralized policy management | operator, for admission only | network |
| Container resource bounds | chart | network |
Namespace ResourceQuota/LimitRange | operator | network |
| Egress restriction | chart (mechanism) + operator (destinations) | network |
| Secrets encrypted at rest | operator | secrets |
| Runtime/syscall detection | operator (unusually cheap here) | secrets |
| Replica behavioural deviation | operator (from metrics we publish) | secrets |
| Breach containment + credential rotation | operator (procedure is ours) | secrets |
| Cluster API audit logging | operator | secrets |
| Container logging | chart/app | secrets |
| Managed control plane | provider | secrets |
Final thoughts: the three practices, checked
The cheat sheet closes with three practices rather than controls. Checking them against what this project actually ships, rather than claiming them:
“Embed security into the container lifecycle as early as possible”:
evidenced. Security here is CI, not a review checklist: the chart’s render gate
parses every rendered object and asserts the Restricted profile per container,
the golden renders pin the exact bytes, the secret-leak gate refuses a credential
that would reach a ConfigMap, the image scanners run at build and again on a
schedule against the published tags, cargo deny runs on every change, and
zizmor and CodeQL read the workflows themselves. Each of those refuses a merge
rather than filing a comment, and each of the chart-side ones has been watched to
fail deliberately; a gate nobody has seen fail is a gate nobody knows works.
“Use Kubernetes-native controls to reduce operational risk”: evidenced. The chart’s controls are the platform’s own: a NetworkPolicy rather than an in-app firewall, a security context and the Restricted profile rather than a hardening sidecar, resource limits rather than in-process throttling alone, probes rather than an external watchdog, a user namespace rather than a trusted UID, and no service mesh because what one would add is either already provided or not needed at this shape. The one place we did not take a native control is Pod Security Admission, and that is because labelling a namespace is not a chart’s call, stated as the operator’s rather than quietly skipped.
“Leverage the context Kubernetes provides to prioritize remediation”: partly,
and here is the honest version. The generic form of this practice is to rank
findings by whether the affected code is reachable in your deployment. This
project’s answer is the OpenVEX documents: when a scanner reports advisories
in a privilege-dropping helper that opens no socket and parses no untrusted
input, the response is a machine-readable not_affected statement with a
controlled-vocabulary justification and an impact_statement a reader can
check, not a silenced ignore list, and not a rebuild that fixes nothing.
That mechanism has a cost, and stating it is what makes the claim honest: a VEX
statement is a claim about today’s binary, and it must be re-checked on every
base-image bump. When upstream rebuilds that helper, statements about it become
obsolete, and a stale not_affected is worse than no VEX at all: it is an
argument that has quietly stopped being true while still suppressing its finding.
The scheduled scan is what surfaces a finding whose statement no longer matches,
and the re-check itself is a human obligation, not an automated one.
The cluster: hosts, control plane, access
The controls on this page are almost all the operator’s, and several are ones no application-level hardening can compensate for. Read them as what you owe the CDR running on top, not as background.
- Host hardening and the version window
- Upgrades roll, they do not replace
- Security advisories
- The dashboard we do not ship
- etcd and what our secrets contain
- Ports, theirs and ours
- Cluster API access
- Cluster RBAC, and why this chart needs none
- Kubelet access
Host hardening and the version window
Operator’s. Keep the node OS patched, hardened and firewalled; a workload chart cannot reach any of it. The cheat sheet’s list applies unchanged.
The part worth stating precisely is the version window. Upstream Kubernetes maintains release branches for the three most recent minor releases, each receiving roughly a year of patch support (kubernetes.io/releases). A cluster below that window receives no security backports at all: a published CVE in the API server or kubelet simply stays open on it.
The chart’s kubeVersion: ">=1.36.0-0" is a compatibility floor, not a
statement about that window. It sits at the window’s newest release because 1.36
is where the newest field the chart renders (hostUsers, user namespaces)
became stable, which is what lets every field it renders apply unconditionally
instead of being gated into silence on the clusters that most needed it. The cost
is real and worth naming: an operator one minor behind cannot install this chart.
See Beyond Restricted: the user
namespace for what
that buys.
A version gate is a silent absence; a floor is a loud refusal. For a workload holding PHI the loud one is correct, so the chart declares the floor and refuses below it rather than installing with a safety property quietly inapplicable.
If you run outside the supported window, you have accepted that the platform
beneath this CDR is unpatched, and no setting in values.yaml changes that.
Upgrades roll, they do not replace
Chart’s. The cheat sheet asks that a new version arrive by rolling update rather than by mutating a running container. The chart sets the strategy explicitly rather than inheriting the API server’s percentages:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # served capacity never drops
maxUnavailable: 0 means a replacement pod must pass its readiness probe before
an old one is removed. At the default two replicas this is identical to what
Kubernetes computed from 25% (it rounds down to zero); above two replicas the
percentage default would have taken a pod out of service, and this does not. The
trade-off is real and fails in the safe direction: on a cluster with no spare
scheduling capacity a maxUnavailable: 0 rollout stalls visibly instead of
proceeding at reduced capacity.
Observed on a live cluster rather than argued: a probe pod drove a long run of
sequential requests at the Service through a full helm upgrade, with
Killing/Started events for both replicas inside the window, and recorded no
failed request, while the ReplicaSet history showed the old revision scaled to
zero as the new one scaled up (a roll, not a recreate) and the Deployment reported
Available=True (MinimumReplicasAvailable) throughout.
Security advisories
Operator’s, with a decision recorded on our side.
You must follow
kubernetes-announce and the
official advisory
feed.
Kubernetes CVEs are announced there, and nothing in this project will tell you
about them.
This project does not track Kubernetes platform advisories, deliberately. The decision, so it is not mistaken for an oversight: we run no cluster and cannot act on a node or control-plane CVE, and a watcher that opened issues we could only close as “the operator’s” would be noise that trains people to ignore it. What we do track is what we ship: dependency advisories on every change, our own container images on a schedule, and the openEHR specifications through release watchers.
A vulnerability in Kubernetes itself is reported to Kubernetes, not to this project. A vulnerability in FerroEHR, including in the chart, comes to us, through the security policy published with the source.
The dashboard we do not ship
Operator’s. This chart installs no Kubernetes Dashboard, and nothing in it depends on one. If you install one, the cheat sheet’s conditions apply: never expose it publicly, give it a limited-privilege ServiceAccount, and put an authenticating reverse proxy in front of it if it must be reachable at all.
This section stays because the same reasoning applies to two surfaces that are ours, and an operator hardening “the dashboard” should find them here:
/management/*: the ops-introspection surface (info,prometheus,metrics,env,loggers,flamegraph). It is a privileged read onto the deployment:envrenders the effective configuration andflamegraphprofiles the live process. The chart ships the master switch on and every endpointoff, so nothing is exposed until you name an endpoint and a level. Setconfig.management.portto move the whole surface onto its own listener so it is never reachable on the clinical API port.- The viewer: a separate image with its own Deployment, which this
chart can render but leaves off (
viewer.enabled). It consumes the CDR strictly over the REST API and holds no database credential, but it is a privileged UI and belongs behind the same authenticating edge you would put in front of a dashboard, which is whatviewer.auth.oidc.enabledandviewer.ingress.enabledare for.
etcd and what our secrets contain
Operator’s, and this is one of the controls that cannot be compensated for.
The cheat sheet’s requirements stand: mutual TLS between the API server and etcd, etcd reachable from nothing else, and separate instances or ACLs to bound what a component can read.
What makes it concrete for this deployment: anything that can read etcd can read every Secret in the cluster, and this release’s Secrets are not incidental. They hold the database DSN (the credential that reaches patient data) plus the version-signing passphrase, any Basic user’s Argon2id hash, and any terminology-server client secret. So “etcd is a cluster concern” is true and insufficient: for this workload etcd is the confidentiality boundary of the credentials that reach PHI. The full inventory is what our Secrets contain.
Two mitigations you can apply without touching etcd’s network posture:
- Encryption at rest for Secrets: not on by default in Kubernetes. See Kubernetes’ encryption-at-rest configuration, which is a cluster-level setting an operator applies once.
- An external secret manager, which removes the credential from etcd
entirely: every secret this chart carries has a
*_fileroute or anexistingSecretroute, so the value can arrive from a CSI driver or an operator-synced Secret rather than from chart values.
Ports, theirs and ours
Operator’s, for the cluster’s ports. Block untrusted access to the
control-plane ports 6443 (API server), 2379-2380 (etcd), 10250-10257
(kubelet and controller/scheduler), and the worker ports 10248-10250. An
exposed 10250 is the kubelet case below.
Ours, for the workload’s ports, and in the default posture the surface is one port:
| Port | Serves | Who should reach it |
|---|---|---|
8080 (service.port) | the openEHR REST API, the always-on /health family, and /management/* when config.management.port is unset | your ingress controller or gateway, not the internet directly |
config.management.port (unset by default) | /management/* on its own listener when set | operators and your Prometheus, never clinical clients |
3000 (viewer.service.port) | the viewer, only when viewer.enabled | your ingress controller, in front of an authenticating edge |
Read off the running pod rather than the template, from the listening sockets in the container’s own network namespace: the default posture binds one port and nothing else:
LISTENING TCP ports in the container netns: [8080]
The shipped NetworkPolicy narrows inbound traffic to that port list. Whether the narrowing is enforced is a property of your CNI, not of the object: on a cluster whose network plugin does not implement NetworkPolicy the object is accepted, stored and displayed with no effect and no warning. Verify it the way the managed-cluster section shows: attempt a connection the policy should refuse, from a pod in another namespace, and require it to fail.
Two limits stated in full under Namespaces, network & policy
§Ingress
and repeated because they matter here: with networkPolicy.ingressFrom empty the
rule carries no from and therefore admits every source, including other
namespaces (only the port list is narrowed in that state; set
networkPolicy.ingressAllowAll: false to have the chart refuse to render that
state at all, and the same pair exists for the viewer under
viewer.networkPolicy); and a NetworkPolicy is only as real as the CNI that
implements it.
Cluster API access
Operator’s. Control access to the Kubernetes API: authenticate, then authorize, and deny by default.
- Recommended routes: OIDC, a managed-IAM integration, or user impersonation, with MFA on the identities that can reach the cluster API.
- Not suitable for production: static token files, long-lived X.509 client certificates, and service-account tokens used as human credentials. They cannot be revoked individually, they do not expire usefully, and they carry no second factor.
Worth drawing explicitly, because it saves a translation: this server implements
the same shape. Authentication then authorization, deny by default, OIDC
preferred over long-lived credentials, and a missing credential distinguished from
a refused one (401 with a challenge versus 403). The
Security chapter is the detail. An operator who understands why
a static token file is a poor cluster credential already understands why
config.auth.basic is a machine and bootstrap mechanism here and a
config.auth.issuer is the production one for people.
Cluster RBAC, and why this chart needs none
Operator’s, for the cluster: run the API server with
--authorization-mode=Node,RBAC and enable the NodeRestriction admission
plugin, so a compromised kubelet cannot edit objects belonging to other nodes.
Ours, and it is an absence on purpose. The chart creates a ServiceAccount and
no Role, RoleBinding, ClusterRole or ClusterRoleBinding at all, with
serviceAccount.automountServiceAccountToken: false. That is not an omission to
be tidied up later: the workload never calls the Kubernetes API, so it needs no
permissions, and it is not given a token with which to try. The same holds for the
viewer’s own ServiceAccount when that workload is enabled. Checkable on a
live release:
helm get manifest ferroehr | grep -cE '^kind: (Role|RoleBinding|ClusterRole|ClusterRoleBinding)'
kubectl -n ferroehr get role,rolebinding
The first prints zero; the second reports no resources. And because the token is not mounted, no service-account token file exists under the pod’s volumes on the node.
If you are reviewing this chart and reaching for a Role to add: don’t. The correct fix for a future feature that genuinely needs the Kubernetes API is a Role enumerating exactly the verbs and resources it needs, plus turning the token mount back on for that ServiceAccount alone, not a broad grant added speculatively.
Kubelet access
Operator’s, and the second control no application hardening can compensate for.
Run every kubelet with --anonymous-auth=false and
--authorization-mode=Webhook so its HTTPS endpoint is not open. Left open, that
endpoint permits arbitrary command execution in any container on the node.
For this deployment, spelled out: an attacker reaching an unauthenticated kubelet gets a process-level foothold in a running CDR: the ability to read the database DSN out of the mounted secret files, to read patient data straight from memory, and to do so beneath the layer where authentication, RBAC, ABAC and the ATNA audit trail operate, so none of them see it and none of them can stop it. Non-root, a read-only root filesystem, an empty capability set and a private user namespace raise the cost of what happens next; they do not prevent the entry.
This is a cluster-configuration control, and it is worth confirming rather than
assuming; an exposed 10250 is a routine finding in real clusters.
Images: build, provenance, scanning
The image half of the audit. Building a minimal image and signing it is ours; requiring a cluster to check that signature before running the image is yours, and this page carries the copyable policies for both engines that can do it.
- The build phase, and what distroless costs
- Image provenance at admission
- Continuous scanning of published images
- The supply-chain map
The build phase, and what distroless costs
Ours. Each build-phase control and what actually satisfies it:
| Control | Satisfied by |
|---|---|
| Minimal image (distroless) | gcr.io/distroless/cc-debian13:nonroot: no shell, no package manager, no libc tooling |
| Image currency | base images and CI job containers pinned by digest, not tag, so a rebuild cannot silently change bytes |
| Vulnerability identification in CI | Trivy over every published image, hadolint over every Dockerfile, plus secret and misconfiguration scanning over the whole tree |
| Continuous scanning after release | a scheduled scan of the published tags; below |
| Authorized images only | signed provenance published; enforcement is the operator’s; below |
| Non-root by construction | the image declares USER 65532:65532 (numeric, so the kubelet can verify it without reading /etc/passwd), and the pod pins runAsNonRoot plus uid 65532 independently |
Three images are published, and they are not equivalent in risk. The server and
the viewer are distroless and carry almost no OS package surface. The
PostgreSQL image is a thin, COPY-only layer over the upstream postgres image
(it adds initialization scripts and nothing else) so its package set is
upstream’s, and its CVEs arrive on upstream’s schedule rather than ours. The chart
deploys none of the second and third: it takes an external DSN and can
optionally render the viewer.
What distroless costs, stated before an incident rather than during one: there
is no shell in the image, so kubectl exec … -- sh does not work. That is the
security property working as intended (an attacker who achieves command
execution finds no interpreter, no curl, no package manager) but it changes how
you debug. Use instead:
kubectl logs(the server logs JSON by default, for a collector),- the always-on
/health/readinessbody, which names the failing dependency, /management/*for the effective configuration, live log filters and an on-demand CPU flamegraph,kubectl debug -it <pod> --image=busybox:1.37 --target=ferroehrstarts an ephemeral container shares the target’s namespaces without adding a shell to the image that ships.
The registry posture: the images are public on GHCR, so a pull needs no credential and there is nothing to leak. Public does not mean trusted, which is the point of the next section: nothing about a public registry stops a cluster pulling a different image with the same name from somewhere else.
Image provenance at admission
The operator’s, and this is provenance nobody in your cluster currently checks.
The publishing lanes attest their artifacts through keyless Sigstore, so a verifier can establish that an artifact came from this repository’s build: the published images carry a signed SLSA v1 provenance attestation, and the chart carries both an attestation and a cosign signature over its digest. Nothing in a cluster requires that check before running an image, and a signature nobody verifies changes nothing about what actually runs. That is what the policies below close.
The identity to trust, read off a real artifact
Each lane signs with a short-lived Fulcio certificate whose subject alternative name is the workflow that issued the token, and whose issuer is GitHub’s OIDC provider. Read it off a published artifact rather than deriving it from a workflow file:
gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:main \
-R FerroHEALTH/FerroEHR --format json \
| jq '.[0].verificationResult.signature.certificate
| {subjectAlternativeName, issuer, sourceRepositoryRef, runnerEnvironment}'
{
"subjectAlternativeName": "https://github.com/FerroHEALTH/FerroEHR/.github/workflows/build-image.yml@refs/heads/main",
"issuer": "https://token.actions.githubusercontent.com",
"sourceRepositoryRef": "refs/heads/main",
"runnerEnvironment": "github-hosted"
}
The SAN’s ref varies with the trigger, and that is the part a policy gets wrong. Each lane runs on more than one ref, so each issues more than one identity:
| Artifact | Signing workflow | SAN on a release build | SAN on a development build |
|---|---|---|---|
| the three images | build-image.yml — the reusable builder both callers use | …/build-image.yml@refs/tags/vX.Y.Z (the release pipeline) | …/build-image.yml@refs/heads/main (via containers.yml) |
| the chart | build-chart.yml — the reusable chart lane | …/build-chart.yml@refs/tags/vX.Y.Z (the release pipeline’s chart leg) | …/build-chart.yml@refs/heads/main (a workflow_dispatch chart-only publish, or the recovery of a release’s chart leg for a tag cut before a re-run could finish it) |
| the release binaries | release-build.yml | …/release-build.yml@refs/tags/vX.Y.Z | (none; the lane only runs on a tag) |
All three prefixed with https://github.com/FerroHEALTH/FerroEHR/.github/workflows/,
and all with issuer https://token.actions.githubusercontent.com.
The release binaries are signed by release-build.yml rather than by the release
workflow because the build lives in a reusable workflow: the certificate
names the workflow that owns the build definition, which is what makes the
--signer-workflow pin below meaningful.
Pick the ref set deliberately, because the choice is a refusal. A policy
matching refs/tags/v… only admits released images and refuses
ghcr.io/ferrohealth/ferroehr:main, correct for production, and the
reason a policy tested against :main appears broken when it is working. A
staging cluster that runs :main needs both refs. Nothing accepts an
arbitrary branch: refs/heads/main is exact, not a prefix match.
Kyverno
The engine chosen here. Two details decide whether this policy works at all:
type: SigstoreBundle. These attestations are GitHub Artifact Attestations, stored in the Sigstore bundle format as an OCI referrer. Kyverno reads that format only under this type; the field defaults toCosign, which looks for asha256-<digest>.sigtag that these images do not have (it returns 404: the bundle is a referrer, not a cosign tag). Requires Kyverno 1.13 or newer.attestations:, notattestors:alone. Kyverno’s own rule is that “eachverifyImagesrule can be used to verify signatures or attestations, but not both”, and what the image lane produces is a signed attestation: there is no detached image signature. A rule withattestors:at the top level therefore fails closed on a perfectly legitimate image.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: ferroehr-image-provenance
annotations:
pod-policies.kyverno.io/autogen-controllers: none
spec:
background: false
webhookTimeoutSeconds: 30
rules:
- name: verify-ferroehr-provenance
match:
any:
- resources:
kinds: [Pod]
namespaces: [ferroehr]
verifyImages:
- imageReferences:
- "ghcr.io/ferrohealth/ferroehr"
- "ghcr.io/ferrohealth/ferroehr:*"
- "ghcr.io/ferrohealth/ferroehr-viewer*"
# Sigstore bundle format, GitHub Artifact Attestations. Omitting
# this defaults to Cosign, which looks for a signature that does not
# exist and refuses every image.
type: SigstoreBundle
failureAction: Enforce
attestations:
- type: https://slsa.dev/provenance/v1
attestors:
- count: 1
entries:
- keyless:
issuer: https://token.actions.githubusercontent.com
# Released images only. For a staging cluster that runs
# the development tag, make the group
# `(heads/main|tags/v.+)`.
subjectRegExp: '^https://github\.com/FerroHEALTH/FerroEHR/\.github/workflows/containers\.yml@refs/(tags/v.+)$'
rekor:
url: https://rekor.sigstore.dev
conditions:
- all:
- key: '{{ buildDefinition.buildType }}'
operator: Equals
value: https://actions.github.io/buildtypes/workflow/v1
Add ghcr.io/ferrohealth/ferroehr-postgres* to imageReferences only if you run
that image in the namespace; the chart never installs it.
failureAction sits on the verifyImages entry: the spec-level
validationFailureAction is deprecated in the CRD (“use validationFailureAction
under the validate rule instead”), and a verifyImages rule has no validate
block. mutateDigest, verifyDigest and required all default to true, which
is what you want: a tag is rewritten to the digest that was verified, and an image
with no attestation is refused rather than passed.
sigstore-policy-controller
If you already run it. The equivalent two details:
signatureFormat: bundleon the authority. The default islegacy(cosign’s own), which cannot read these attestations. Requires policy-controller v0.13.0 or newer.- an
attestations:entry, because in bundle format policy-controller supports only attestations, not plain signatures.
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: ferroehr-image-provenance
spec:
images:
- glob: "ghcr.io/ferrohealth/ferroehr**"
authorities:
- keyless:
url: https://fulcio.sigstore.dev
identities:
- issuer: https://token.actions.githubusercontent.com
# Same group as the Kyverno policy: `(heads/main|tags/v.+)` for a
# staging cluster that runs the development tag.
subjectRegExp: '^https://github\.com/FerroHEALTH/FerroEHR/\.github/workflows/containers\.yml@refs/(tags/v.+)$'
signatureFormat: bundle
attestations:
- name: require-slsa-provenance
predicateType: https://slsa.dev/provenance/v1
Note
What has been checked, and what has not. The identity, the issuer and the predicate type above are verified first-hand against the published images with
cosign verify --certificate-identity-regexp …, which admits them and refuses both an unsigned image and, under a tags-only pattern, a:mainimage, so the matcher is neither vacuous nor accidentally permissive. The manifests are checked field by field against the publishedClusterPolicyandClusterImagePolicyCRDs. Neither policy has been exercised by a running admission controller, and the Kyverno CLI is no substitute:kyverno testreports averifyImagesrule asExcludedand returns the same verdict whichever result you assert. Before enforcing, run the policy inAudit(Kyverno) or as awarnpolicy (policy-controller) long enough to see one real deployment pass.
The chart is signed too, and verifying it is a release-time check rather than a
per-pod one: an admission controller sees pods and images, never the Helm
artifact a human pulled. The chart’s own commands are on the
Kubernetes page; its identity is the
build-chart.yml row in the table above.
Should the chart ship an admission policy? No, and the reason is structural: an
admission policy is cluster-scoped and governs workloads the chart knows nothing
about, while the chart deliberately renders no cluster-scoped object at all (see
namespaces). A
ClusterPolicy in this chart would mean helm uninstall removing a control that
other releases had come to depend on. The chart’s contribution is the policy
document, here, versioned with the lanes whose identity it encodes.
Without an admission controller, the manual equivalent is a release-time check.
Add --signer-workflow to insist on the lane as well as the repository; without
it you are trusting that some workflow here signed the image:
gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:main \
-R FerroHEALTH/FerroEHR \
--signer-workflow FerroHEALTH/FerroEHR/.github/workflows/build-image.yml
Substitute a vX.Y.Z tag for main on a release; the signer workflow is the
same.
Important
Signing landed in the publishing lanes during the
3.17.4cycle, so image tags from before it answerHTTP 404: Not Found, and there is nothing to verify, which is the correct answer and not a verification failure to work around. Pin a current version instead.
Then deploy by digest (image.digest), so what you verified is what runs: a
tag can be moved afterwards, a digest cannot.
Continuous scanning of published images
Ours. CI scans images at build time, which catches what was known when they were built and nothing after. A CVE published the week after a release applies to the image people are running, so the published tags are re-scanned on a weekly schedule: all three images at the tag a user pulls, with the same severity floor and the same adjudicated exceptions as the build-time scan, and the OpenVEX documents applied so an accepted finding stays accepted with its argument attached.
A finding does two things, because either alone fails quietly: it opens (or comments on) a tracking issue, and it fails the run. A red scheduled run nobody looks at is not a control, and an issue with no failing check can be closed without the finding being addressed.
The PostgreSQL image is what this lane exists for, and it has already fired: the
3.17.5 release moved that image onto a rebuilt PostgreSQL 18.4 base to pull in
current Debian packages, changing nothing about FerroEHR itself. The distroless
images carry almost no OS package surface, and when the lane was written none of
the three published images carried a fixable HIGH or CRITICAL finding.
The remediation side is standing machinery, not a per-incident scramble. The PostgreSQL image applies Debian security updates at its own build time, so an OS-package fix published between upstream base rebuilds reaches the image at the next release rather than waiting on someone else’s cadence; a second weekly lane watches the pinned base itself and opens an issue when a newer PostgreSQL patch tag exists or the pinned tag is re-pointed upstream; and the exact published-image scan is reproducible locally against a rebuilt candidate, so a fix is proven clean before it merges.
The supply-chain map
Each cheat-sheet supply-chain control, and the artifact that satisfies it, so a reader can check rather than trust:
| Control | Satisfied by | Check it yourself |
|---|---|---|
| Trusted, minimal base images | gcr.io/distroless/cc-debian13:nonroot, digest-pinned; build stages pinned by digest too | grep FROM docker/Dockerfile |
| Vulnerability scanning in CI | Trivy over every published image, HIGH/CRITICAL with a fix available | the image vulnerability scan job log |
| Scanning after release | a weekly scan of the published tags | .github/workflows/image-scan.yml |
| Dockerfile linting | hadolint, with adjudicated exceptions in .hadolint.yaml | the Dockerfile lint job |
| Secret + misconfiguration scanning | Trivy’s secret and misconfig scanners over the tree | the tree-scan job |
| Dependency advisories | cargo deny on every change, plus a scheduled latest-dependencies lane | cargo deny check |
| Signed images | a Sigstore keyless SLSA v1 provenance attestation per image | gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:<tag> -R FerroHEALTH/FerroEHR |
| Signed chart | an attestation plus a cosign signature over the chart digest, both read back from the registry before the lane reports success | gh attestation verify oci://ghcr.io/ferrohealth/charts/ferroehr:<version> -R FerroHEALTH/FerroEHR |
| SBOM | an SPDX SBOM written onto the image index by the builder; a CycloneDX dependency-graph SBOM attached and Sigstore-attested per released binary | docker buildx imagetools inspect <image> --format '{{json .SBOM}}' |
| Adjudicated findings carry their argument | OpenVEX documents under security/vex/, applied by the scheduled scan | read the impact_statement in the document |
| Secured CI/CD | every uses: digest-pinned, permissions: {} by default, no context interpolated into a shell, zizmor and CodeQL over the workflows themselves | the zizmor job |
| No long-lived registry token | crates.io Trusted Publishing (OIDC); GHCR uses the ephemeral workflow token | the crates.io leg of .github/workflows/release.yml |
| Independent grade | OpenSSF Scorecard, computed by someone other than us | the Scorecard badge |
Two gaps remain, stated here rather than left out of a page that otherwise reads as complete:
- Nothing verifies the signatures at admission. We sign; no cluster is required to check before running an image. The policies to close it are above, and neither has yet been exercised by a running admission controller, which is why the instruction there is to run them in audit mode first.
- Provenance exists only from the
3.17.4cycle onward. Images published before the signing lane landed carry no attestation and never will, because a published artifact is not replaced.gh attestation verifyon those returns a 404, which is the correct answer and not a verification failure to work around.
The workload: security context & admission
The pod-level controls: what the chart asks for, what the runtime actually applies, and the one command that turns compliance into enforcement.
- The security context, and what keeps it true
- Beyond Restricted: the user namespace
- AppArmor, and why it is not on by default
- Pod Security Admission: complying versus being refused
- Sandboxing is not a substitute for instance separation
- Kernel modules: already impossible
The security context, and what keeps it true
Ours, and settled. Read back from the running container through the
container runtime’s own view of the pod, not from values.yaml:
process.user : {'additionalGids': [65532], 'gid': 65532, 'uid': 65532}
noNewPrivileges : True
capabilities.bnd : None ← an EMPTY bounding set, not "default minus ours"
root.readonly : True
seccomp default : SCMP_ACT_ERRNO
RW mounts : /tmp (plus the kernel/kubelet-managed /proc, /dev/*, /etc/hosts)
RO mounts : /etc/ferroehr /etc/ferroehr-secrets /sys /sys/fs/cgroup
Three things worth drawing out. The capability bounding set is empty, so the
drop is total at the kernel level rather than a subtraction from a runtime’s
default set. readOnlyRootFilesystem: true needed exactly one writable
path (the chart’s own /tmp emptyDir) and no per-integration surprise, which
is what makes it safe to keep rather than the setting an operator relaxes during
the first incident. And the server pod carries no init containers and no
sidecars, so the context above is the whole pod.
What keeps it true is not this page. deploy/helm/validate.sh parses the rendered
objects and asserts the Restricted fields per container, for every workload in
the render (including the optional viewer and the migration Job) and
the golden renders pin the exact bytes, so even a changed default fails a diff.
Both run in CI on any change to the chart. That structure is deliberate: the gate
this replaced grepped the rendered file for field names, so one compliant
container vouched for every other one, and a second workload could ship
non-compliant while the gate stayed green. The gate also fails when it finds
nothing to check, because a pod-less render reporting “all containers compliant”
is a false green, and it fails when a render carries no NetworkPolicy, or when a
multi-replica Deployment has neither spread constraints nor affinity.
A template edit that drops securityContext.readOnlyRootFilesystem or
securityContext.allowPrivilegeEscalation therefore fails a job, not a review.
Beyond Restricted: the user namespace
The Restricted profile stops a container from asking for privilege. It does nothing about what a container’s UID means on the node, and under the Kubernetes default, uid 65532 in the pod is uid 65532 on the host, so a container escape arrives as a real host user with whatever that user can reach.
The chart closes that by default. Every pod it renders carries hostUsers: false,
which puts the pod in its own user namespace and maps its UID range onto an
unprivileged host range. Read off a running pod:
$ cat /proc/self/uid_map
0 838860800 65536
$ id
uid=65532 gid=65532 groups=65532
The process still sees uid 65532; the kernel sees an offset host UID inside that mapped range. Root inside the pod (which this workload never uses, but a compromised process might reach for) maps to the base of the range, which owns nothing. Capabilities granted inside the namespace do not apply outside it.
This is why the chart’s kubeVersion floor is 1.36: that is the release where
user namespaces went stable
(KEP-127),
and the floor is what lets the field render unconditionally instead of being
gated and silently absent on the clusters that most needed it.
If your nodes cannot support it, the pod does not start, which is the failure
mode you want rather than a silent downgrade. The requirement is a Linux node
whose runtime implements idmapped mounts (containerd 2.0 or newer, CRI-O 1.25 or
newer). Set hostUsers: true to opt out; the chart then omits the field entirely
rather than stating the API default, so a future cluster-wide default can still
apply.
The same reasoning drives podSecurityContext.supplementalGroupsPolicy: Strict,
so the process gets only the groups the manifest names: a group baked into an
image cannot widen file access. And the chart’s render gate asserts that this
isolation set is identical across every workload of a release: a viewer that
shared the host user namespace while the server did not would be a posture nobody
could state in one sentence, and that is exactly the shape of drift a second
workload introduces. That is also why hostUsers is a release-wide key rather
than one per workload.
AppArmor, and why it is not on by default
securityContext.appArmorProfile (stable since Kubernetes 1.31) is a further
confinement layer, and it is left off deliberately, because it is not free: a node
without AppArmor rejects the pod outright rather than ignoring the field.
Observed on a node that does not carry it:
STATUS: AppArmor
Warning AppArmor pod/… Cannot enforce AppArmor: AppArmor is not enabled on the host
Turn it on once you know your nodes carry it: most Debian and Ubuntu nodes do, while Docker Desktop and several minimal distributions do not:
securityContext:
appArmorProfile:
type: RuntimeDefault
Pod Security Admission: complying versus being refused
Meeting the Restricted profile and being refused when you stop meeting it are different properties, and only the first is ours.
Restricted compliance, field by field, so the claim is checkable against the standard rather than asserted:
| Restricted requires | The chart sets |
|---|---|
hostNetwork/hostPID/hostIPC unset | none set |
| no privileged containers | securityContext.privileged: false |
allowPrivilegeEscalation: false | set |
capabilities dropped to ALL (only NET_BIND_SERVICE may be added) | drop: [ALL], none added |
runAsNonRoot: true | set, pod and container, uid/gid 65532 |
seccompProfile.type RuntimeDefault or Localhost | RuntimeDefault, pod and container |
| no hostPath volumes | only emptyDir and projected volumes (the latter carrying the ConfigMap and Secret sources) |
readOnlyRootFilesystem (hardening beyond Restricted) | true |
The enforcement half is the operator’s, and it is one command:
kubectl label --overwrite namespace ferroehr \
pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/audit=restricted \
pod-security.kubernetes.io/warn=restricted
The install notes print the same command as a prerequisite, with
enforce-version=latest so the profile does not silently loosen as the cluster
moves.
Why the chart does not do this itself, recorded so it is not re-litigated:
Helm installs into a namespace that already exists (or one
helm --create-namespace creates, which is the CLI’s action and not a template),
so a chart that declared its own release namespace would fight the tool, and
helm uninstall would then delete a namespace holding objects the release does
not own. More fundamentally, a PSA label is namespace-wide policy: it governs
every workload in that namespace, including backup jobs, sidecars and any database
an operator colocates. A single application chart is the wrong scope to claim it.
Observed on a live cluster, in a namespace labelled enforce=restricted, with the
database genuinely external to it:
-
the chart installs and serves unchanged: both replicas running with no restarts, readiness
UP, and a real openEHR write accepted. No admission warnings for its pods. -
a regression of the chart’s own pod spec is refused. Upgrading with
--set securityContext.privileged=true --set securityContext.allowPrivilegeEscalation=trueproduced a ReplicaSet that could create no pods, and:Error creating: pods "ferroehr-…" is forbidden: violates PodSecurity "restricted:latest": privileged (container "ferroehr" must not set securityContext.privileged=true), allowPrivilegeEscalation != falsewhile the healthy ReplicaSet kept both replicas serving, because
maxUnavailable: 0means the rollout cannot retire a good pod before a replacement is ready. The two controls compose: the label refuses the regression, and the strategy means the refusal costs no availability.
What you get by not applying the label: the chart still complies, but nothing
enforces it. A future chart change, a stray --set securityContext.privileged=true,
or a sidecar injected by other tooling would be admitted, and the first sign would
be a running privileged container in the namespace holding your PHI.
One practical note from that test: a colocated database fixture is unlikely to be
Restricted-compliant (the upstream postgres entrypoint must start as root), so
labelling a namespace that contains one will refuse it. That is another reason the
production posture puts the database outside the cluster.
Sandboxing is not a substitute for instance separation
Not required, and the reason matters more than the conclusion. The cheat sheet scopes sandboxing (Kata, gVisor, Firecracker) to clusters running untrusted workloads. This is our own code, so the threat it addresses (a container escape by hostile software you chose to run) is not the one in front of us. And the escape it hardens against is already narrowed by the pod’s own user namespace, which is on by default.
Important
A container sandbox does nothing to separate organisations, and that is the misreading worth preventing. FerroEHR is single-tenant: one instance serves one organisation, with its own database and its own domain roles. A sandbox draws a stronger boundary around one container, which is a boundary a single organisation is already alone inside. What separates two organisations is running two instances; the namespace model is where that is arranged.
A sandbox is worth considering in one case: a cluster where this workload runs
beside third-party or customer-supplied code, and you want to protect this
workload’s node from that. Note the cost first: a sandboxed runtime changes the
syscall surface and the performance profile of a database-bound server, and the
gVisor/Kata runtimes need a RuntimeClass the chart does not set (add it through
your platform’s pod defaults if you adopt one).
Kernel modules: already impossible
Satisfied by the chart’s own posture, which is unusual for a host-side control and worth recording precisely rather than deferring.
Loading a kernel module requires CAP_SYS_MODULE. The container’s capability
bounding set is empty (read off the running container, not off the
manifest’s request) and allowPrivilegeEscalation: false with
noNewPrivileges set means no setuid binary can regain it. The pod’s own user
namespace makes the point twice over: a capability held inside it does not apply
outside it. So this container cannot trigger a module load at all, whatever
/etc/modprobe.d/ on the host says. An attempt would still be a
runtime-detection
signal;
it just cannot succeed.
Host-side blacklisting remains good practice for the node, and stays the operator’s: it constrains every other workload on that node, including ones with capabilities this one does not have, and a privileged pod anywhere on the node can still load modules that affect this container’s kernel.
Namespaces, network & policy
Where the release’s boundaries are drawn: namespace scoping, how organisations are separated, the decisions not to adopt a mesh or a second policy engine, resource bounds in four nested layers, the ingress policy that narrows ports before it narrows sources, and deny-by-default egress: the one control on these pages that is not free.
- Namespaces, and one instance per organisation
- Service mesh: a recorded decision
- Centralized policy, and which engine
- Resource bounds: four layers
- Ingress: ports are narrowed, sources are yours
- Egress: deny by default, and what it breaks
Namespaces, and one instance per organisation
Ours, and satisfied by construction. Every object the chart renders is namespace-scoped: Deployment, Service, ConfigMap, Secret, ServiceAccount, NetworkPolicy, PodDisruptionBudget, HorizontalPodAutoscaler, Ingress, ServiceMonitor, the migration Job, and the viewer’s own Deployment, Service, ServiceAccount, NetworkPolicy and Ingress when that workload is enabled. There is no ClusterRole, no ClusterRoleBinding, no CustomResourceDefinition, no cluster-scoped object of any kind, and no template hard-codes a namespace: every reference resolves within the release’s own namespace. So two releases in two namespaces cannot collide, and neither can reach the other’s Secrets.
Separating organisations is a release per organisation, each in its own
namespace with its own database. FerroEHR carries no in-database tenancy, and
that is where openEHR puts the boundary: an openEHR system is one organisation’s
repository, and multi-tenancy is something the infrastructure beneath it does
with several such systems (BASE architecture_overview
master06-design_of_the_ehr.adoc §The EHR System).
What that buys, against the in-process alternative it replaces:
| One release per organisation | |
|---|---|
| Isolation boundary | Kubernetes plus PostgreSQL: separate Secrets, NetworkPolicies, quotas, RBAC, and a separate database |
| Blast radius of an application-level bug | one organisation, because a query has nothing else to reach |
| Blast radius of a compromised database credential | one organisation’s database |
| Cost | one Deployment, one connection pool and one image pull per organisation |
The cost is real and it is the price of the boundary. A shared process scoped by a row predicate is cheaper and is not equivalent, however similar the wire behaviour looks.
Service mesh: a recorded decision
Not adopted, deliberately. The cheat sheet presents a mesh as a trade-off, not a requirement, and for this workload the trade lands clearly.
What a mesh would provide, and what already provides it:
| Mesh benefit | Already covered by |
|---|---|
| mTLS between services | the server terminates TLS natively (config.server.tls), including client-certificate authentication for the IHE ATNA node-authentication posture; the database connection uses sslmode=verify-full |
| East-west traffic restriction | the shipped NetworkPolicy, and for the viewer an egress policy that admits the CDR Service, DNS and outbound HTTPS and nothing else |
| Request-level observability | OTLP traces and Prometheus metrics from the application, which sees openEHR operations rather than an L7 proxy’s view of opaque HTTP |
| An audit trail of access | the ATNA/BALP audit trail, which records who read which patient’s record, a property no proxy can reconstruct |
Against that: a mesh is a second control plane, a sidecar in every pod (which the Restricted profile and the empty capability set then have to accommodate), and an opinionated platform to upgrade in lockstep. For one workload plus an external database, the lateral-movement problem a mesh exists to solve barely exists.
A genuine gap, named rather than glossed: a mesh would give workload
identity: SPIFFE-style cryptographic identity per pod, so the database could
authenticate the client workload rather than a shared password held in a Secret.
Nothing in this deployment provides that; the DSN is a bearer credential, and any
process that can read the Secret can use it. The mitigations available without a
mesh are an external secret manager plus short-lived credentials, for example
cloud IAM database authentication, where the DSN carries a rotating token rather
than a password, bound through serviceAccount.annotations. If you run a mesh
anyway, expect the overlaps above rather than double-implementing them, and note
that its sidecar will need its own Pod Security accommodation.
Centralized policy, and which engine
Operator’s, and only for one of the three use cases a general policy engine is usually proposed for.
- Application authorization: already solved, do not add a second engine. This server ships a policy-driven authorization layer: RBAC, plus ABAC with an embedded Cedar engine or an external policy decision point. Adding a second engine for application decisions would mean two policy engines disagreeing about one question, and the one that loses is whichever is consulted second.
- Service-mesh authorization: moot, no mesh (above).
- Admission control: applies, and it is the same lever image provenance and Pod Security enforcement need.
Decision: Kyverno, chosen for what it must enforce rather than on general
merit. It is the only one of the three candidates that covers both levers with
one controller: verifyImages does keyless signature and attestation
verification, and its policies can enforce pod-security constraints beyond what
namespace labels express. sigstore-policy-controller does provenance only, so it
would have to be paired with something else. Kubernetes’ built-in
ValidatingAdmissionPolicy (CEL, no external controller) is attractive for pod
shape and is the right tool for cheap structural rules, but it cannot verify
signatures at all, because CEL evaluation makes no network calls and cannot
reach a transparency log, so it cannot be the answer to the control that matters
most here.
If you already run OPA/Gatekeeper for other reasons, keep it and add
sigstore-policy-controller alongside for provenance; do not run two general
admission engines. The copyable provenance policies are in
Images, and Pod
Security enforcement needs no engine at all: it is
the namespace label.
Resource bounds: four layers
Split exactly. Container requests and limits are the chart’s; namespace
ResourceQuota and LimitRange are cluster-admin objects, and a workload chart
that created one would be claiming the whole namespace for itself, wrong the
moment anything else shares it.
The chart’s bounds, and where they come from:
| Value | Derivation | |
|---|---|---|
resources.requests.cpu | 250m | the scheduling floor: enough to boot, run migrations and serve steady traffic. An idle-but-serving pod uses far less, so this is deliberately generous rather than tuned to observed idle: a request is what the scheduler reserves, and a too-tight one gets the pod placed on a node with nothing left for a traffic spike. |
resources.requests.memory | 256Mi | observed steady-state resident use is a fraction of this, and the headroom is for the connection pool, the template and WebTemplate caches, and the per-request AQL working set. |
resources.limits.cpu | 2 | AQL execution is the CPU-heavy path and is bounded per query by config.query.timeout_ms; two cores lets a query and normal traffic proceed without throttling. CPU limits throttle rather than kill, so this trades latency, not availability. |
resources.limits.memory | 1Gi | a hard ceiling: exceeding it is an OOM kill, so it sits well above observed use. The application-level body and result limits below are what keep a single request from approaching it. |
Those figures are for a modest replica. Tune them from your own metrics rather than
treating them as a recommendation, and remember that raising replicaCount
multiplies the request, which is what a namespace quota will notice first. The
viewer has its own, smaller bounds under viewer.resources.
The layering is the point, and an operator should see it as one story: four nested bounds, each catching what the next cannot:
- Per request:
config.server.body_limit_bytesandconfig.rest.body_limit_bytes(413 on an over-large body),config.server.request_timeout_ms(408),config.query.timeout_msandconfig.query.max_result_rows(a query cannot return an unbounded row set),config.db.statement_timeout_msas the backstop the HTTP timeout cannot be, because dropping a handler future does not cancel the statement PostgreSQL is running. - Per connection / per caller:
config.server.header_read_timeout_msbounds a socket before a request exists andconfig.server.idle_timeout_msa kept-alive one between requests;config.server.caller_raterefuses429;config.server.max_concurrent_requestsrefuses503when the server is full. - Per container: the requests and limits above.
- Per namespace: the operator’s, and the only layer that protects other workloads from this one:
apiVersion: v1
kind: ResourceQuota
metadata:
name: ferroehr
namespace: ferroehr
spec:
hard:
requests.cpu: "2"
requests.memory: 4Gi
limits.cpu: "12"
limits.memory: 8Gi
pods: "12"
count/services: "4"
---
apiVersion: v1
kind: LimitRange
metadata:
name: ferroehr-defaults
namespace: ferroehr
spec:
limits:
- type: Container
# A pod that arrives with no limits gets these, so nothing in the namespace
# can be unbounded by omission.
default: {cpu: "1", memory: 512Mi}
defaultRequest: {cpu: 100m, memory: 128Mi}
max: {cpu: "4", memory: 2Gi}
Size the quota above replicaCount × requests with headroom for a rolling
upgrade: maxSurge: 1 means one extra pod exists mid-rollout, and a quota with
no room for it makes upgrades stall rather than fail, which looks like a hung
deployment. The migration Job needs its share too when
migrations.job.enabled is on.
Ingress: ports are narrowed, sources are yours
The chart’s mechanism, your peers: and the one control on this page whose default looks stronger than it is.
networkPolicy.enabled ships on, and the policy it renders admits inbound
traffic to the API port (plus the management port when that runs on its own
listener) and to nothing else. That half is unconditional. The other half is not:
the rule’s sources are narrowed only when you set networkPolicy.ingressFrom.
While that list is empty the rule carries no from clause at all, and in the
NetworkPolicy API a rule with no from admits every source, other
namespaces and off-cluster clients included, not “any pod in this namespace”
(NetworkPolicies).
So the shipped posture is: ports restricted, sources open. Nothing about
kubectl get networkpolicy, the object’s own name, or a summary that says
“NetworkPolicy: enabled” distinguishes that from a policy that restricts both:
which is why the chart now states it as a value you can see and change rather
than leaving it implicit in an empty list.
Narrow it to whatever fronts the CDR:
networkPolicy:
enabled: true
ingressFrom:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx # your controller's namespace
# - ipBlock: {cidr: 10.0.0.0/16} # or a CIDR, for off-cluster callers
ingressAllowAll: false # refuse to ever render the open rule
The viewer does not need an entry: when viewer.enabled is on, the chart
appends the viewer’s own pod selector to whatever you list, because a narrowed
policy that forgets it locks the viewer out of the CDR and presents as the CDR
being down.
The viewer’s own policy works exactly the same way, under
viewer.networkPolicy.ingressFrom and viewer.networkPolicy.ingressAllowAll,
with the same refusal on false + empty. Its egress half is genuinely closed
(the CDR Service and DNS, nothing else) but its ingress half ships open like the
CDR’s, and what sits behind it is a login page. Narrow both.
Important
networkPolicy.ingressAllowAll: falsemakes “no open ingress” a checked fact. With it set, an emptyingressFromis refused at render time, the same treatment egress gives a policy with no database destination, and for the same reason: an absent selector is a decision, and this one is invisible in the place an operator would look for it. Set it in the values file of any deployment where an open ingress rule would be a finding, and a later edit that drops youringressFromfails the install instead of quietly reopening the port.It ships as
true, which is the honest name for what the chart has always rendered: a stockhelm installproduces the open rule, and now says so, in the values file, in the object’skubernetes.io/description, and in the notes printed at install. A non-emptyingressFromalways narrows, whatever this key is set to; it decides the empty case only.
Warning
A NetworkPolicy is only as real as the CNI that implements it. On a cluster whose network plugin does not enforce NetworkPolicy, the object is accepted, stored and displayed with no effect and no warning. Verify by attempting a connection the policy should refuse, from a pod in another namespace, since that is precisely the source an empty
ingressFromadmits.
Egress: deny by default, and what it breaks
The mechanism is the chart’s; the destinations are yours: and this is the control where a NetworkPolicy stops being free.
Enabling networkPolicy.egress.enabled refuses all outbound traffic except
what is listed. DNS is always included, and the database is a first-class value
because it is the only destination that is never optional:
networkPolicy:
egress:
enabled: true
database:
to:
- ipBlock: {cidr: 10.0.12.7/32} # a managed database
# or, in-cluster:
# - podSelector: {matchLabels: {app.kubernetes.io/name: postgres}}
port: 5432
rules: [] # one entry per integration, below
Important
Enabling egress with no destinations at all (neither
networkPolicy.egress.database.tonornetworkPolicy.egress.rules) is refused at render time rather than at rollout, because that mistake presents as a database fault: readiness reports the database down, the log shows a connect timeout, and nothing mentions the network policy. Supplyingrulesbut omitting the database destination renders, so keep the database entry where it belongs rather than folding it into a raw rule where it is easy to lose.
The destination set, derived from the configuration tree rather than from what a default install happens to use. Every row except the usage report is off unless its key is set, so add only the rows you have switched on:
| Destination | Turned on by | Port | Typically |
|---|---|---|---|
| Cluster DNS | always | UDP+TCP 53 | in-cluster (always allowed) |
| PostgreSQL | the DSN, never optional | the DSN’s (5432) | off-cluster (managed) |
| OTLP collector | config.telemetry.otlp.endpoint | 4317 gRPC / 4318 HTTP | in-cluster |
| Token issuer (metadata + key set) | config.auth.issuer, unless config.auth.jwks_file is supplied | 443 | off-cluster |
| External policy decision point | config.authz.abac.engine: remote plus config.authz.abac.remote.server | the URL’s (3001) | in-cluster |
| FHIR terminology server | config.terminology.server.enabled plus its url | 443 | off-cluster |
| FerroTERM in the cluster | terminology.enabled | 8080 | in-cluster, and the chart renders this rule itself — it chose the destination when it wired the CDR at the Service, so you do not add it to rules. Omitted, terminology would fail silently: under the shipped fail-open posture an unresolvable binding is accepted, so nothing refuses and nothing logs. |
| Terminology token endpoint | config.terminology.server.oauth.token_endpoint | 443 | off-cluster |
| AMQP broker (events) | config.events.enabled plus secrets.eventsUrl | 5672, or 5671 with config.events.tls | in-cluster |
| Object store | config.multimedia.enabled plus config.multimedia.endpoint (unset means AWS regional resolution) | 443, or the endpoint’s | off-cluster |
| Syslog audit repository | config.audit.syslog.enabled | 6514 TCP (TLS) | off-cluster |
| FHIR audit repository | config.audit.fhir_feed.enabled plus secrets.auditFhirFeedUrl | 443 | off-cluster |
| FerroPULSE usage report | usageReport.enabled, on by default | 443 | off-cluster, report.ferropulse.eu. Without the rule the report logs a warning and nothing else is affected; see Usage report |
The viewer, when enabled, carries its own egress policy rather than appearing in this table: it admits the CDR Service, DNS, and outbound HTTPS for an identity provider. Narrow that last rule to your issuer’s address if you can.
Warning
A NetworkPolicy cannot match a DNS name. The API selects peers by pod, namespace or
ipBlockCIDR only, so every off-cluster row above needs a CIDR you supply and keep current. A managed database that moves IP, or a terminology server behind a rotating CDN address, will break under a CIDR that was correct when written. Where a provider publishes no stable range, the honest options are an egress gateway with a fixed address, or leaving egress off and accepting that outbound traffic is unrestricted, not a0.0.0.0/0rule that pretends to be a policy.
Two failure modes worth knowing before you tighten this.
An over-tight policy silently stops observability. A blocked OTLP exporter does not fail the request that generated the span: it drops the span, with no log line and no error. So a policy that forgets the collector produces a server that is healthy by every check and has quietly stopped being observable. If you enable egress and traces disappear, look at the policy before the collector.
Tightening egress under a running pod appears to work when it has not. A NetworkPolicy is enforced on new connections; existing conntrack flows survive. So a pod whose connection pool is already established keeps serving after you remove its database rule, and fails at the next restart, which may be a node drain in the middle of the night. Observed on a live cluster:
policy: DNS + database → readiness 200 {"status":"UP", db UP, migrations UP}, POST /ehr 201
remove the database rule (DNS only), pod untouched
→ readiness STILL 200, db STILL "UP" ← the pool survives
delete the pods so a fresh one must connect
→ readiness 503, no replica available
kubelet: Readiness probe failed: HTTP probe failed with statuscode: 503
restore the rule → ready again, with no restart
So: verify an egress policy by restarting a pod, not by watching the one that is already running. (The recovery in the last line needing no restart is the readiness check re-testing its dependencies on every probe, the same property described for migrations.)
Secrets, detection & response
What this deployment’s Secrets actually contain, how to notice a compromise on a shell-less image, what to do when you have one, and where a managed control plane takes controls out of your hands.
- Secrets at rest, and what ours contain
- Runtime detection on a shell-less image
- Replica deviation and the outbound inventory
- Breach containment and rotating credentials
- Logging: two streams that are not interchangeable
- On a managed control plane
Secrets at rest, and what ours contain
Operator’s: encryption at rest is an API-server flag
(--encryption-provider-config), and Kubernetes does not encrypt Secrets by
default. A Secret is base64-encoded, which is an encoding and not a
protection: without that configuration, everything below sits readable in etcd,
which is why the etcd
section is the other half
of this one.
The useful half is ours: exactly what this deployment’s Secrets contain, so you can judge the exposure rather than reading “secrets” generically.
| Secret content | Present when | What it gets an attacker |
|---|---|---|
| The database DSN | always | direct read/write access to all patient data, bypassing the API, its authorization and its audit trail entirely |
| A Basic user’s Argon2id hash | secrets.basicUserPasswordHashes | an offline cracking target, not a usable password |
The version-signing passphrase (plus the PGP key via config.files) | config.signing.mode: pgp | the ability to forge version signatures, breaking the integrity guarantee |
A terminology client_secret | secrets.terminologyClientSecret | access to the terminology server as this client |
| The AMQP broker URL | secrets.eventsUrl | access to the change-event stream, which is PHI-free by design |
| The audit repository URL | secrets.auditFhirFeedUrl | the ability to read or forge audit records at the repository |
| S3 credentials | secrets.multimediaSecretAccessKey (with secrets.multimediaAccessKeyId) | offloaded DV_MULTIMEDIA blobs, which are PHI |
| The viewer’s OIDC client secret | viewer.existingSecret, when the viewer is enabled | the ability to impersonate the viewer at your identity provider |
The rendered ferroehr.toml itself is not in that list, and that is worth
stating because it used to be: while a Basic user’s hash had nowhere secure to go,
configuring one moved the whole configuration file into a Secret. Every credential
the server models now has either a *_file sibling or a Secret-borne environment
route, so no key takes that branch today; the chart keeps it only so that a
secret key added upstream tomorrow fails safe instead of landing in a ConfigMap.
The first row is the one that matters most, and it has a property worth naming: the DSN is a bearer credential. Any process that can read it can use it, from anywhere the database is reachable: there is no binding to the workload that was issued it. That is the same gap named under service mesh (workload identity), reached from the other direction, and the mitigation is the same: a credential that is short-lived and issued to a workload identity rather than a long-lived password in an object.
Enable encryption at rest with an
EncryptionConfiguration
on every API server. Prefer a KMS provider over aescbc/secretbox with a local
key: a key sitting in a file on the control-plane node is protected by the same
boundary as the etcd data it encrypts. On a managed cluster this is usually one
setting (envelope encryption with the provider’s KMS); check whether it is on,
because it generally is not by default. Existing Secrets are only re-encrypted
when rewritten, so follow the documented kubectl get secrets --all-namespaces -o json | kubectl replace -f - step, or the encryption applies to new writes only.
Or remove them from etcd entirely. Every secret this chart carries has a
*_file route or an existingSecret route, so no code change is needed to source
them from a secret manager:
- A CSI driver (Secrets Store CSI
Driver with the Vault, AWS,
Azure or GCP provider) mounts the value as a file. Point
extraVolumesandextraVolumeMountsat it and set the matching*_fileconfiguration key;config.signing.key_passphrase_file,config.multimedia.secret_access_key_file, the terminology grant’sclient_secret_file. Nothing reaches a Kubernetes Secret at all. - An operator that syncs into a Secret (External Secrets Operator, Vault Agent Injector) still lands in etcd, so it buys rotation rather than removal: worth having, but pair it with encryption at rest.
- The DSN is mounted too, from
database.existingSecret, so a CSI-provided Secret carries it like any other. What a mount does not fix is that the DSN remains a bearer credential: cloud IAM database authentication is the route that removes the standing password (the DSN then carries a short-lived token), andserviceAccount.annotationsexists for the IRSA or Workload Identity binding that needs. config.audit.fhir_feed.urlis the only credential-bearing key with no*_filesibling, so it is the one value still passed as an environment variable.
Finding exposed secrets is already covered in CI rather than left to an
operator: Trivy’s secret scanner runs over the whole tree and over every
published image, so a credential committed to the repository or baked into a layer
fails a job. The deliberate development credentials were checked against it and are
not flagged, so nothing is exempted to make it pass.
The volume-versus-environment half of this control (mounting secrets as read-only
files rather than passing them as environment variables) is
covered on the Kubernetes page and is
already the chart’s behaviour for every secret whose configuration key has a
*_file sibling.
Runtime detection on a shell-less image
Operator’s tooling (Falco, Tetragon, or a managed equivalent), but the signals are unusually high-confidence here, and that is what makes it worth more than a recommendation.
The runtime image is distroless and shell-less. There is no sh, no bash,
no curl, no package manager, and the container runs one process. So the usual
heuristics stop being heuristics:
| Signal | Why it is unambiguous for this image |
|---|---|
Any execve of anything other than /usr/local/bin/ferroehr | the image contains no other executable to run, so a second process means one arrived from outside |
| A shell process in the container | impossible under normal operation; there is no shell in the filesystem |
A write outside /tmp | the root filesystem is read-only and /tmp is the only declared writable mount |
| An outbound connection to anything not in the egress table | the inventory is complete and small |
| Any attempt to load a kernel module, mount, or change namespaces | the capability bounding set is empty, so these cannot succeed, but an attempt is still a signal |
A starter Falco rule set exploiting exactly that:
- macro: ferroehr_container
condition: container.image.repository endswith "/ferroehr"
- rule: FerroEHR unexpected process
desc: Any process other than the server binary in a shell-less image
condition: spawned_process and ferroehr_container and proc.exepath != "/usr/local/bin/ferroehr"
output: Unexpected process in FerroEHR container (proc=%proc.exepath parent=%proc.pname container=%container.id)
priority: CRITICAL
- rule: FerroEHR write outside tmp
desc: The root filesystem is read-only; /tmp is the only writable mount
condition: open_write and ferroehr_container and not fd.name startswith "/tmp/"
output: Write outside /tmp in FerroEHR container (file=%fd.name proc=%proc.exepath container=%container.id)
priority: CRITICAL
- rule: FerroEHR unexpected outbound connection
desc: Outbound to a destination outside the configured inventory
condition: >
outbound and ferroehr_container and
not fd.sport in (53) and not fd.sport in (5432, 4317, 4318, 443, 5671, 5672, 6514)
output: Unexpected egress from FerroEHR container (dest=%fd.rip:%fd.rport proc=%proc.exepath)
priority: WARNING
Tune the port list to the destinations you actually enabled; the point of the third rule is that the list is short enough to be worth writing. The macro matches the viewer’s image too, whose only expected process is its own server binary.
What each layer sees, and cannot. The syscall layer sees process execution,
file writes and raw connections (a compromise of the container) but it has no
idea which patient’s record was read, because to it every request is bytes on an
established socket. The ATNA audit trail sees exactly that: which subject
accessed which EHR, through which operation, under which authenticated identity:
but it is emitted by the application, so a compromise deep enough to control the
process can stop or falsify it. They are complementary and neither substitutes:
runtime detection is how you learn the process is not itself any more; the audit
trail is how you answer what was accessed while it still was. Forwarding audit
records off-box to an external repository (config.audit.syslog,
config.audit.fhir_feed) is what keeps the second answer available after the
first alarm.
Replica deviation and the outbound inventory
Operator’s practice, from material the server already publishes: which makes it more actionable here than the generic advice.
Replicas of this Deployment are interchangeable: same image, same configuration, traffic distributed by the Service. So a metric that differs per pod is a signal, and the Prometheus surface is already per-pod (each pod is its own scrape target). Worth alerting on a divergence between pods rather than on an absolute value:
| Comparison across pods | What a deviation suggests |
|---|---|
| request rate per pod | a load-balancing fault, or one pod being addressed directly, bypassing the Service |
| error ratio (5xx over total) per pod | a pod-local fault: a broken database connection, an exhausted pool, a failing dependency one pod reaches and others do not |
| tail latency per pod | a throttled pod (CPU limit), a noisy neighbour, or a degraded node |
| authentication-failure rate per pod | credential stuffing aimed at one endpoint, or a token-validation path failing on one pod (an unreachable JWKS endpoint) |
| resident memory slope per pod | a leak or an unbounded working set on one replica only |
| database pool acquire-wait per pod | that pod’s pool is starved while others are not |
The management surface’s prometheus endpoint (opened with
config.management.endpoints.prometheus) is the source, and
metrics.serviceMonitor.enabled is how an operator-managed Prometheus discovers
it. Two things worth knowing: the ATNA audit trail gives a second, independent
view (an access pattern that deviates per pod is visible there at
patient-and-operation granularity) and a pod that fails readiness leaves the
Service, so “one pod has zero traffic” can mean it is unready rather than
unreachable.
The outbound inventory the traffic half of this control asks for is the egress table, derived from the configuration tree. In the chart’s default posture, read from node conntrack on a running pod, the complete set is two destinations: TCP 5432 to the database and UDP 53 to cluster DNS. Nothing else. That is what makes a deny-by-default egress policy tractable: the base allowance is two rules, and every addition is a named, configured endpoint rather than an open range. Compare live traffic against the policy periodically: a connection the policy permits and nothing makes is a rule to remove.
Breach containment and rotating credentials
Scaling to zero is a clinical-safety decision, and it should be made before you
need it. kubectl scale deploy/ferroehr --replicas=0 is the Kubernetes-native
containment action, and for this workload it means clinical access stops
immediately: no reads, no commits, for everyone. That is the point during a
breach, and it is also an outage of a system clinicians may be depending on at
that moment. Decide in advance who is authorized to make that call.
What scaling to zero does:
- stops all new requests, including whatever the attacker is doing through the API;
- leaves the database untouched: it is external, so its data, its contents and its own access controls are unaffected;
- preserves the pod’s evidence only partially: scaling to zero terminates the pods, so anything in memory is gone. To preserve a pod for forensics, cordon its node and remove the pod from the Service by editing its labels instead; the ReplicaSet then creates a replacement while the original keeps running, detached from traffic.
What it does not do:
- it does not undo committed data. openEHR change control is append-only, so a malicious commit is a new version, not an overwrite. The prior version is still there and still retrievable.
- it does not stop an attacker who has the DSN. The database is reachable independently of these pods; a leaked DSN is used from anywhere the database admits, which is why rotating it (below), not scaling, is the containment action for that particular compromise.
- it does not truncate the audit trail. Records already written to the local
store, or already forwarded to an external repository, survive. Records still in
the outbox at termination are drained during the grace period, so a scale-to-zero
loses less than an abrupt kill; forwarding to an external repository
(
config.audit.syslog,config.audit.fhir_feed) is what makes the trail survive the pods entirely.
Rotating each credential
Every secret except two is a mounted file, so rotation is: update the Secret, then restart the pods. The restart is not optional: configuration is read at boot, and Kubernetes propagating a new Secret into the volume does not make a running process re-read it:
kubectl -n ferroehr create secret generic ferroehr-db \
--from-literal=FERROEHR__DB__URL='postgres://…new…' --dry-run=client -o yaml \
| kubectl apply -f -
kubectl -n ferroehr rollout restart deploy/ferroehr
(A rotation you make through helm upgrade needs no explicit restart: any change
under config, config.files or secrets moves the checksum/config pod
annotation and rolls the Deployment by itself.)
| Credential | Rotation | Notes |
|---|---|---|
| Database DSN | update the Secret, then rollout restart | rotate the database password too, or the old one still works; maxUnavailable: 0 keeps the old pods serving on the old credential until the new ones are ready, so grant both briefly or accept a gap |
Terminology client_secret | update, then restart | rotate at the identity provider in the same window |
| AMQP broker URLs | update, then restart | the credential is inside the URL |
| S3 credentials | update, then restart | or remove them entirely with IRSA or Workload Identity |
| Audit repository URL | update, then restart | still an environment value (no *_file sibling) |
| Basic user password hash | update, then restart | rotate the password and re-hash at the OWASP Argon2id floor, which the server checks at boot |
| Viewer OIDC client secret | update, then restart the viewer | rotate at the identity provider in the same window |
| Version signing key | read the next section first | rotate the signing subkey, not the certificate |
Rotating the version signing key
In the default digest mode there is no key: the version signature is a hash of
the canonical form, recomputed at read time. Nothing to rotate, and nothing
breaks.
In pgp mode there are two mechanisms, and the first is the one to reach for.
The ordinary path: rotate the signing subkey. OpenPGP is built for this. A certificate is a primary key plus its subkeys (RFC 9580 §10.1), the primary key certifies while a subkey signs, and rotating means issuing a new signing subkey on the same certificate. The retired subkey stays in the certificate, so every version it signed keeps verifying, with no configuration change, no second key file, and no window where history is unreadable. It is also the only path that keeps the primary key’s identity intact; replacing a primary key discards everything that has ever been said about it.
# add a fresh signing subkey to the existing certificate
gpg --quick-add-key <FINGERPRINT> ed25519 sign 1y
# revoke or expire the previous one, then re-export BOTH halves
gpg --export-secret-keys --armor <FINGERPRINT> > signing.asc
Update the mounted Secret and roll the pods. The server signs with the newest signing-capable subkey and verifies against every subkey the certificate carries, so the switch is invisible to readers.
The exception: a whole certificate is replaced. A compromised primary key, an organisational change, or migrating from a different signer means a genuinely new certificate, and then the old one must be retained, because a stored signature carries no key identifier and a version signature is an immutable committed fact that cannot be re-issued. Keep the retired public key:
[signing]
mode = "pgp"
key_path = "/etc/ferroehr/signing.asc" # the new certificate
retired_key_paths = ["/etc/ferroehr/signing-2025.pub.asc"] # public, verify-only
Under Helm these are config.signing.key_path and
config.signing.retired_key_paths, with both files mounted through
config.files.
Note
Retired entries are public keys, which is what makes the safety property structural rather than a promise: no secret key is loaded for them, so a retired certificate can verify and can never sign again. Verification does not become permissive either: a signature matching neither the active certificate nor any retired one still fails, and tampered content still fails.
This is the same mechanism Debian uses for its archive: debian-archive-keyring
ships current and retired keys so packages signed under an older key stay
verifiable (Debian archive keys).
What neither mechanism covers. Key expiry and revocation are a different
problem: a strict verifier arguably should accept a signature made while the key
was valid and reject one made after, and nothing in a detached signature proves
when it was made. Solving that needs trusted timestamps (RFC 3161), the
approach Sigstore takes with
short-lived keys and a timestamp authority. FerroEHR does not implement it, so
treat an expired signing key as a certificate replacement and use
config.signing.retired_key_paths.
If you are mid-rotation and need reads to keep working before either mechanism
is in place, config.signing.verify_on_read: warn downgrades a verification
failure from a 5xx to a logged and metered event
(version_signature_invalid_total{verdict="pgp_invalid"}). That is a deliberate,
recorded reduction in an integrity guarantee, not a setting to leave on.
Logging: two streams that are not interchangeable
Container logging is ours, and already the right shape. The server writes to
stdout and stderr and never to a file inside the container, which is both the
Kubernetes logging
architecture’s
expectation and a requirement of readOnlyRootFilesystem: true: there is nowhere
to write a log file. Set config.telemetry.format: json (the chart’s default) for a
collector; pretty is for a terminal.
The distinction that matters, because getting it wrong loses the accountability record:
| Application log | ATNA audit trail | |
|---|---|---|
| Purpose | diagnostics: what the process is doing | accountability: who accessed which patient’s record |
| Destination | stdout/stderr → node → your collector | its own store in the database, plus optional forwarding to an external repository |
| Format | JSON lines, ours | DICOM PS3.15 plus FHIR AuditEvent (IHE BALP), standardised |
| Retrieval | your log tool | the ITI-81 FHIR AuditEvent search endpoint |
| Retention | your collector’s policy | config.audit.store.retention_days (0 keeps forever) |
| May it be sampled or dropped? | yes, it is diagnostics | no |
Important
Do not treat the audit trail as “logs”. A collector configured to sample a noisy stream, or to drop under volume, is a reasonable policy for diagnostics and a compliance failure for the audit trail: it silently discards the record of who read which patient’s data. The two travel by different paths precisely so that one can be lossy: the audit trail does not go through stdout at all. If you also ship audit records to your log platform for convenience, that copy is a convenience, not the record.
The audit trail’s own failure behaviour is configurable, and the default is worth
knowing rather than inheriting: config.audit.fail_mode defaults to open, so
an operation whose audit record cannot be written still proceeds, and the failure
is metered rather than refused. closed answers 503 instead, the stronger
compliance posture, and one that turns an audit outage into a clinical outage.
Which is correct depends on whether your regulatory position can tolerate an
unaudited access more or less than a refused one; it is a policy choice either way,
and the shipped default chooses availability.
Cluster API audit logging is the operator’s. Enable it on the API server with
an audit policy (None / Metadata / Request / RequestResponse per rule).
Metadata for most resources with Request-level detail for Secret and RBAC
changes is a reasonable starting shape. Two things worth alerting on specifically:
authorization failures (Forbidden responses, a principal probing what it can
reach), and any read of Secrets in this namespace by a principal that is not the
kubelet, since that is what reading the DSN looks like from the API side.
Kubernetes Events are a third source, and distinct from both: they are the
cluster’s account of what happened to your objects, they expire (typically an
hour), and they are where this chart’s failures show up first:
Readiness probe failed: HTTP probe failed with statuscode: 503 when a dependency
is down, FailedCreate … violates PodSecurity "restricted:latest" when a pod spec
regresses under enforcement, Unhealthy and Killing during a rollout. Check
kubectl get events --sort-by=.lastTimestamp before the application log when a pod
will not start: the reason is usually there, and it is usually not in the log,
because the container never ran.
On a managed control plane
On EKS, GKE, AKS or an equivalent, several controls in this audit stop being yours: you cannot set API-server flags, reach etcd, or configure kubelet authentication. In exchange you inherit the provider’s defaults, which may be stronger or weaker than this sheet assumes, and which you should verify rather than assume.
| Control | On a managed cluster |
|---|---|
| Host hardening, node OS patching | provider’s, though node images and upgrades are usually still yours to trigger |
| API-server flags, authorization mode | provider’s (RBAC is on by default everywhere mainstream) |
| etcd access + encryption at rest | provider’s, but envelope encryption with your own KMS key is usually opt-in, and it is the control our Secrets need |
| Kubelet authentication | provider’s |
| Control-plane audit logging | provider’s to enable, often off or short-retention by default, and usually billed |
User namespaces (hostUsers: false) | depends on the node image’s runtime version, which is why the pod fails loudly rather than downgrading |
| Pod Security Admission | yours (namespace labels) |
| NetworkPolicy | yours to write; enforcement depends on the CNI |
| Everything the chart sets | unchanged: it is a workload |
Check the CNI before relying on the shipped NetworkPolicy. This is the item that varies most and fails most silently: a NetworkPolicy on a cluster whose network plugin does not enforce it is an object the API accepts, stores and displays, with no effect and no warning. Provider defaults differ, versions change them, and some require enabling enforcement at cluster creation, which cannot be changed afterwards on some platforms. Do not read your provider’s documentation and conclude; test it:
kubectl create namespace ferroehr-probe
kubectl -n ferroehr-probe run probe --image=busybox:1.37 --restart=Never --command -- sleep 600
kubectl -n ferroehr-probe exec probe -- nc -w3 -z <ferroehr-pod-ip> 5432 # must fail
kubectl -n ferroehr-probe exec probe -- nc -w3 -z <ferroehr-pod-ip> 8080 # must succeed
If the first command succeeds, the policy is decoration and every claim in this section that rests on it is void for your cluster. This is also the reason the project’s own deployment probe harness declares NetworkPolicy enforcement as something it does not exercise: a green result on one cluster’s CNI would say nothing about yours.
Provider audit tooling exists: for EKS, hardeneks is the commonly cited one. We have not run it, so it is named as a starting point rather than a recommendation: treat its output as input to the same ownership question this section asks, not as a verdict.
From source
You can build the ferroehr binary yourself: for a platform without a
published image, for local development, or to run the test suite. This chapter
covers the prerequisites and the build. Most operators should prefer the
published container images (Docker Compose,
Kubernetes & Helm); build from source when you need to.
Prerequisites
- The pinned Rust toolchain. The repository pins Rust 1.99.1 (edition
2024) via
rust-toolchain.toml, sorustupinstalls and selects it automatically the first time you build in the checkout; you do not choose a version by hand. - Docker: required only for the integration tests, which spin up a real PostgreSQL 18 in a container.
xmllint: required only for the canonical-XML tests.
Building
From the repository root:
cargo build --workspace
To build just the server binary in release mode (what the container image ships):
cargo build --release --locked -p ferroehr-server
The binary crate is ferroehr-server; the executable it produces is named
ferroehr, so the resulting binary is target/release/ferroehr. It links a
pure-Rust TLS stack (no OpenSSL, no JVM, no runtime dependencies) so it
drops into a minimal base image or runs directly on the host.
Running the tests
cargo nextest run --workspace
Every database-backed test takes its database from a shared harness: one
PostgreSQL 18 server, one migrated template database, and a fast clone per
test. By default the harness starts (or re-adopts) a single reusable postgres:18 container, so
Docker must be running; reclaim it afterwards with
docker rm -f ferroehr-testkit-pg18. To use a PostgreSQL 18 server you already
run instead, point the suite at it and Docker is not needed at all:
export FERROEHR_TEST_PG_URL='postgres://user:password@localhost:5432/postgres'
cargo nextest run --workspace
The role in that DSN must be allowed to CREATE DATABASE.
Running the binary
The binary is configured entirely through FERROEHR_* environment variables
(see the configuration reference). At minimum it needs a
database URL:
export FERROEHR__DB__URL='postgres://ferroehr:ferroehr@localhost:5432/ferroehr'
target/release/ferroehr
It runs its schema migrations at boot and then serves on the configured bind
address (default 0.0.0.0:8080). That boot-time migration is a setting, not a
fixture: db.migrate = "verify" makes the server issue no DDL at all, for a
deployment whose database role has none of those rights (see
Operations).
Two global flags and three subcommand groups round the CLI out:
--config <path>points at a configuration file, overridingFERROEHR_CONFIG(with neither, the server reads no file);--set <key>=<value>is a repeatable dotted-path override with the highest precedence of all (--set db.max_connections=40);ferroehr config checkvalidates the effective configuration and prints it redacted, exiting 0 when valid and 1 otherwise, the fastest way to test a deployment’s configuration before starting it;ferroehr db migrateapplies the embedded migrations and exits, the out-of-band schema step, run under aferroehr_migratorDSN;ferroehr db verifychecks, without issuing any DDL, that the database carries exactly this build’s migrations, exiting 0 when it does;ferroehr healthcheck(used by the container healthcheck and Kubernetes exec probes) asksGET /health/readinesson the configuredserver.listenport over the loopback and exits 0 on200, 1 otherwise.
Note
Building from source gives you the same binary the images use: the container Dockerfile pins its Rust version from the same
rust-toolchain.toml, and CI cross-checks the two so they cannot drift.
Build features
The server builds with three additive cargo features, all on by default:
fhir (the FHIR terminology providers, and the FHIR AuditEvent the audit
store, the audit feed and the ITI-81 retrieval carry), events
(contribution-outbox eventing and the AMQP transport), and multimedia
(DV_MULTIMEDIA externalization to S3-compatible object storage). Their
implementations live in a separate crate that the platform library pulls in
only when the matching feature is on, so a build without them contains none of
their code.
A slim build compiles them out entirely:
cargo build --release --locked -p ferroehr-server --no-default-features
A slim binary refuses loudly at boot if the configuration enables an
integration it was built without: multimedia.enabled, events.enabled,
audit.store.enabled, audit.fhir_feed.enabled, or a configured external
FHIR terminology provider. The syslog ATNA feed and the in-process terminology
bundle remain available in slim builds.
Warning
The local audit store is on in the shipped defaults, so a slim build refuses to start on an untouched configuration. To run one, disable it explicitly (
FERROEHR__AUDIT__STORE__ENABLED=false) or configure the syslog feed as the audit sink; silently dropping the audit trail is not a boot mode.
Configuration reference
FerroEHR is configured by one file, ferroehr.toml, whose sections cover
the entire server, with FERROEHR_* environment variables (and repeatable
--set flags) as per-key overrides on top. This page is the entry point: the
quickstart, how configuration loads and how environment names map onto the
file, the one key that selects the openEHR specification generation set, and a
map of which page documents which section.
Quickstart
Copy the annotated template, edit it, and run:
# A fully-commented ferroehr.toml with every key at its default.
curl -fsSLo ferroehr.toml \
https://raw.githubusercontent.com/FerroHEALTH/FerroEHR/main/app/ferroehr/assets/ferroehr.default.toml
# Edit it — at minimum set db.url and an auth mechanism.
$EDITOR ferroehr.toml
# Validate without touching the database, then run.
ferroehr config check --config ferroehr.toml
ferroehr --config ferroehr.toml
A server started with no file and no environment still boots, with one exception you will hit immediately: authentication is on by default and a server with no mechanism configured refuses to start. See Zero-config boot and the production checklist.
How configuration loads
Configuration is assembled once at boot from four layers, lowest precedence to highest:
- Built-in defaults: the values in the tables on the section pages.
- The config file:
ferroehr.toml(see file discovery). FERROEHR_*environment variables: override individual keys.--set key=valueCLI flags (repeatable), which win over everything.
Some conventional names sit below their FERROEHR_ forms within layer 3:
DATABASE_URL→db.url, andRUST_LOG→telemetry.filter.PORT→ a0.0.0.0:<PORT>value forserver.listen(the port container platforms such as Vercel, Cloud Run and Heroku inject and route traffic to).- The libpq environment set
(
PGHOST,PGUSER,PGPASSWORD,PGDATABASE,PGPORT,PGSSLMODE), which managed-Postgres integrations such as Neon inject: whenPGHOSTis set and no URL form is, the server assembles the DSN from them. An explicitDATABASE_URLbeats the assembled form, andFERROEHR__DB__URLbeats both.
Nothing else has a non-FERROEHR_ name.
The environment-variable mapping
Every key has one mechanical environment spelling: FERROEHR + the TOML
path, upper-cased, with a double underscore (__) between every segment,
including after the FERROEHR prefix. A single underscore only ever appears
inside a key word.
| TOML | Environment variable |
|---|---|
[db] max_connections = 20 | FERROEHR__DB__MAX_CONNECTIONS=20 |
[auth] issuer = "…" | FERROEHR__AUTH__ISSUER=… |
[management.endpoints] env = "off" | FERROEHR__MANAGEMENT__ENDPOINTS__ENV=off |
[terminology.server] url = "…" | FERROEHR__TERMINOLOGY__SERVER__URL=… |
Scalars are typed automatically (bool / int / float, else string).
List-typed keys take comma-separated values
(FERROEHR__AUTH__AUDIENCE=ferroehr,other). Map-keyed tables are
reachable too: the map key is just another segment
(FERROEHR__AUTHZ__ABAC__POLICY__COMPOSITION__NAME). Arrays of tables (the
Basic-auth user store) are file-only, because the environment grammar has
no way to spell an array index.
Note
Enum values are lowercase /
snake_casetokens, exactly as the tables show. Secret-typed keys are redacted everywhere the configuration is rendered (the/management/envsnapshot,GET /ferroehr/rest/extensions/v1/admin/config,ferroehr config check,ferroehr report, logs), and a secret-URL key shows its host and path with the embedded credentials masked. Each secret, and each database and broker URL, has a*_filesibling that reads the value from a file, for Kubernetes and Docker secret mounts. Setting a key and its*_filesibling at once is a boot error.
File discovery
The server reads the file --config <path> names, or else the file
FERROEHR_CONFIG=<path> names; later layers still override its values. A named
file is fatal if absent, unreadable or unparseable. With neither set, the server
reads no file and searches no directory: the environment and the defaults make
the whole configuration.
Strict validation
Configuration is validated at boot (and by ferroehr config check), and the
server refuses to start on any error:
- Unknown keys are rejected: in the file, naming the key and the closest
valid one, and in every
FERROEHR__…override, so a misspelled security key can never be silently ignored. Only the uniformFERROEHR__<SECTION>__<KEY>form sets a key. Any otherFERROEHR_variable, such as the single-underscoreFERROEHR_DB_URL, sets nothing and is left alone, because the viewer’sFERROEHR_VIEWER_variables share the prefix. - Type errors are boot errors, naming the key and what was expected.
- Semantic errors are aggregated: one pass reports every problem at once, so a broken configuration is fixed in a single iteration.
spec_profile
# The openEHR specification generation set the server runs.
spec_profile = "development" # or "stable"
openEHR publishes released specification versions and keeps developing the next ones. FerroEHR generates both, and this key decides which set the running server serves.
| Value | RM | BASE | LANG | Choose it when |
|---|---|---|---|---|
development (default) | 1.2.0 | 1.3.0 | 1.1.0 | You want the generations this build is developed against. This is the default for every deployment that does not set the key. |
stable | 1.1.0 | 1.2.0 | 1.0.0 | Your governance requires running on released openEHR specifications only. |
Environment form: FERROEHR__SPEC_PROFILE=stable. The key is a top-level
scalar, not a section; there is no [spec_profile] table.
Why it is one key and not three
The components’ generations are modelled against each other, not independently: RM 1.1.0’s own machine-readable model declares that it includes BASE 1.2.0. Letting you pick RM 1.1.0 with BASE 1.3.0 would offer a combination openEHR never published, so the profile is a single coupled choice and incoherent sets are unrepresentable.
Seeing which profile is active
The profile is reported in two places, so it is never a guess:
- the boot banner, on every start, alongside the RM version it serves;
GET /management/info, which names the active profile with the RM and BASE generations it selects, next to the build provenance.
What changes on the wire
The profile is an acceptance boundary, and it is exact in both directions.
Under stable, a query that addresses specification surface the released
generations do not define is refused with a typed error naming the active
profile: an AQL FROM class or a path attribute RM 1.1.0 does not declare
is rejected at planning time rather than answered as though it existed.
Released surface the development line later dropped stays accepted under
stable, which is the half most implementations get wrong. A demographic
party carrying the RM 1.1.0 PARTY.reverse_relationships attribute is read
and accepted under stable; development refuses it as an undeclared key,
because RM 1.2.0 removed it. The attribute is derived data the server
recomputes from relationships, so the copy you send is validated and then
dropped rather than stored.
Reading a stored object is bounded the same way. Every commit records whether
the released generations can express the body it accepted, and under stable
a stored version they cannot is refused with 409 Conflict naming the
active profile, the version, and the remedy; never served under a generation
set that does not define it, and never rewritten to fit one. Under
development the same object reads normally. This is FerroEHR’s own
extension: no openEHR specification governs runtime version selection, so the
status follows HTTP itself (RFC 9110 §15.5.10, a conflict with the current
state of the target resource, whose resolution the response describes).
Queries take the same refusal wherever they serve a version body. AQL is
gated in two places, and they answer different questions. At planning time the
query text is checked: a FROM class or path attribute the released generations
do not declare is rejected. At result assembly the projection is checked: a
whole-object projection (SELECT c FROM EHR e CONTAINS COMPOSITION c) returns
stored version bodies, so if any row of the page comes from a version the
released generations cannot express, the whole query answers 409 Conflict
naming that version. The row is never quietly dropped from the result set
instead: a RESULT_SET is columns and rows of values with nowhere to explain a
missing row, so silently eliding one would be an answer you could not tell from
“no such data”.
A leaf projection over the very same rows (SELECT c/name/value FROM EHR e CONTAINS COMPOSITION c) still answers 200. It serves data values rather than
version bodies, over paths the planning gate has already bounded to the released
generation’s declared surface. That is the honest boundary: the profile is an
acceptance boundary on what is served as an openEHR object, not a content filter
over the values inside one.
Under development none of this applies and it costs nothing: the assembly
gate returns before it touches the database.
The exact additive delta between the two generation sets is pinned in the build, so a future openEHR re-vendoring cannot silently widen or narrow what a profile accepts.
Changing the profile on an existing deployment
Treat the profile as a deployment commitment. Both directions are defined, but they are not symmetric:
| Direction | Supported? | Why |
|---|---|---|
stable → development | Always safe | openEHR minor releases are additive by the Foundation’s own release strategy, so every object stored under the released generations is valid under the development ones. |
development → stable | Only for data that never used a development-only construct | There is no down-conversion. An object that did use one becomes unreadable (409, not a silently degraded body) until you switch back. |
If you need to stay on released specifications, choose stable on day one
rather than migrating into it later. Silently rewriting stored clinical
content to fit an older generation would be data loss disguised as a setting,
so no tool does it.
Objects committed before this stamp existed, and objects written by the
verbatim-replay paths (EHR-Extract import, archive load), carry no recorded
answer; they are assessed at read instead, which costs one extra parse per
read of such an object and only under stable. Nothing is written back: a
read stays a read.
Note
No openEHR specification governs runtime version selection; this key is FerroEHR’s own design. What the specifications do govern is the compatibility direction it relies on: minor releases within a major line are additive supersets.
deployment_profile
# The declared posture: what this deployment may hold.
deployment_profile = "sandbox" # or "production"
deployment_accepts = [] # production only: gaps run without, by name
Every separation FerroEHR can make is a configuration key an operator can leave unset, and a deployment that has made none of them looks, from its own logs and its own API, like one that has made all of them. This key gives the server a declared posture and makes the two impossible to confuse.
| Value | Asserts | On boot |
|---|---|---|
sandbox (the binary’s default; the Helm chart has none and refuses to render until you choose) | Nothing; the deployment must not hold real personal data | Says which separations are missing: a red notice on the banner, one warn line per gap with the structured field posture = "deployment", and the same list on GET /management/status under deployment; the public GET /ferroehr/rest/status carries the profile only |
production | Every separation below holds, or is accepted by name in deployment_accepts | Refuses to start otherwise, naming each open gap, what it found and what to change |
The separations, each a real property the server checks rather than a box ticked by being present:
| Gap token | What production requires |
|---|---|
shared_credential | [storage.party] url and [storage.linkage] url set, so the domains connect on their own database roles |
shared_cluster | The four pools reach four different PostgreSQL clusters, read from pg_control_system().system_identifier on each pool, never from the DSN text. The audit pool counts: Swiss law keeps the access log on a system separate from the one processing the data |
open_subject_namespace | [privacy] subject_namespaces declared, so an EHR_STATUS subject is an opaque pseudonym |
audit_off | [audit] enabled with a durable sink (the local store, syslog or the FHIR feed) |
audit_fails_open | [audit] fail_mode = "closed", so an operation whose access record cannot be taken is refused instead of succeeding unlogged |
no_category_map | A category map applies while auditing is on: the one FerroEHR ships (the default) or [audit.categories] templates and archetypes of your own, so every access record names the categories of the data it reached (EHDS Annex II 3.2(c)). The gap opens only with [audit.categories] shipped_map = false and nothing declared |
open_ehr_access_default | [authz.rbac] ehr_access_default = "restricted", so an EHR carrying no ACCESS_CONTROL_SETTINGS is reachable only by an admin. The shipped default is open. The setting is enforced with [authz.rbac] enabled on or off |
migrate_on_runtime_credential | [db] migrate_url set, or migrate = "verify", so no credential that serves requests can alter the schema. Evaluated per domain database: a domain relocated to a database of its own is prepared on [storage.migrate.<domain>] url when that is set, and on that domain’s runtime DSN, which opens the gap, when it is not |
auth_off | [auth] enabled = true, so no request is served unauthenticated (CRA Annex I Part I(2)(d)) |
rbac_off | [authz.rbac] enabled = true whenever authentication is on, so a caller reaches only the operations its roles allow (CRA Annex I Part I(2)(d)). Not reported while auth_off is |
plaintext_listener | [server.tls] set, or [server] listen on a loopback address, so data does not cross the network in clear (CRA Annex I Part I(2)(e)). A deployment behind a TLS-terminating ingress accepts plaintext_listener by name, which records that the ingress carries the encryption. The gap also opens when [management] port is set on an enabled management surface: that listener binds every interface and never speaks TLS, so unset the port (the surface then shares the main listener) or accept the gap by name where the port is unreachable off the host |
plaintext_broker | An amqps:// broker URL, or tls = true, on the enabled change-event outbox ([events]), so change events do not cross the network in clear (CRA Annex I Part I(2)(e)). A deployment whose broker path stays on a segment it trusts accepts plaintext_broker by name |
plaintext_audit_feed | An https:// URL for an enabled audit FHIR feed ([audit.fhir_feed] url), so the access records, which name the patient and the professional, do not cross the network in clear (CRA Annex I Part I(2)(e)). A deployment whose repository is reached on a segment it trusts accepts plaintext_audit_feed by name |
plaintext_object_store | [multimedia] allow_http = false on an enabled multimedia store, so the externalised clinical blobs travel over an https:// endpoint (CRA Annex I Part I(2)(e)). A deployment whose object store is reached on a segment it trusts accepts plaintext_object_store by name |
An accepted gap is stated on every boot and on /management/status; it can be run,
not hidden. Environment form: FERROEHR__DEPLOYMENT_PROFILE=production,
FERROEHR__DEPLOYMENT_ACCEPTS=shared_cluster. There is no research value:
the key controls rigour, not purpose, and a secondary-use platform under an
EHDS data permit runs on real patient data at production rigour whatever it is
for.
This is FerroEHR’s own posture, not a legal requirement. GDPR Art. 4(5) asks that the additional information needed to re-identify a person be “kept separately and … subject to technical and organisational measures”, not that it sit on a separate server; one cluster with separated schemas and roles is a defensible reading. Two clusters are materially stronger, because a superuser, an instance-wide point-in-time recovery and a single compromise are bridges no grant can close, and that is a choice a deployment should make deliberately rather than inherit from a quickstart. See Compliance.
Where each section is documented
| Section | What it covers | Page |
|---|---|---|
[server], [server.caller_rate], [server.tls] | The HTTP listener (the family section): timeouts, the body limit, the concurrency bound, the drain, the caller rate, TLS | Server, database & telemetry |
[rest], [rest.identity] | The openEHR REST surface: the API base path, the system identifier, Swagger UI, CORS, the clinical body tier, the deployment’s own identity | Server, database & telemetry |
[db] | PostgreSQL connection, pool, migrations | Server, database & telemetry |
[telemetry] | The console, the request log and the OpenTelemetry export | Server, database & telemetry |
[usage_report] | The daily installation report to FerroPULSE, on by default; what it sends is on the Usage report page | Server, database & telemetry |
[auth], [authz] | Authentication (Basic, OAuth2/OIDC) and RBAC/ABAC | Authentication & access |
[admin], [management] | The ADMIN API group and the ops-introspection surface | Authentication & access |
[smart] | SMART App Launch discovery and scope enforcement | Authentication & access |
[signing] | VERSION signing and read-time verification | Authentication & access |
[licence] | The commercial licence token | Authentication & access |
[query] | AQL execution budgets and result ceilings | Integrations |
[ehr_index], [ehr_index.oauth], [ehr_index.tls] | Who holds the patient-to-EHR cross-reference: an IHE patient identity source over PIXm (the default) or the in-instance map | Integrations |
[events] | Change eventing | Integrations |
[terminology], [multimedia] | The terminology server, multimedia externalization | Integrations |
[audit], [audit.store], [audit.syslog], [audit.fhir_feed] | The IHE ATNA audit trail and its sinks | Audit |
[origins] | The origins of data the deployment recognises, which a retention period and an ABAC rule may name (EHDS Annex II 3.4) | Audit |
[privacy], [privacy.identifier_scan] | What the clinical side refuses to hold: the subject reference, identified parties, the identifier scanner | Privacy & data minimisation |
[cohort], [cohort.predicates] | Cross-domain cohort queries: the demographic predicate allow-list, the small-cell floor, the cohort ceiling | Privacy & data minimisation |
[demographic.identifier_protection] | Sealing national identifiers in the demographic domain, and the key that opens them | Privacy & data minimisation |
| — | The CLI, the production checklist, file-versus-environment guidance | CLI & production checklist |
Server, database & telemetry
The listener ([server]), the REST surface ([rest]), the PostgreSQL
connection ([db]), the observability section ([telemetry]), and the
usage report ([usage_report]). Precedence, the
environment-name grammar, and file discovery are on the
Configuration reference index.
[server]
The HTTP listener: the section every FerroHEALTH product shares
(ferrohealth-server), so the keys and their behaviour are the same in each.
It carries the stack every request passes through: the server’s request id,
the request timeout, the body limit, the concurrency bound, compression, the
request log, the health routes and the bounded drain on SIGTERM.
[server]
listen = "0.0.0.0:8080"
base_path = ""
request_timeout_ms = 30000
header_read_timeout_ms = 30000
idle_timeout_ms = 120000
body_limit_bytes = 67108864
drain_delay_ms = 0
shutdown_timeout_ms = 10000
# max_concurrent_requests = 256
retry_after_s = 1
trusted_proxies = []
forwarded_header = "forwarded"
compression = true
compression_min_bytes = 1024
| Key | Type | Default | Description |
|---|---|---|---|
listen | socket address | 0.0.0.0:8080 | The IP address and port the listener binds: every interface at the port the container image exposes. A host name is refused at startup. PORT alone gives 0.0.0.0:<PORT>, and FERROEHR__SERVER__LISTEN wins over both. Set 127.0.0.1:8080 to keep a bare binary on the loopback. |
base_path | string | empty | The path every route is served under, the health routes included. Empty serves them at the process root. The openEHR API path is rest.base_path. |
request_timeout_ms | int | 30000 | A request still running after this long is answered 408 with the openEHR error body. |
header_read_timeout_ms | int | 30000 | How long a client may take to send its request head, counted from accept for a connection’s first request and from the first byte of each later request on a kept-alive connection; a client that has not sent it by then, one that sent nothing after connecting included, is disconnected. Bounds the slow-header and the silent connection, which no other limit reaches. 0 is refused. |
idle_timeout_ms | int | 120000 | How long a kept-alive HTTP/1.1 connection may wait for its next request; past it the server closes the connection with a clean end of stream. Keep it longer than the idle timeout of your clients (Go’s http.DefaultTransport keeps an idle connection 90 s) and of any load balancer, ingress controller or proxy in front, so they close an idle connection first and never send a request into one the server is closing. 0 is refused. |
body_limit_bytes | int | 67108864 (64 MiB) | The largest request body any route reads, refused 413 above it. The bulk routes (operational-template upload, /message/import) take up to this; rest.body_limit_bytes narrows the rest of the clinical surface below it. The largest operational template in the vendored CKM corpus is several megabytes, well below this ceiling. |
drain_delay_ms | int | 0 | On SIGTERM or SIGINT, readiness answers 503 this long while the listener still accepts, so an orchestrator stops routing first. |
shutdown_timeout_ms | int | 10000 | Then the listener closes and the requests in flight get this long to finish; a connection still open after it is dropped. |
max_concurrent_requests | int | unset | The requests served at once. Past it a request is answered 503 with Retry-After and the openEHR error body, never queued, so offered load beyond capacity cannot exhaust memory. The health routes are never refused. Unset, there is no bound. |
retry_after_s | int | 1 | The Retry-After of a 503 or 429 the listener writes. |
trusted_proxies | list | [] | The reverse proxies, as addresses or CIDR blocks, whose forwarded client address is believed. Empty, every request is named by its peer address. |
forwarded_header | enum{forwarded,x-forwarded-for} | forwarded | The header a trusted proxy names the client in (Forwarded is RFC 7239). |
compression | bool | true | Compress a response with gzip or Brotli when the client accepts one. |
compression_min_bytes | int | 1024 | The smallest response body that is compressed. |
A refusal the listener writes itself (408, 413, 429, 503) and a handler
that panics (500) answer the standard openEHR { error, message } body. 413
and 429 are not in the ITS-REST status table; ITS-REST admits them as
additional, non-conflicting codes, and they are what RFC 9110 §15.5.14 and
RFC 6585 §4 define for these refusals. A panic’s message reaches neither the
body nor the log.
Every request gets a request id the server mints, which the request log and the
access record carry and the response returns in X-Request-Id. A client’s own
X-Request-Id is returned to it unchanged and never recorded.
[server.caller_rate]: per-caller request rates
[server.caller_rate]
requests_per_second = 1024
burst = 2048
| Key | Type | Default | Description |
|---|---|---|---|
requests_per_second | int | (required in the table) | Sustained requests per second one authenticated subject may send on the clinical API. |
burst | int | (required in the table) | How many requests one subject may send at once before the rate applies. |
Unset, no caller is limited. The chart and the Compose quickstart set the values above.
This is not max_concurrent_requests, and you can tell them apart from the
status alone. max_concurrent_requests protects capacity: too many
requests in flight at once, from anyone, and the excess is refused 503 +
Retry-After. The caller rate protects fairness: one caller asking too often
over time, refused 429 + Retry-After. A 503 means the server is full; a
429 means you are asking too fast.
The limit is keyed on the authenticated subject, the only fair key for a clinical API: a hospital behind one NAT is a single address. It sits inside authentication, so the health routes, the status document and the discovery document are never refused by it.
Tip
Benchmarking this server? Lift the caller rate first, or you will measure it instead of the server. The project’s own measurement lanes compose an overlay that does exactly that, and both instruments refuse to write a record if the server answered any
429.
[server.tls]: native TLS and mutual-TLS client authentication
Native TLS termination on the main listener, unset by default; deployments
commonly terminate TLS at an ingress. client_auth = "required" is the IHE
ATNA ITI-19 mutually-authenticated-node posture (see the
Audit trail chapter). The
separate-port management listener always stays plain HTTP, so under
deployment_profile = "production" setting [management] port opens the
plaintext_listener gap.
[server.tls]
certificate_file = "/etc/ferroehr/server.pem"
key_file = "/etc/ferroehr/server.key"
client_auth = "none"
min_version = "1.3"
# reload_interval_s = 60
| Key | Type | Default | Description |
|---|---|---|---|
certificate_file | path | (required in the table) | Server certificate chain (PEM), the leaf first. A missing, unreadable or certificate-less file stops startup. |
key_file | path | (required in the table) | Server private key (PEM), read as a secret file. A key that does not belong to the leaf stops startup. |
client_auth | enum{none,optional,required} | none | Client-certificate policy. optional verifies a certificate when one is presented and still accepts connections without; required rejects any client without a verified certificate at the handshake. |
client_ca_file | path | unset | The explicit CA bundle client certificates must chain to, never the web PKI. Required when client_auth is optional or required, and refused at load when it is none, since a listener under none asks no client certificate. |
min_version | enum{“1.3”,“1.2”} | "1.3" | The lowest TLS version this listener negotiates. "1.0" and "1.1" are refused when the configuration is read (RFC 8996). |
reload_interval_s | int | unset | How often the certificate, key and client CA are read again while the server runs. The files are always read again on SIGHUP. A changed pair is served, and a changed client CA verifies, from the next handshake on without a restart; a read that fails is logged and the running configuration stays. |
healthcheck_identity_file | path | unset | The client certificate chain and key, in one PEM, that ferroehr healthcheck presents to a listener requiring one. |
Before 5.5.0, client_ca_file alone meant that every client presents a
certificate. From 5.5.0, client_auth decides it and defaults to none, so a
configuration carried over from an earlier release set client_ca_file and no
client_auth, and 5.5.0 served it without asking a client certificate. Such a
configuration is now refused at load. Set client_auth = "required" beside
client_ca_file to keep mutual TLS (GHSA-52mf-qg39-4q4j).
ferroehr healthcheck speaks TLS to a TLS listener over the loopback and
accepts exactly the leaf certificate in certificate_file.
Note
min_versiondefaults to 1.3 only, as RFC 9325 §3.1.1 has it supported. Setting"1.2"enables 1.2 alongside 1.3, with the four forward-secret suites RFC 9325 §4.2 recommends; pick it only for a client that cannot do 1.3, such as an older integration engine or a pinned Java runtime.
Note
An EC certificate must name its curve. RFC 5480 §2.1.1 forbids explicit curve parameters, TLS clients refuse such a certificate, and the server refuses the pair at startup. LibreSSL (the
opensslthat ships with macOS) writes explicit parameters fromgenpkeyandreq -newkey ecunless you add-pkeyopt ec_param_enc:named_curve;openssl ecparam -name prime256v1 -genkeywrites a named curve.
[rest]
The openEHR REST surface the listener serves.
[rest]
base_path = "/ferroehr/rest/openehr/v1"
swagger_ui = "private"
cors_permissive = false
system_id = "ferroehr.local"
# body_limit_bytes = 16777216
| Key | Type | Default | Description |
|---|---|---|---|
base_path | string | /ferroehr/rest/openehr/v1 | ITS-REST base path all API routes hang off. Shortening it is supported; see below. The served OpenAPI document describes whatever paths this setting produces, never the defaults. |
swagger_ui | enum{off,admin_only,private,public} | private | Who may read the Swagger UI and the OpenAPI documents under the REST root: off does not mount them, admin_only needs the admin role, private needs any authenticated principal (unauthenticated is 401 with the server’s WWW-Authenticate challenge, so a browser prompts for the Basic credential), public needs nothing. The documents list the whole enabled operation surface, admin and message groups included, so public discloses it to anyone who can reach the port; the hosted sandbox sets it deliberately. With authentication off the guard admits everyone, as the management endpoints do. |
cors_permissive | bool | false | Permissive (development) CORS. Left on, any origin may read API responses, so the server warns loudly at boot. Production configures explicit origins at the edge. |
system_id | string | ferroehr.local | This deployment’s own openEHR system identifier; see below. Set a stable, deployment-unique name in production (FERROEHR__REST__SYSTEM_ID). |
body_limit_bytes | int | unset | The largest body the ordinary clinical surface accepts, below server.body_limit_bytes, which the bulk routes keep. Unset, every route takes the listener’s limit. A value at or above the listener’s is refused at startup. The largest example composition in the vendored corpus is 526 KB; raise it if your compositions embed large DV_MULTIMEDIA data. |
base_path: shortening the REST base path
ITS-REST leaves the API base to the deployment and fixes only the version
segment at its end, so you may shorten base_path. The shape is checked at
boot, and a value that breaks a rule stops the server with an error naming the
key and every rule it broke:
- The first segment is
ferroehr./ferroehrx/v1and/x/ferroehr/v1are refused. This segment is not configurable away. - The last segment is
v1, the openEHR API version this server implements. The shortest accepted value is therefore/ferroehr/v1. - No trailing slash, no empty segment (
//), and every segment uses only the unreserved URL charactersA-Z a-z 0-9 - . _ ~.
The status and documentation routes hang off the REST root, which the server
derives from base_path by dropping the segments that name the openEHR API: the
trailing v1, plus an openehr segment directly before it when you spell one.
base_path | REST root | Status document |
|---|---|---|
/ferroehr/rest/openehr/v1 (default) | /ferroehr/rest | /ferroehr/rest/status |
/ferroehr/openehr/v1 | /ferroehr | /ferroehr/status |
/ferroehr/v1 | /ferroehr | /ferroehr/status |
/ferroehr/cdr/v1 | /ferroehr/cdr | /ferroehr/cdr/status |
The Swagger UI ({rest root}/swagger-ui), the OpenAPI documents
({rest root}/api-docs/…) and the SMART discovery document
({rest root}/.well-known/smart-configuration) move with it. The health family
is served under server.base_path, which is empty by default, so it stays at the
process root: /health, /health/liveness, /health/readiness.
Set it from the environment with:
FERROEHR__REST__BASE_PATH=/ferroehr/v1
Note
The
ferroehr healthchecksubcommand, which the published container images run as their Docker health check, asksGET /health/readinesson theserver.listenport over the loopback, sorest.base_pathdoes not move it.
Behind a path-prefixed reverse proxy
A proxy that mounts the CDR under a prefix stacks that prefix on top of
base_path. A proxy serving the server at /cdr with the default base path
gives clients URLs like:
https://cdr.example.org/cdr/ferroehr/rest/openehr/v1/definition/template/adl1.4
Two ways to shorten that. Strip the prefix at the proxy, so the server sees the
path it serves (nginx proxy_pass http://cdr:8080/; with the trailing slash,
Caddy handle_path, Traefik StripPrefix). Or shorten the base path:
FERROEHR__REST__BASE_PATH=/ferroehr/v1
which gives https://cdr.example.org/cdr/ferroehr/v1/definition/template/adl1.4.
Whichever you choose, tell every client. The server builds its Location
headers, its served OpenAPI paths and its SMART discovery document from its own
rest.base_path, and it cannot see a prefix the proxy adds. The
FerroEHR Viewer has its own mirror key, cdr.base_path,
which must match.
system_id: the data-authoring identity
system_id is the identifier this CDR stamps into the data it authors. It
appears on the wire in three places:
EHR.system_id, recorded when an EHR is created. The openEHR RM (EHR Information Model, EHR Identifier Allocation) says theEHR.system_id“should be set to the value that would normally be used for locally created EHRs”: a value the deployment chooses, not a product constant.AUDIT_DETAILS.system_idon every commit for which the client did not supply one through theopenehr-audit-detailsheader. The openEHR REST API requires that “whensystem_idis not provided by the client, the server MUST set it to its own configured system identifier”.OBJECT_VERSION_ID.creating_system_id: the middle segment of every version identifier the server mints (<object_id>::<creating_system_id>::<version>).
Practical notes:
- The value must be a legal openEHR UID: a UUID, an ISO OID, or an
internet id (a reverse-domain / DNS-style name) per the openEHR BASE
identification grammar. The server judges it with the same validating
constructor its reader uses and refuses to boot on an illegal value,
because it becomes the
creating_system_idsegment of every version identifier the server mints and an illegal value would produce ids the server’s own reader rejects. A DNS-style name likecdr.hospital.exampleis valid; an empty value, or one containing the::field separator, is not. - Choose it before going live and keep it stable. The value is stored with
each EHR and each version; changing it later affects only newly authored
data: existing EHR ids, audit rows and version identifiers are never
rewritten, and previously issued
OBJECT_VERSION_IDs stay valid. - Make it unique per system, so data exchanged between openEHR systems
keeps unambiguous provenance. A node of a federation must hold a
system_idno other member uses (Federation Tier N42a); the defaultferroehr.localis the same on every node that leaves it unset. See FerroEHR as a federation node. system_idis not[rest.identity].system_idsays which system authored the data;[rest.identity]is the display identity of theOPTIONSSystem-Options manifest. Rebranding changes the manifest and nothing in stored data; changingsystem_idchanges what new data says about its origin and leaves the manifest alone.
[rest.identity]
The System-Options manifest identity (OPTIONS on the API base path, e.g.
OPTIONS /ferroehr/rest/openehr/v1, the System API’s one location). This is
the deployment’s display identity only; the identifier stamped into authored
data is system_id.
| Key | Type | Default | Description |
|---|---|---|---|
solution | string | FerroEHR | Product name. |
solution_version | string | the build’s version | Product version. |
vendor | string | Cadasto B.V. | Providing organisation: by default the manufacturer of the release, the name ferroehr --version and GET /management/info give. |
restapi_specs_version | string | the ITS-REST release this build implements (1.1.0) | The openEHR REST API edition advertised. |
conformance_profile | string | the profile the build’s recorded conformance verdict earned | Advertised conformance profile. |
The defaults are derived from the build rather than typed into the handler, so the manifest never out-claims what was actually measured. Override them only to rebrand.
[db]
PostgreSQL connection.
[db]
url = "postgres://ferroehr:ferroehr@localhost:5432/ferroehr"
migrate = "apply"
max_connections = 20
min_connections = 2
acquire_timeout_secs = 30
statement_timeout_ms = 60000
| Key | Type | Default | Description |
|---|---|---|---|
url | secret URL | postgres://ferroehr:ferroehr@localhost:5432/ferroehr | Connection DSN. The default suits a local from-source run against a localhost PostgreSQL, and the server logs a prominent warning at boot while it is in use; production MUST set it. Credentials are redacted from every rendering. DATABASE_URL is a recognized lower-priority alias. |
url_file | path | unset | Read the DSN from a file instead of the key above, for a mounted secret. Preferred over the environment form in Kubernetes: an environment value is readable through /proc/<pid>/environ and inherited by every child process. At most one of the pair, where the built-in development default does not count as “set”. |
migrate_url | secret URL | unset | DSN that PREPARES the schema, used for that one boot step and then closed. Preparation spans every schema of a database at once, which no domain-scoped runtime credential can do, so a deployment separating the runtime roles names the credential that can here. It prepares every domain whose DSN reaches the SAME DATABASE — which is the whole of the separated-credential posture, where each domain has a login role of its own on one database; a domain whose DSN reaches a different database is prepared on that database’s storage.migrate.<domain>.url (on the domain’s own DSN when that is unset). Which it is, is read from the server (pg_control_system() plus current_database()), never from the DSN text. Unset, it falls back to url. See Operations → Which credential prepares the schema. |
migrate_url_file | path | unset | Read migrate_url from a file instead, for a mounted secret. At most one of the pair. |
migrate | enum{apply,verify} | apply | Whether the server applies its embedded migrations at boot. apply is what makes an empty configuration boot against an empty database. verify issues no DDL at all: it checks that the database already carries exactly this build’s migrations and refuses to start otherwise, so the serving DSN can authenticate as a role with no DDL rights. That check still READS every _sqlx_migrations table of each database, which no least-privilege role can do, so pair it with migrate_url above and with ferroehr db migrate run out of band; see Operations. |
max_connections | int | 20 | Pool ceiling. Write-heavy deployments benefit from raising it. |
min_connections | int | 2 | Idle connections kept open, avoiding cold-reopen churn under variable load. |
acquire_timeout_secs | int | 30 | Seconds to wait for a free connection before failing. |
statement_timeout_ms | int | 60000 | statement_timeout applied to every pooled connection; 0 leaves the server default. |
statement_timeout_ms is the backstop for every statement. An AQL query
(ad hoc or stored) and the composition-count statistics stop sooner when their
caller goes away: a client that disconnects, or the AQL engine’s own
query.timeout_ms, closes the statement’s
connection, its pool slot frees at once, and every pooled session sets
PostgreSQL’s client_connection_check_interval to one second, so the backend
stops the statement within about a second. Other statements run to their end
or to this timeout, so keep it above query.timeout_ms: the AQL engine’s
typed refusal then fires first and this only catches what the engine does not
govern.
[storage]
One DSN per storage domain. Every domain always lives in its own schema
(clinical, party, linkage, audit) and every pool carries only its own on
its search_path, so no statement can reach another domain’s relations without
naming a schema it is not granted. Setting a url here adds the CREDENTIAL
separation, and — if the DSN names another host — lets the domain live in a
database or cluster of its own.
[storage.party]
url_file = "/run/secrets/db-party-dsn"
[storage.linkage]
url_file = "/run/secrets/db-linkage-dsn"
| Key | Type | Default | Description |
|---|---|---|---|
storage.clinical.url | secret URL | unset | DSN the clinical domain connects on (role ferroehr_clinical). Unset, it uses db.url. |
storage.party.url | secret URL | unset | DSN the party (demographic) domain connects on (role ferroehr_party). Unset, it uses db.url. |
storage.linkage.url | secret URL | unset | DSN the linkage domain connects on (role ferroehr_linkage) — the map from a party to the EHR whose subject it is, barred from both domains it joins and they from it. Unset, it uses db.url. |
storage.audit.url | secret URL | unset | DSN the audit repository is written through. Unset, it uses db.url. |
storage.<domain>.url_file | path | unset | Read that domain’s DSN from a file instead, for a mounted secret. At most one of the pair. |
Why a schema is not always enough: a base backup, WAL archiving and physical
replication carry every schema of a database together
(PostgreSQL 18, Backup and Restore),
so a separation that must survive those is a separation of databases. The Swiss
EPDV Art. 10 Abs. 1 lit. b asks for storage “von anderen Datenbeständen getrennt”
and the DSV Art. 4 Abs. 5 for the log to be kept “getrennt vom System, in welchem
die Personendaten bearbeitet werden”; openEHR reads the same way for the identity
cross-reference, which “could be located on different machines” (BASE
architecture_overview/master07-security.adoc §Anonymity). No openEHR spec
governs pools or database roles — this is our own design.
What the server checks at boot:
- a runtime role that can read another domain’s relations is refused, always;
- two domains configured on different DSNs that turn out to authenticate as the same database role are refused — a separation that exists only in the configuration is worse than none, because it reads as one that holds;
- a missing domain role is a warning under
deployment_profile = "sandbox"and a refusal underproduction, where there are no grants to separate anything with.
Each database is prepared on its own migration credential. [db] migrate_url
names one database; a domain whose DSN reaches another database is prepared
there, and verified there under migrate = "verify", on the credential named in
[storage.migrate.<domain>]:
[storage.linkage]
url_file = "/run/secrets/db-linkage-dsn" # postgres://ferroehr_linkage:…@linkage-db/…
[storage.migrate.linkage]
url_file = "/run/secrets/db-linkage-migrate-dsn" # postgres://ferroehr_migrator:…@linkage-db/…
| Key | Type | Default | Description |
|---|---|---|---|
storage.migrate.<domain>.url | secret URL | unset | DSN that prepares the database the domain lives in, when that is not the database db.migrate_url reaches. Used for that one step and closed again. Unset, such a database is prepared on the domain’s own url, which a DML-only runtime role cannot do: it is refused on the first bookkeeping table it cannot read. |
storage.migrate.<domain>.url_file | path | unset | Read it from a file instead, for a mounted secret. At most one of the pair. |
The server refuses to start when a domain’s url and its storage.migrate
URL reach different databases. A database that holds the linkage domain
without the party domain also receives the party migration set, its schema
and grants and never a row, because the linkage set revokes a function the
party set creates.
[telemetry]
The console, the request log and the OpenTelemetry export: the section every
FerroHEALTH product shares (ferrohealth-telemetry). With no
[telemetry.otlp] table nothing is exported.
[telemetry]
format = "auto"
filter = "info,hyper=warn,tower=warn,h2=warn"
logged_query_parameters = []
# environment = "production"
# [telemetry.otlp]
# endpoint = "http://collector:4317"
# traces = true
# metrics = true
# sample_ratio = 1.0
| Key | Type | Default | Description |
|---|---|---|---|
format | enum{auto,json,pretty} | auto | Stdout rendering. auto picks json when stdout is not a TTY and the human format when it is. NO_COLOR turns colour off. |
filter | string | info,hyper=warn,tower=warn,h2=warn | Console filter directives, refused at boot when they do not parse; also the value /management/loggers resets to. RUST_LOG is a lower-priority fallback. The filter decides what the console writes and never thins a trace. |
logged_query_parameters | list of strings | [] | The query parameters whose values the request log may carry, each cut to 64 characters. Empty, the request log carries no query value. |
environment | string | unset | The deployment.environment.name resource attribute of every span and metric. |
otlp.endpoint | secret URL | (required in the table) | OTLP/gRPC collector endpoint; https:// speaks TLS. Credentials in it are masked in every rendering. |
otlp.traces | bool | true | Export FerroEHR’s spans. |
otlp.metrics | bool | true | Push the metrics over OTLP beside the Prometheus pull surface. One meter provider feeds both. |
otlp.sample_ratio | float | 1.0 | The share of traces sampled (0.1 is a common production start). A client’s traceparent is never read: every request starts a trace of the server’s own. |
otlp.trust_roots_file | path | unset | PEM trust roots the collector’s certificate may chain to, beside the platform’s. |
otlp.client_identity_file | path | unset | PEM client certificate chain and key, for mutual TLS with the collector. |
The service.name of every span and metric is ferroehr, and
service.version is the release and its commit.
The request log writes one line per request: the method, the matched route
template, the status, the latency and the server’s request id, never a body,
the raw path or a header value. It records the http.server.request.duration
histogram, scraped as http_server_request_duration_seconds with the
http_request_method, http_route, http_response_status_code and
url_scheme labels.
Tip
The ASCII boot banner follows the rendering that is actually installed, not the configured word: it prints for
pretty, and forautoonly when stdout is a terminal. Underjson, and underautooff a terminal (a container, a pipe into a log collector) stdout is parseable JSON from the first byte.
Note
Instrument names carry no Prometheus suffix; the exporter derives one. A counter named
auth_failuresis scraped asauth_failures_total, and units add_seconds/_bytes. Over OTLP the unsuffixed name is what a collector receives.
The scrape endpoint itself is not opened here; it is
management.endpoints.prometheus, which is off
until you name a level.
[usage_report]
The daily installation report to FerroPULSE, the collector Cadasto B.V. runs. It is on by default, and every boot logs one line saying whether it is on. What a report carries, when it is sent, the network it needs and the privacy notice are on the Usage report page.
[usage_report]
enabled = true
endpoint = "https://report.ferropulse.eu/v1/report"
deployment = "unknown"
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Send the report. false sends nothing and writes nothing to the database for it. In a CI run (GITHUB_ACTIONS or CI set to true or 1) the report is off whatever this says. Environment: FERROEHR__USAGE_REPORT__ENABLED. |
endpoint | URL | https://report.ferropulse.eu/v1/report | Where reports go. Must be an absolute https URL without credentials; plain http is accepted for a loopback host only. Anything else is refused when the configuration is read. Environment: FERROEHR__USAGE_REPORT__ENDPOINT. |
deployment | enum{helm,compose,binary,unknown} | unknown | How the instance was deployed, as the report states it. The Helm chart sets it to helm. Environment: FERROEHR__USAGE_REPORT__DEPLOYMENT. |
state_dir | path | unset | Refused. The section is the one every FerroHEALTH product shares, where this key names the directory of the instance id; FerroEHR keeps the instance id and the counts in the clinical database, so every replica reports as one installation. |
slow_aql_ms is no longer a key: the report carries no AQL series. A
configuration that still sets it is refused at boot; remove the line.
Under the Helm chart, set the switch with usageReport.enabled. The chart
writes enabled and deployment = "helm" into the rendered file itself and
refuses to render if either is also set under config.usage_report;
endpoint passes through config.usage_report unchanged.
Authentication & access
Who may call the server, what they may do once identified, and the surfaces
that are gated rather than always-on: [auth], [authz], [admin],
[smart], [management], and [signing]. Precedence, the
environment-name grammar, and file discovery are on the
Configuration reference index.
[auth]
Authentication: bearer tokens of one or more trusted issuers, Basic
credentials, or both. [auth] is the section every FerroHEALTH product shares,
checked by the ferrohealth-smart crate. FerroEHR is a resource server: it
checks tokens and never issues them.
[auth]
enabled = true
issuer = "https://keycloak.example.com/realms/ferroehr"
audience = ["ferroehr"]
[[auth.basic.users]]
user = "clinician"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…$…" # never a plaintext password
scopes = ["user/composition-*.cruds", "user/template-*.cruds", "user/aql-*.cruds"]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master switch. false = all requests pass unauthenticated (development only). With true and no mechanism configured the server refuses to start; see below. |
issuer | string, or [[auth.issuer]] tables | unset | The issuer whose tokens are accepted. Its metadata is read at boot, at the RFC 8414 §3 location first and the OpenID Connect Discovery 1.0 §4 location after a 404, and with it the key set at its jwks_uri, so an authorization server that cannot be reached refuses the start. An https URL with no query and no fragment and no user name or password; plain http is accepted on a loopback host only. Several issuers are [[auth.issuer]] tables, each carrying the keys from issuer to jwks_file. |
audience | list of string | required with an issuer | Accepted aud values; a token naming none of them is refused (RFC 9068 §4, RFC 8725 §3.9). |
algorithms | list of string | ["RS256","RS384","PS256","PS384","ES256","ES384"] | Accepted signature algorithms. Asymmetric only: an HS* algorithm is a boot error, and none is no algorithm at all (RFC 8725 §3.1, §3.2). |
token_profile | rfc9068 or jwt | rfc9068 | rfc9068 requires typ at+jwt and the client_id, iat and jti claims (RFC 9068 §2.2, §4). jwt admits a plain JWT bearer token (typ absent or JWT) for an issuer that mints no RFC 9068 tokens, and reads a scp array when scope is absent. A token typed as another kind of JWT is refused under either. |
mode | jwt or introspection | jwt | How a bearer token is checked. jwt checks it as a JWT against the issuer’s key set. introspection posts an opaque token to the issuer’s RFC 7662 endpoint (see Opaque tokens); it needs a flat issuer, and an [auth.introspection] table beside jwt is a boot error. |
clock_skew_seconds | int | 60 | Leeway on exp, nbf and iat. At most 300. |
jwks_max_age_seconds | int | 600 | A key set older than this is read again. |
jwks_refresh_interval_seconds | int | 60 | A token naming an unknown kid reads the key set again at most once per interval. |
jwks_file | path | "" | A JWK Set of public keys handed over out of band, read instead of the issuer’s metadata. A symmetric key (kty: "oct") or a private-key member (d, p, q, dp, dq, qi, oth, k) is a boot error. An issuer read from a file has no metadata, so it serves the SMART discovery document only with [smart] issuer_metadata_file set. |
timeout_ms | int | 10000 | One call to the authorization server. |
ca_file | path | unset | A PEM bundle of certificate authorities trusted in addition to the platform roots, on every platform, for an issuer behind a private PKI: the issuer’s metadata and key set are read trusting it, and under mode = "introspection" the metadata, introspection and token calls trust it beside the grant’s own ca_file. A file that cannot be read, holds no certificate, or does not parse refuses the start. |
realm | string | "" ⇒ ferroehr | The realm every WWW-Authenticate challenge names. |
role_claims | list of string | ["roles","groups","entitlements"] | Token claim paths the caller’s roles are read from, in order; dotted paths walk nested claims, and a role is matched without regard to case. A path that is empty, has an empty step or carries whitespace is a boot error. scope is not a role source; see Security. [authz.rbac] role_claims, where this key lived before, is refused at boot. |
scopeless_subjects | list of string | [] | The exact sub values whose token, when it carries no resource scope of either the SMART on openEHR or the SMART on FHIR grammar, meets every SMART on openEHR requirement. A token that carries resource scopes gets only its scopes, and the allowlist reaches neither the ADMIN API nor the DEMOGRAPHIC API, which need the product scope. |
capabilities | list of string | [] | SMART capabilities the discovery document adds: one SMART App Launch or the SMART on openEHR draft lists, or a full URI. |
scopes_supported | list of string | [] | The scopes the discovery document lists. Empty, it lists the scopes the route table honours. |
product_scope | string | "" ⇒ __ferroehr.platform | FerroEHR’s own scope. It admits the ADMIN API and, beside the bearer clients in [smart] demographic_clients, the DEMOGRAPHIC API, the two surfaces no SMART on openEHR resource family covers. A full URI or a string starting with __ (SMART App Launch 2.2.0, Scopes and Launch Context, §Extensibility). |
Warning
auth.enabled = truewith no mechanism is a boot error. Such a server could only refuse every request while advertising an authentication scheme it does not implement, which RFC 9110 §11.6.1 forbids: a401challenge must name a scheme applicable to the target resource. Configure anissuer, configure[[auth.basic.users]], or setauth.enabled = falsefor a development server.
The key-set fetches verify the identity provider’s certificate against the
platform trust store and follow no redirects. An identity provider whose
certificate comes from a private CA is trusted once that CA is in the image’s
system store or in a directory or file named by SSL_CERT_DIR or
SSL_CERT_FILE. A key set that cannot be read when a token needs it answers
503, never 401: the server cannot decide, and a 401 would tell a caller
with a valid token that its credential was refused.
A validated token must also carry a non-blank sub claim: the authenticated
subject is stamped into the audit trail, so a token without one is refused with
401 rather than recorded under a placeholder identity. A bearer token outside
the RFC 6750 §2.1 b64token alphabet is refused the same way, with 401 and
error="invalid_token".
Opaque tokens by introspection
An authorization server that issues opaque access tokens is served with
mode = "introspection". Each bearer token is posted to the issuer’s
introspection endpoint (RFC 7662 §2.1), and the active response stands in for
the JWT claims: sub, scope, the roles of role_claims, aud against
audience and iss against the issuer. An inactive token is a 401. An
endpoint that cannot answer is a 503, because the server cannot decide. An
active response is reused until its exp, at most cache_max_age_seconds.
| Key | Type | Default | Description |
|---|---|---|---|
endpoint | URL | unset | The introspection endpoint; unset reads the introspection_endpoint of the issuer’s metadata. https, or http on a loopback host. |
cache_max_age_seconds | int | 60 | The longest an active response is used; 0 caches nothing. |
cache_capacity | int | 10000 | The most responses held at once. |
client | table | unset | The client authentication of the call (RFC 7662 §2.1), with the keys of an outbound grant: client_id and client_secret, private_key_jwt or mutual TLS (see the grant keys). |
bearer | table | unset | A client credentials grant whose token authorizes the call instead (SMART App Launch 2.2.0, Token Introspection). Exactly one of client and bearer. |
[auth]
issuer = "https://idp.example.org/realms/ferroehr"
audience = ["ferroehr"]
mode = "introspection"
[auth.introspection.client]
client_id = "ferroehr-rs"
client_secret_file = "/run/secrets/introspection-client"
The Basic-auth user store
[[auth.basic.users]] is an array of tables and therefore file-only: the
environment grammar cannot spell an array index.
| Key | Type | Default | Description |
|---|---|---|---|
user | string | required | The user-id. Empty, or with a colon or a control character, is a boot error (RFC 7617 §2). |
password_hash | secret | required | Argon2id PHC hash ($argon2id$v=19$…), never a plaintext password. Boot-validated against the OWASP floor; see below. |
password_hash_file | path | unset | Read the hash from a file instead, for a mounted secret. A hash is an offline cracking target, so prefer this wherever the configuration file itself is not treated as sensitive. Exactly one of the pair is required. |
scopes | list of string | [] | The SMART on openEHR scopes the user holds, checked on every route exactly like a token’s scope claim. A user with none is refused everywhere a requirement applies. |
roles | list of string | [] | The user’s RBAC roles, upper-cased on authentication. A user with none holds no role and is refused wherever [authz.rbac] asks for one. The former [authz.rbac.basic_roles] table is refused at boot. |
[auth.basic] itself takes one key beside the users:
| Key | Type | Default | Description |
|---|---|---|---|
cache_ttl_seconds | int | 300 | How long a credential that verified is kept, so a repeat request skips the Argon2id derivation; 0 turns the cache off and checks every request. The entry is named by a keyed hash of user-id:password under a key drawn at boot, never the password, and only a credential that verified is kept, so a wrong password always pays the derivation. The cache holds at most 4096 credentials and empties at a restart. The TTL bounds how long a removed user keeps working, which matters only until the restart that applies the new user list. Argon2id is slow by design, so with the cache off its cost bounds how many Basic requests a core can answer. |
Warning
Every
password_hashmust meet the OWASP Argon2id floor:m>=19456(19 MiB),t>=2,p>=1, algorithmargon2id. Anything weaker (or a non-argon2idPHC string, or an unparsable one) is a boot error naming the user. This is checked at startup because the verifier takes its cost parameters from the stored hash, so a deliberately cheap hash would otherwise verify happily and silently weaken every password in the store. The floor is the OWASP Password Storage Cheat Sheet §Argon2id minimum.
An unknown user-id is checked against a decoy hash of the same cost, so the time a refusal takes does not say whether the user exists.
[authz]
Role-based (RBAC) and attribute-based (ABAC) authorization, and the patient-data assurance gate. The full evaluation order and design rationale are in Security.
[authz.assurance]: the assurance level patient data requires
Off by default. What it refuses and the 401 challenge it sends are described
in Security.
| Key | Type | Default | Description |
|---|---|---|---|
claim | string | acr | The token claim carrying the authentication context class; a dotted path reaches into a JSON object. |
levels | table of string → low | substantial | high | empty | Maps each value your identity provider puts in claim to an eIDAS level of assurance. A value the table does not name carries no level. File-only: the keys are usually URIs. |
minimum | low | substantial | high | unset | The level a patient-data request must carry. Unset refuses nothing. |
[authz.assurance]
minimum = "substantial"
[authz.assurance.levels]
"http://eidas.europa.eu/LoA/substantial" = "substantial"
"http://eidas.europa.eu/LoA/high" = "high"
Boot refuses a minimum with an empty levels table or with no value mapped at
or above it (every patient-data request would be refused), a blank claim
beside a declared table, and a levels value that is blank or carries
whitespace, a quote or a backslash, because the refusal names the accepted
values in one quoted, space-separated acr_values parameter (RFC 9470 §3).
[authz.professional]: the natural person behind a token
Absent by default, and tokens are then not judged on it. Present, a
patient-data request whose token names no natural person is refused with 403,
unless it is a client token acting for a named professional.
| Key | Type | Default | Description |
|---|---|---|---|
claim | string | sub | The claim identifying the natural person; a dotted path reaches into a JSON object. A token lacking it is a client token. |
client_tokens | sub_is_client | claim_absent | sub_is_client | How else a client-credentials token is recognised. sub_is_client also treats a token whose sub equals its client_id or azp as a client token (RFC 9068 §2.2); claim_absent relies on the missing claim alone. |
acting_for_claim | string | unset | The claim a client token carries the professional’s identifier in, such as act.sub. Unset refuses every client token on patient data. |
The access record then carries the acting mode and the professional; see
Audit trail. Both tables are
reachable from the environment too (FERROEHR__AUTHZ__ASSURANCE__MINIMUM,
FERROEHR__AUTHZ__PROFESSIONAL__ACTING_FOR_CLAIM), except the levels map.
[authz.rbac]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | The coarse role gate (active when authentication is enabled). Off, every authenticated caller may use every enabled surface, reported as the rbac_off deployment gap. ehr_access_default and admin_role apply either way. |
admin_role | string | ADMIN | Role required for admin-class operations. A blank value is a boot error. |
readonly_role | string | READONLY | Role marking a principal read-only: refused on every write operation (create/update/delete/upload), even alongside granting roles. Reads and AQL queries are still allowed. |
emergency_access_role | string | unset | Role that reads data under an EHDS Art. 8 access restriction when the caller also declares one of [audit] emergency_purpose_codes (EHDS Art. 11(5)); the access record then carries the emergency mark. Unset, nobody may override a restriction. See restriction of access. |
identifier_reader_role | string | unset | Role that may read a party’s protected national identifiers through GET /ferroehr/rest/extensions/v1/admin/party/identifiers, beside the admin role. Every read is an access record naming the reader, the party and the scheme. Unset, nobody may. See reading a party’s protected identifiers. |
subject_audit_role | string | unset | Role that may read the access log for one subject at a time: GET /fhir/r4/AuditEvent with the patient parameter required, refused without it. The grant a patient portal holds to serve a person’s right to know who accessed their record (GDPR Art. 15, EHDS Art. 9), instead of an admin credential over every patient’s log. Unset, the log stays admin-only. See Audit trail. |
ehr_access_default | enum{open,restricted} | open | What an EHR carrying no ACCESS_CONTROL_SETTINGS admits. restricted is object-level default-deny: only admin_role reaches a setting-less EHR, so an operator can still author the settings that open it. It is enforced whether enabled is on or off. With [auth] enabled = false nothing could enforce restricted, so that combination is a boot error. See Security. |
Note
The management surface is not configured under
[authz.rbac].[management.endpoints]owns it, one level per endpoint, with no global default beside it: an endpoint you do not name isoffand is not mounted. Only theadmin_onlylevel consultsauthz.rbac.admin_role.
[authz.abac]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Master ABAC switch. |
engine | enum{cedar,remote} | cedar | Embedded Cedar, or a remote decision point. |
organization_claim | string | organization_id | JWT claim carrying the caller’s organization. |
patient_claim | string | patient_id | JWT claim carrying the patient id. Set, it scopes every request to the patient it names (see below). Empty turns the patient scope off. |
patient_namespace | string | unset | The subject namespace (EHR_STATUS.subject.external_ref.namespace) the patient ids in patient_claim are issued in. Set, a claim names the EHR whose subject is that id under this namespace. Unset, a claim names the EHR holding that id only when no other namespace holds the same id. |
check_directory | bool | false | Submit DIRECTORY (FOLDER) operations to the decision point. Engine-independent, so it works under Cedar as well as a remote PDP. |
The patient scope. With ABAC on and patient_claim set, a token
without that claim is refused with 403. With the claim:
- A route that names an EHR (
/ehr/{ehr_id}/…) answers403unless the claim names that EHR’s subject. An EHR that records no subject is in no patient’s scope and is refused. - Without
patient_namespace, an id held under two or more namespaces names none of those subjects, so it reaches none of their EHRs. Setpatient_namespaceto read the claim in one namespace. - An AQL query returns rows only from EHRs the claim names, whatever the query
selects, including
SELECT e/ehr_id/value FROM EHR e.
After a query runs, the decision point receives the subject ids of every EHR
the query touched as its patient attribute, so a patient-keyed policy
applies to queries whether or not patient_claim is set. Without
patient_namespace, a query that touches a subject id held under several
namespaces is refused with 403, because a bare id would name both subjects.
[authz.abac.cedar]:policy_dir(path, required whenengine = "cedar"and ABAC is on) andreload_secs(int, unset, an optional hot-reload interval).[authz.abac.remote]:server(secret URL, required whenengine = "remote", and it must end with/, because the policy name is appended; credentials embedded in it are redacted from every rendering),connect_timeout_ms(int,2000),request_timeout_ms(int,5000).[authz.abac.remote.oauth]: calls the policy service as an OAuth 2.0 client instead of with the Basic credentials ofserver: FerroEHR obtains a token with the client credentials grant and sends it as a bearer token. The keys are those of[audit.fhir_feed.oauth](token_endpointorissuer,client_id,client_secretorclient_secret_file,client_auth,scope,audience,resource). When the policy service refuses the token with401, a new one is fetched and the decision asked once more; a token that cannot be obtained, or that is refused again, is no decision, and the request fails closed with a500, never a403.serverwith credentials beside this table stops startup.[authz.abac.policy.<kind>]: one entry per resource kind, withkind∈ehr,ehr_status,composition,contribution,query,directory. Keys:name(string, the policy to evaluate) andparameters(list of enum{organization,patient,template}). A key that is not one of those six kinds is a boot error, andtemplateis rejected onehrandehr_status, since neither carries a template.
Warning
With
engine = "remote", every resource kind the enforcement point consults needs a policy entry:ehr,ehr_status,composition,contribution,query, plusdirectorywhencheck_directory = true. A missing one is a boot error. At runtime a kind with no policy can only be denied, since there is no policy to ask and permitting would be a silent hole, so the misconfiguration is caught at startup rather than turning into blanket403s on live traffic. The Cedar engine reads its policies fromauthz.abac.cedar.policy_dirand needs no entries here.
[admin]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Mount the ADMIN API (physical, irreversible delete). Off ⇒ every admin route answers 405 Method Not Allowed with an empty Allow header, never 403, and never touches the backend. |
Physical deletion is irreversible, so the group stays off by default. With it
on, /admin also joins the group list the OPTIONS System-Options manifest
advertises, so the manifest never names a group that answers 404.
[smart]
The SMART on openEHR scopes are enforced on every route whenever
[auth] is on: each operation carries a requirement from FerroEHR’s route
table, which the ferrohealth-smart crate checks against the token’s scopes
or a Basic user’s scopes. There is no switch for it. This section decides
what beside the route table: whether the discovery document is served, and
whose grants count for what. See SMART App Launch.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Serve /.well-known/smart-configuration. It is built from the first [auth] issuer’s metadata, so it needs an issuer whose metadata names a token_endpoint (SMART App Launch 2.2.0, Conformance, §Metadata); an issuer read from a jwks_file refuses the start with enabled = true unless issuer_metadata_file supplies that metadata. |
issuer_metadata_file | path | unset | A JSON file holding the RFC 8414 §2 metadata of the first [auth] issuer when its key set is a jwks_file, read at boot. It must name that issuer, repeat no member name, and every endpoint it names must be https (a loopback host excepted); the discovery document takes its token_endpoint and the other endpoints from it. Set for an issuer whose metadata is read from the issuer, it is a boot error. |
platform_base_url | string | unset ⇒ the REST root | The path the discovery document hangs off, e.g. /gateway/v1. |
public_base_url | string | required when enabled | The server’s externally reachable origin (e.g. https://cdr.example.com), from which the discovery document’s absolute services.*.baseUrl values are built. |
launch_base64_json | bool | false | Advertise the launch-base64-json capability. Experimental, and advisory: the base64 JSON launch object is consumed by the application, not the CDR. |
backend_clients | list of string | [] | The client_ids a system/aql-* grant counts for: such a grant “would grant access to all registered and ad-hoc AQL queries system-wide” (master08 §Resource Scopes). |
patient_issuers | list of string | [] | The issuers whose patient/ grants count, each confined to the EHR the token’s ehrId claim names (master07 §Context Selection). Empty, a patient/ grant grants nothing. |
demographic_clients | list of string | [] | The bearer client_ids that reach the DEMOGRAPHIC API, which no SMART on openEHR scope covers. A Basic user-id never matches an entry. A caller holding [auth] product_scope reaches it too. |
[smart.episode]: enabled (bool, false) advertises episode context and
accepts the launch/episode scope and episodeId claim, but applies no
episode-scoped filtering.
No smart URL may carry a user name or password, whether or not the document
is served: the discovery document publishes these URLs to every application,
so a credential in one would be served to anyone who asks.
[management]
The ops-introspection surface: build info, Prometheus, metric views, the effective configuration, runtime log control, and the on-demand profiler. Off by default, and every endpoint off individually.
The health probes are not configured here: /health, /health/liveness
and /health/readiness are always served on the main API port without
authentication, whatever this section says (see
Operations → Health probes).
[management]
enabled = false
base_path = "/management"
[management.endpoints]
info = "off"
metrics = "off"
prometheus = "off"
env = "off"
loggers = "off"
flamegraph = "off"
[management.profiling]
max_seconds = 30
max_frequency = 999
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Mount the management router. |
base_path | string | /management | Base path for the management endpoints. |
port | int | unset ⇒ share the main listener | Serve management on its own listener and port. Must differ from the server.listen port, and it is plain HTTP, an internal surface. |
flame_file | path | unset | Span-timing capture: write the folded stacks of every span to this file, and render offline with inferno-flamegraph. Unset, the layer is not installed. For diagnostic sessions: the file grows with span traffic. |
[management.endpoints]: info, metrics, prometheus, env, loggers
and flamegraph, each enum{off,admin_only,private,public}, default off.
| Level | Meaning |
|---|---|
off | Not mounted at all; the route answers 404. |
admin_only | Requires an authenticated principal carrying authz.rbac.admin_role (401 unauthenticated, 403 authenticated but not admin). |
private | Requires any authenticated principal. |
public | Served outside authentication. |
env renders the effective configuration and flamegraph starts a profiler on
request, so the boot log prints exactly which endpoints a configuration turned
on and at which level.
[management.profiling]: limits for the on-demand CPU flamegraph behind
endpoints.flamegraph (see
Operations → Profiling):
| Key | Type | Default | Description |
|---|---|---|---|
max_seconds | int | 30 | Longest sample window one request may ask for. A request asking for more is refused with 400, never clamped. |
max_frequency | int | 999 | Highest sampling frequency (Hz) a request may ask for. Same refusal semantics. |
Warning
probes_enabledandendpoints.healthdo not exist. Configuration is strict, so a file or environment variable still setting either one fails at boot with an unknown-key error: delete the key; the probes are always on.
[licence]
The commercial licence token. Every build embeds the licensor’s
non-commercial grant; a deployment that holds a commercial licence installs
its token here. The server behaves identically under either grant and reports
the one in force on GET /management/status (licence.use, licence.licensee,
licence.not_after, licence.configured_token). See
Licensing & legal.
| Key | Type | Default | Description |
|---|---|---|---|
file | path | unset | The licence token the licensor issued. A token that cannot be read or does not verify is reported as refused on /management/status; the embedded grant stays in force. |
[signing]
VERSION signing. On by default in digest mode, with read-time verification of
the server’s own signatures strict by default.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Server-side signing of committed versions. |
mode | enum{digest,pgp} | digest | A SHA-256 integrity digest, or an OpenPGP (RFC 4880) detached signature. |
key_path | path | unset | Armored secret key; required for pgp (a boot error otherwise). |
key_passphrase / key_passphrase_file | secret / path | unset | Key passphrase. At most one of the pair. |
retired_key_paths | list of path | [] | Armored public keys retired from signing and kept for verification, so versions signed before a key rotation keep verifying. |
verify_on_read | enum{off,warn,strict} | unset ⇒ strict when signing is enabled | Read-time recompute-and-compare policy for the server’s own signatures. |
retired_key_paths exists because a stored VERSION.signature records no key
identifier and is an immutable committed fact that cannot be re-issued;
keeping the retired public key is the only way history stays verifiable across
a rotation, and a public key can verify but never sign. Its environment form
takes a comma-separated list
(FERROEHR__SIGNING__RETIRED_KEY_PATHS=/keys/a.pub.asc,/keys/b.pub.asc).
Note
verify_on_readresolves tostrictwhen signing is enabled. On every read the server recomputes the signature of a version it signed and, on a mismatch, returns a500integrity fault rather than silently serving a provably corrupt record. Set it explicitly towarn(log + meterversion_signature_invalid_total, still serve) oroff(never check). Client-supplied signatures (an author’s own, or one carried by an imported version) are always stored verbatim and never re-verified, whatever this setting says, because the author may have signed a different agreed serialization.
Warning
pgpmode fails closed at boot if the key is missing or unusable: the server will not start. Verify the key and passphrase before switching modes.
Choose an Ed25519 signing key
The server refuses an ECDSA (P-256, P-384, P-521, secp256k1, Brainpool)
or Ed448 signing key at boot: their private-key arithmetic runs on
crypto-bigint 0.5, whose constant-time operations can branch on secret
values (RUSTSEC-2026-0387), with no fixed release on that line. The boot error
names the advisory. Move to Ed25519 with the steps below; the old certificate
goes in retired_key_paths, where it keeps verifying, because verification
uses only public values.
An RSA signing key is accepted, but it makes every commit perform an RSA
private-key operation, the operation the Marvin timing sidechannel concerns
(RUSTSEC-2023-0071 / CVE-2023-49092), for which the underlying rsa crate has
no fixed release. An Ed25519 key keeps that code off the signing path entirely.
The server does not refuse an RSA key: a repository whose history is already RSA-signed needs that key to keep verifying, and signatures are immutable committed facts that cannot be re-issued. Instead it logs a warning at boot naming the advisory. To clear it:
- Generate a new signing key:
gpg --quick-generate-key "…" ed25519 sign, and export the armored secret key tokey_path. - Export the public half of the old certificate and add it to
retired_key_paths, so versions signed with it still verify (see above). - Restart. New versions are signed with Ed25519; old ones keep verifying.
Rotation inside one certificate is cheaper still: add an Ed25519 signing
subkey to the existing certificate, export the armored secret key again to
key_path, and restart. The server signs with the newest subkey whose binding
grants signing and that is neither expired nor revoked
(the rule), with no
retired_key_paths entry needed: the certificate keeps the previous subkey, so
past signatures continue to verify. For a refused ECDSA or Ed448 key this works
when the new Ed25519 subkey is the one chosen. The steps above, with a new
certificate, work in every case.
On Kubernetes, digest mode needs nothing; it is the default. pgp mode
needs the key as a file and its passphrase as a secret, both of which the chart
mounts for you:
# values.yaml
config:
signing:
enabled: true
mode: pgp
key_path: /etc/ferroehr/signing-key.asc
files:
signing-key.asc: |
-----BEGIN PGP PRIVATE KEY BLOCK-----
…
secrets:
signingKeyPassphrase: "…"
Every config.files key becomes /etc/ferroehr/<key>, mounted read-only from
a chart Secret at mode 0440, and that volume holds the private key, so it is
never world-readable inside the container. To go back to digest, set
mode: digest and drop the key material; versions already signed keep their
signatures and still verify.
Integrations
Query execution, the patient identity source and the three optional
integrations: [query], [ehr_index], [events], [terminology],
[multimedia]. Everything except the query knobs and the identity source is
off by default, and a disabled integration contacts nothing and mounts no
routes. Precedence, the environment-name grammar, and file discovery are on the
Configuration reference index.
[query]
AQL execution bounds.
[query]
plan_cache_capacity = 256
timeout_ms = 30000
max_result_rows = 10000
| Key | Type | Default | Description |
|---|---|---|---|
plan_cache_capacity | int | 256 | Maximum distinct cached query plans; 0 disables the cache and every lookup runs the full parse-and-lower path. Cache activity is reported by the aql_plan_cache_events counter. |
timeout_ms | int | 30000 | Per-query database execution budget; 0 disables it. Overrun is refused 408. |
max_result_rows | int | 10000 | The largest page one query execution serves: the page of a query nothing else bounds, and the maximum an explicit LIMIT or fetch may ask for (a larger page is refused 400); 0 means unbounded. |
timeout_ms is on by default, and deliberately tighter than
db.statement_timeout_ms. Keeping this budget the
tighter of the two means an overrun surfaces as the engine’s own typed 408
rather than a driver error. When it fires, or when the client disconnects or
the HTTP request timeout gives up first, the query’s connection is closed
instead of returned to the pool, so its pool slot frees at once and PostgreSQL
stops the statement within about a second (the session’s
client_connection_check_interval).
max_result_rows is the largest page one execution serves. Without it,
SELECT c FROM COMPOSITION c with no fetch generates SQL with no LIMIT and
materialises every matching row: one request, unbounded allocation. A query that
nothing else bounds takes the ceiling as its page. An explicit AQL LIMIT or a
fetch parameter is honoured as written up to the ceiling; a page larger than the
ceiling is refused with 400 naming the ceiling. The refusal is deliberate: a
silently shortened page would let a client that pages with its own fetch as the
stride skip the rows between the shortened page and its next offset, and the
RESULT_SET has nothing to say so. ITS-REST leaves both the fetch default and
its maximum to the implementation. A bulk consumer pages with offset and a
fetch at or below the ceiling, or the operator raises the ceiling deliberately.
[ehr_index]
Who holds the cross-reference from a patient to the EHR that records them (the
EHR index). No openEHR specification governs where it lives: the
RM’s PARTY_SELF section says the association “may be done elsewhere for
security reasons”, and FerroEHR’s design puts it in an IHE patient identity
source.
The IHE patient identity source is the default. FerroEHR asks a PIX
Manager, as a PIXm Patient Identifier Cross-reference Consumer
(IHE ITI TF-2 §3.83,
$ihe-pix), which identifier the clinical domain holds for a patient that
another domain’s identifier names. That identifier is the pseudonym
EHR_STATUS.subject carries. FerroEHR sends only what ITI-83 specifies, so any
conformant PIX Manager serves; FerroPIX is the one the compose quickstart and
the Helm chart run.
The in-instance map, provider = "local", is the other supported
posture, for production as much as for development, tests and the
conformance suites: the subject-id and subject-namespace model. A client names
a patient in EHR_STATUS.subject.external_ref by any namespace and identifier
(benchmark / 1793131381), as OBJECT_REF.namespace and a local
GENERIC_ID.scheme allow, and the map answers the openEHR REST API’s
GET /ehr?subject_id=…&subject_namespace=… lookup over EHR_STATUS content,
matching both exactly. An EHR created for a subject through the service’s
create_ehr_for_subject enters the map with its creation, as a Primary
association with no location, so a subject cohort finds it; an EHR created
over POST /ehr or PUT /ehr/{ehr_id} enters the map only through
add_ehr_subject. With neither provider configured, the server refuses to
start and the refusal names both choices.
What the local posture means for pseudonymisation: with no
[privacy] subject_namespaces declared, the server stores a subject reference
as the client sends it, a real identifier included. Pseudonymisation then
belongs to the client, or to the demographic service the reference points at
(GDPR Art. 4(5)). There is no mixed mode: a deployment that declares pseudonym
namespaces keeps the rule in force under local too, so every subject
reference must name a declared namespace and carry a UUID, and no other
namespace is accepted beside them. A production deployment runs the
subject-id posture by accepting the open_subject_namespace gap by name
(deployment_profile), which boot and
GET /management/status then state.
provider alone switches the index, in the file or as
FERROEHR__EHR_INDEX__PROVIDER. The keys of the provider not chosen are kept
and not used, and boot names them, so a configuration written for the IHE
source runs on the local map with FERROEHR__EHR_INDEX__PROVIDER=local and no
other change; its declared pseudonym namespaces stay in force. The compose
quickstart selects its local configuration with one variable
(The local EHR index).
| Key | Type | Default | Description |
|---|---|---|---|
provider | enum{ihe,local} | ihe | Which provider holds the cross-reference. |
pixm_url | secret URL | required for ihe | The PIX Manager’s FHIR base, [base] of [base]/Patient/$ihe-pix. Credentials embedded in it are masked in every rendering. |
assigning_authority | URI | required for ihe | The clinical domain, such as urn:oid:2.999.7.1: the targetSystem of every ITI-83 query and the namespace the answered pseudonym is stored under. It must be one of privacy.subject_namespaces. |
invocation | enum{post,get} | post | post sends a Parameters body, so a patient identifier never appears in a URL or an access log; get is the form ITI-83 prescribes, for a PIX Manager that accepts no POST. |
request_timeout_ms | int | 10000 | How long one ITI-83 exchange may take, connection included. |
[privacy]
subject_namespaces = ["urn:oid:2.999.7.1"]
[ehr_index]
provider = "ihe"
pixm_url = "https://pix.example.org/fhir"
assigning_authority = "urn:oid:2.999.7.1"
[ehr_index.oauth]
token_endpoint = "https://idp.example.org/realms/pix/protocol/openid-connect/token"
client_id = "ferroehr"
client_secret_file = "/run/secrets/pix-client"
scope = "system/Patient.rs"
[ehr_index.oauth] is the client credentials grant every ITI-83 request
carries, with the keys of the
terminology server’s grant; a
401 is answered once more under a new token. The client id is the one the
identity source mints pseudonyms in the clinical domain for, and the scope is
the one its PIX Manager takes from a Consumer. [ehr_index.tls] holds the
client’s TLS material: trust_roots_file, a PEM bundle trusted beside the
platform roots for a PIX Manager behind a private PKI, client_identity_file
(or client_identity inline), the chain and key presented for mutual TLS, and
min_version ("1.3", or "1.2").
What each operation asks:
| Operation | Under ihe | Under local |
|---|---|---|
has_ehr_for_subject, create_ehr_for_subject | the subject’s clinical pseudonym, asked of the source; a subject already in the clinical domain names itself | the subject as given |
| the EHRs of a subject | the one EHR whose EHR_STATUS names the subject’s pseudonym | the in-instance map |
| the subjects of an EHR | the pseudonym its EHR_STATUS carries; the source is never asked to resolve it back | the in-instance map |
writing the index (add, update, remove) | refused: the source holds the cross-reference | the in-instance map |
No fallback. At start the server reads the source’s
CapabilityStatement and refuses to start when it does not answer. A request
the source then fails, refuses or times out on answers with an error that
carries what the source said, and nothing is answered from the in-instance map.
An answered pseudonym the [privacy] subject rule refuses, or more than one
identifier in the clinical domain, is refused too.
FerroEHR does no identity work in either mode. It matches no patients,
merges none and links no identifier domains; the identity source does. It asks
only for the cross-reference ITI-83 answers. PDQm
(ITI-78) is not used: its
search answers with the demographic Patient, which FerroEHR would only
discard, so asking for it breaks data minimisation
(GDPR Art. 5(1)(c)), and its
$match is patient matching.
[events]
Contribution-outbox eventing to an AMQP broker, plus its admin API. Off by default; the envelopes are PHI-free by design.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Spawn the outbox publisher. It also gates the per-commit outbox INSERT, so with it off the commit path writes no outbox rows at all. |
url | secret URL | amqp://guest:guest@localhost:5672/%2f | AMQP broker URL; credentials are redacted from every rendering. |
url_file | path | unset | Read the broker URL from a file instead, for a mounted secret. At most one of the pair, where the built-in development default does not count as “set”. |
exchange | string | ferroehr.events | Topic exchange for the PHI-free envelope stream. |
tls | bool | false | Upgrade an amqp:// URL to amqps:// (an already-amqps:// URL is TLS regardless). Under deployment_profile = "production", an enabled outbox that still publishes over amqp:// opens the plaintext_broker gap. |
batch_size | int | 128 | Rows drained per poll. |
poll_interval_ms | int | 1000 | Idle poll interval. |
retention_days | int | 7 | Published-row retention window. |
prune_interval_secs | int | 3600 | Retention-prune cadence. |
publish_max_retries | int | 3 | Per-row publish retries before backing off. |
admin_api | bool | false | Mount the /admin/event_subscription CRUD routes. |
Note
Eventing needs the
eventsbuild feature, which is on in the published binary and container images. A binary built with--no-default-featuresrefuses at startup ifevents.enabledis set, rather than running with the publisher silently absent.
[terminology]
The terminology extension API and the one FHIR terminology server the CDR asks.
[terminology]: api_enabled (bool, false) mounts the terminology extension
API.
[terminology.server]:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Build the client. With it off, nothing is requested and validation stays on the in-process openEHR terminology bundle. |
url | secret URL | required when enabled | The server’s FHIR R4B base URL, for FerroTERM http://ferroterm:8080/r4b. Empty while enabled is a boot error. Credentials embedded in it are redacted from every rendering. |
fail_on_error | bool | false | What an unresolvable binding does to a commit: false accepts it (fail-open), true rejects it. |
request_timeout_ms | int | 10000 | How long one request may take, connection included. |
Every key is a scalar, so the whole section is reachable through the
environment: FERROEHR__TERMINOLOGY__SERVER__ENABLED,
FERROEHR__TERMINOLOGY__SERVER__URL,
FERROEHR__TERMINOLOGY__SERVER__FAIL_ON_ERROR,
FERROEHR__TERMINOLOGY__SERVER__REQUEST_TIMEOUT_MS.
[terminology.server]
enabled = true
url = "http://ferroterm:8080/r4b"
fail_on_error = false
The server answers every terminology the bundled openEHR terminology does not
carry: the /terminology/* extension API, AQL TERMINOLOGY(…) resolution, the
ADL 2 binding check and the composition-commit binding checks below. A response
that is not a valid FHIR R4B Parameters or ValueSet resource (for example an
$expand result missing the required ValueSet.status or
expansion.timestamp) is treated as an upstream fault rather than partially
read, so it takes the same path as an unreachable server and fail_on_error
decides what the commit does.
Note
The terminology server needs the
fhirbuild feature, which is on in the published binary and container images. A binary built with--no-default-featuresrefuses at startup ifterminology.serveris enabled; the in-process openEHR terminology bundle remains available.
Authenticating to the terminology server
A server behind an authorization server takes the OAuth2 client-credentials
grant under [terminology.server.oauth], with the same keys as
[audit.fhir_feed.oauth] and [authz.abac.remote.oauth]. The access token is
cached and re-requested shortly before it expires, and a request the server
answers with 401 is sent once more under a new token.
| Key | Type | Default | Description |
|---|---|---|---|
token_endpoint | URL | one of token_endpoint and issuer | The OAuth2 token endpoint. Credentials embedded in it are masked in every rendering. |
issuer | string | one of token_endpoint and issuer | The issuer whose metadata names the token endpoint (RFC 8414). |
client_id | string | required | The registered client identifier. Empty is a boot error. |
client_secret / client_secret_file | secret / path | one is required | The client secret, inline or read from a mounted file. |
client_auth | enum{client_secret_basic,client_secret_post,private_key_jwt,tls_client_auth,self_signed_tls_client_auth} | client_secret_basic | How the client authenticates at the token endpoint. |
scope | string | unset | The scope request parameter. |
audience | string | unset | The audience request parameter some authorization servers read. |
resource | URL | unset | The RFC 8707 resource request parameter. |
private_key_file | path | unset | The PEM private key that signs the client assertion under private_key_jwt (RFC 7523). |
kid | string | the RFC 7638 thumbprint | The kid the client assertion names. |
assertion_alg | string | by key type | The algorithm the client assertion is signed with. |
assertion_audience | enum{token_endpoint,issuer} | token_endpoint | The aud of the client assertion; issuer needs issuer set. |
client_certificate_file / client_key_file | path | unset | The client certificate chain and its key presented over mutual TLS (RFC 8705); set together. |
ca_file | path | unset | PEM trust roots added to the built-in ones, for a token endpoint behind a private certificate authority. |
refresh_margin_ms | int | 30000 | How long before the token expires a new one is fetched. |
timeout_ms | int | 10000 | How long one call to the authorization server may take. |
[terminology.server.oauth]
token_endpoint = "https://idp.example.org/realms/ts/protocol/openid-connect/token"
client_id = "ferroehr-cdr"
client_secret_file = "/run/secrets/ts-client"
scope = "system/*.read"
A grant beside credentials in the server URL’s userinfo is a boot error: the server would receive two authorizations.
Archetype value-set bindings at commit
With [terminology.server] enabled, committing a COMPOSITION also resolves
the archetype constraint bindings its template declares: where a template
binds an ac code to an external terminology query, the coded value in the
composition must be a member of the value set that query returns.
- The code is in the value set → the commit proceeds.
- The code is not in the value set →
422naming the path, the code, and the bound query. That is a real constraint violation, not a service failure, sofail_on_errordoes not change it. - The value set could not be resolved (server down, error response, unknown
value set) →
fail_on_errordecides:false(the default) accepts the commit and logs a warning;truerejects it with422.
With [terminology.server] disabled (the default) no binding is resolved
and no request is made, so commit behaviour is exactly as if this section did
not exist.
Note
The composition’s
terminology_idis sent verbatim as the FHIRsystemparameter, and no openEHR specification defines a mapping betweenterminology_idvalues (SNOMED-CT) and FHIR system URIs (http://snomed.info/sct). If your archetypes and your terminology server disagree, align them in the terminology-server configuration; the CDR does not rewrite the value.
[multimedia]
DV_MULTIMEDIA externalization to an S3-compatible object store. Off by
default: blobs stay inline, byte-identical, and no object store is built or
contacted.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Externalize large multimedia data. |
threshold_bytes | int | 262144 (256 KiB) | Decoded size strictly above which data is offloaded; at or below it stays inline. |
endpoint | secret URL | unset ⇒ default AWS endpoint resolution | S3-compatible endpoint. Must be an absolute http/https URL when set. Credentials embedded in it are redacted from every rendering. |
bucket | string | openehr-multimedia | Target bucket for content-addressed blobs. |
region | string | us-east-1 | AWS region; S3 requires one even for non-AWS endpoints. |
access_key_id | string | unset | S3 access key id. Unset, with no secret key either, runs the client anonymously. |
secret_access_key / secret_access_key_file | secret / path | unset | S3 secret access key. At most one of the pair. |
allow_http | bool | false | Allow plain-HTTP endpoints, development only; production S3 is HTTPS. |
An enabled integration whose endpoint is set but blank, not an absolute URL,
or carries a scheme other than http/https is a boot error. That case is
easy to reach by accident (an unset Compose variable expanding to nothing, or
an empty Helm value) and used to boot cleanly and then fail on the first
multimedia commit, so it is refused where an operator can still act on it.
Warning
Offloaded blobs are PHI. The bucket must be private and encrypted, and reached over HTTPS. Multimedia externalization also needs the
multimediabuild feature (on in the published binary and container images); a slim build refuses at startup rather than silently keeping blobs inline.
Audit
The IHE ATNA audit trail: [audit] and its three sinks. Precedence, the
environment-name grammar, and file discovery are on the
Configuration reference index.
[audit]
[audit]
The IHE ATNA audit trail (see the Audit trail chapter for what a record contains and how to search it). On by default with only the local store active: every deployment gets a queryable audit trail with nothing leaving the node, and forwarding to an external Audit Record Repository is opt-in per sink.
[audit]
enabled = true
source_id = "ferroehr"
value_if_missing = "UNKNOWN"
suppress_login_events = true
fail_mode = "open"
resolve_subject = true
queue_capacity = 8192
purpose_header = "x-purpose-of-use"
permit_header = "x-data-permit"
purpose_codes = []
emergency_purpose_codes = []
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master audit switch. |
enterprise_site_id | string | unset | The AuditEnterpriseSiteID field. |
source_id | string | ferroehr | The audit source id, also used for the destination participant. |
value_if_missing | string | UNKNOWN | Fill value for empty mandatory fields. |
suppress_login_events | bool | true | Skip successful-login records. Rejected accesses (401/403) are always recorded. |
fail_mode | enum{open,closed} | open | What an undeliverable audit record does. open logs, meters and lets the request succeed; closed rejects auditable operations with 503 (including when the local store has stopped accepting writes) so no PHI access goes un-audited. |
resolve_subject | bool | true | Enrich the patient participant with a background lookup of the EHR’s subject. The lookup runs on the background drain, never on the request path; the IHE BALP patient patterns and the patient-centric audit search need the subject. |
queue_capacity | int | 8192 | Bounded audit queue capacity. Sized for write-path bursts: the drain persists in multi-row batches, so the queue only needs to ride out sink latency spikes. |
server_host | string | unset ⇒ the value_if_missing fill | This node’s advertised network address, reported as the destination NetworkAccessPointID. |
purpose_header | string | x-purpose-of-use | The request header a caller declares its purpose of use in, recorded on every access record. NEN 7513 asks on whose authority a record was read and EHDS Art. 9 asks why; neither is derivable from the request, so the caller declares it. IHE carries the equivalent in a SAML attribute rather than a header, so the header is FerroEHR’s own. |
permit_header | string | x-data-permit | The request header a secondary-use caller declares the data permit it acts under in (EHDS Art. 68), recorded on every access record as permit. The value is the permit’s identifier, 1 to 128 characters of letters, digits and . _ : / # -; any other value is recorded as absent. No specification names a header for it, so the header is FerroEHR’s own. See Secondary use. |
purpose_codes | list of string | [] | The purpose codes this deployment accepts. Empty records whatever the caller declares. A non-empty list records a declared code only when it is on the list, so an unagreed string does not sit in the trail reading like an established purpose. |
emergency_purpose_codes | list of string | [] | The purpose codes that mark an access as an emergency access in the vital interest of the patient (EHDS Art. 11(5)). Each must also be on purpose_codes, or the server refuses to start. Empty marks nothing. The mark is recorded on the access record and shown to the patient through the access log; it lifts no restriction. |
legal_basis | string | unset | The legal basis this deployment processes under, recorded on every access record. A deployment-level fact: the controller establishes the GDPR Art. 6/9 condition once. Unset records nothing rather than a guess. |
Note
The local store renders a FHIR R4
AuditEventfrom each record when it is read, and the ATX:FHIR Feed sends one, so both need thefhirbuild feature, on in the published binary and container images. A binary built with--no-default-featuresrefuses at startup ifaudit.store.enabledoraudit.fhir_feed.enabledis set; the DICOM/syslog feed needs no FHIR and stays available.
Note
There is no
[atna]section. Configuration is strict, so a file or environment variable still setting an[atna]key fails at boot with an unknown-key error; move the setting under[audit].
[audit.store]: the local Audit Record Repository
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Persist every record in the audit schema, served through the ITI-81 GET /fhir/r4/AuditEvent search. |
retention_days | int | 0 | Days to keep records; 0 keeps them forever. Applied hourly by the retention reaper. A non-zero value below the retention floor of a jurisdiction the active [privacy.identifier_scan] rules name is a boot error naming both numbers; see Audit trail. |
retention_years | int | unset | Calendar years to keep records, in place of retention_days. Compared exactly against the floors and ceilings, which are written in years, and reaped with calendar arithmetic. Setting it together with a non-zero retention_days, or to 0, is a boot error. |
sgb_v_309_controller | bool | false | Set only if the deploying organisation is, or acts for, one of the controllers SGB V § 307 names for a German telematics-infrastructure application. It caps the horizon at the three-year period of SGB V § 309 Abs. 1, after which Abs. 3 requires deletion without delay, so a declared controller cannot keep records forever. |
verify_interval_seconds | int | 3600 | Seconds between two scheduled verifications of the store’s hash chain; 0 turns the schedule off. Each run verifies the records written since the newest link an earlier run found intact. A finding is logged at ERROR, counted in atna_audit_chain_findings and reported by the audit_chain indicator on GET /health/readiness. See Tamper evidence. |
full_verify_interval_seconds | int | 604800 | Seconds between two scheduled full passes over the hash chain, which also recompute the records before the checkpoint; 0 leaves the full pass to SELECT * FROM audit.verify_audit_chain() on demand. A run that found damage is always followed by a full pass. |
The local store is the durability anchor of the whole subsystem: with it on, the FHIR feed drains from it, so a down repository loses nothing.
The floors and ceilings are written in calendar years. In every EU Member State
the floor is three years (EHDS Art. 9(2)), and a longer national floor wins:
five years in the Netherlands. Switzerland keeps its one-year floor. A
retention_years value is compared against them exactly. A retention_days
value is held to the most days the years can span for a floor (1096 for three
years, 1830 for the Dutch five, 366 for one) and to the fewest for a ceiling
(1095 for the German three). A declared § 309 controller in Germany therefore
has no retention_days value that passes both the EHDS floor and the § 309
ceiling, and sets retention_years = 3:
[audit.store]
retention_years = 3
sgb_v_309_controller = true
[audit.categories]
The map every access record is classified through by EHDS category (Annex II
3.2(c); see the EHDS logging elements).
The classification is done by ehds-logging, the EHDS logging component the
FerroHEALTH products share (FerroAUDIT); this section is the deployment’s map.
A template id, or failing that a root archetype id, maps to a list of
categories, or to the word none for data of no category. An access the map
in force cannot classify is recorded unclassified with its ids, never
refused.
The shipped map. While the section declares no template and no archetype,
the map FerroEHR ships applies
(app/ferroehr/assets/audit-categories.default.toml).
It names the published CKM templates and COMPOSITION archetypes whose purpose
places their data in one priority category:
| Key | Category |
|---|---|
template International Patient Summary, archetype openEHR-EHR-COMPOSITION.health_summary.v1 | Patient-Summaries |
template ePrescription (FHIR), archetype openEHR-EHR-COMPOSITION.prescription.v0 | Electronic-Prescriptions |
template Generic lab test result example simple | Laboratory-Reports |
template CCTA report | Medical-Imaging |
archetype openEHR-EHR-COMPOSITION.transfer_summary.v1 | Discharge-Reports |
It leaves out the generic report.v1 and report-result.v1 containers, which
carry laboratory, imaging and other reports alike, so an access through them
is recorded unclassified instead of guessed. CKM publishes no composition for
an electronic dispensation. A deployment that declares any template or
archetype replaces the shipped map whole, so copy its entries into your own
map to keep them. A national category declared alone keeps the shipped map.
shipped_map = false with nothing declared leaves no map: every access is then
recorded unclassified, and deployment_profile = "production" refuses to
start on the no_category_map gap unless deployment_accepts names it.
[audit.categories]
national = ["urn:oid:2.16.840.1.113883.2.4.3.11.60.1|beeld"]
[audit.categories.templates]
"International Patient Summary" = ["Patient-Summaries"]
"Laboratory Report" = ["Laboratory-Reports"]
[audit.categories.archetypes]
"openEHR-EHR-COMPOSITION.report-result.v1" = ["Laboratory-Reports"]
"openEHR-EHR-COMPOSITION.encounter.v1" = "none"
| Key | Type | Default | Description |
|---|---|---|---|
shipped_map | bool | true | Apply the map FerroEHR ships while no template and no archetype is declared. |
national | list of <system>|<code> | empty | The national categories (EHDS Art. 14(1) third subparagraph) the map may name. |
templates | table of template id → list of category, or "none" | empty | Consulted first, by the template id the object was committed against. |
archetypes | table of archetype id → list of category, or "none" | empty | Consulted for an object whose template the map does not name, by its root archetype id. A key that is not an archetype id refuses boot. |
A priority category of EHDS Art. 14(1) is named by its code in HL7 Europe’s
EEHRxFDocumentPriorityCategoryCS: Patient-Summaries,
Electronic-Prescriptions, Electronic-Dispensations, Medical-Imaging,
Laboratory-Reports and Discharge-Reports, or as <system>|<code>. A
national category is named by the <system>|<code> it is declared with. Keys
compare case-insensitively. An unknown code, an empty list, a word other than
none, or two keys that differ only in case refuses boot with a message naming
the entry. A record stores each category as its <system>|<code> token.
The map’s SHA-256 digest is on the boot line, on GET /management/info under
audit.category_map, and on every access record. At boot the map is also
mirrored into the retention register, so a retention period can be keyed on a
category through the admin marks API.
[origins]: the origins a deployment recognises
Every version records where its data came from at commit: each FEEDER_AUDIT
originating-system id its body carries, and the access log reports them (EHDS
Annex II 3.2(e)). Annex II 3.4 also asks for retention periods and access
rights that take the origin into account. The ids a sending system writes are
free text, so this register declares the origins your deployment recognises,
which a retention period
and an ABAC rule may then
name. A top-level table, not part of [audit].
[[origins.registered]]
id = "lab.example"
clients = ["lab-gateway"]
| Key | Type | Default | Description |
|---|---|---|---|
registered | list of tables | empty | The recognised origins. |
registered[].id | string | (required) | The id the origin’s data carry: the FEEDER_AUDIT system_id its feed writes, compared exactly, and the origin a direct commit by one of its clients is stamped with. |
registered[].clients | list of string | empty | The clients whose direct commits come from this origin: a Basic user name, or an access token’s client_id, failing that its azp. |
A body that carries no FEEDER_AUDIT, committed by a listed client, is stamped
with that client’s origin; a direct commit by any other caller keeps this
server’s [rest] system_id, as before. A blank id or one with whitespace, an
id registered twice, a blank client, or a client listed under two origins
refuses boot.
[audit.syslog]: the classic DICOM/syslog feed (ITI-20)
The DICOM PS3.15 audit message, framed as RFC 5424 syslog and sent over RFC
5425 TLS 1.2 or later by ihe-atna (FerroAUDIT). TLS is the one transport: an
audit message names a patient (ITI TF-2 §3.20.4.1.2.1.1).
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Ship DICOM PS3.15 audit records to an external repository over syslog. |
host | string | localhost | Repository host. |
port | int | 6514 | Repository port, the RFC 5425 port by default. |
tls_ca_file | path | unset | PEM file with the repository CA to trust beside the platform’s roots. |
tls_identity_cert_file | path | unset | Client-certificate PEM for mutual TLS. |
tls_identity_key_file | path | unset | Client-key PEM for mutual TLS. |
spool_dir | path | unset | The directory of the spool every frame is stored in before it is sent. It must give no access to the group or to other users. Unset, the spool is held in memory, and a restart loses the frames not yet delivered. |
spool_max_messages | int | 100000 | The most frames the spool holds; a frame past it is refused and counted in atna_audit_dropped. |
spool_max_bytes | int | 268435456 | The most bytes the spool holds. |
retry_max_ms | int | 60000 | The longest wait between two delivery attempts after a failure. |
The drain stores each frame in ihe-atna’s spool, and the crate’s forwarder
delivers it: one connection held open, a failed send retried with an
exponential backoff and jitter, and the connection closed with a TLS
close_notify on shutdown, so nothing is lost to a reset (ITI TF-2
§3.20.4.1.1, RFC 5425 §4.4). The local audit store keeps only the FHIR
document, never the syslog frame, so the spool, not the store, is syslog’s
outbox. delivered_syslog_at is no longer written.
[audit.fhir_feed]: the RESTful-ATNA feed (ITI-20 ATX:FHIR Feed)
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | POST each FHIR AuditEvent to an external FHIR Audit Record Repository. |
url | secret URL | http://localhost:8080/fhir | The repository’s FHIR base; records go to {url}/AuditEvent. Credentials embedded in the URL are redacted from every rendering. |
poll_interval_ms | int | 2000 | How long the forwarder waits for a new record before it reads the store again. |
retry_max_ms | int | 60000 | The longest wait between two delivery attempts after a failure. |
ihe-atna’s forwarder delivers the feed. With the local store on, it
delivers from the store, oldest record first: a record not yet delivered has
no delivered_fhir_feed_at, so a repository outage loses nothing, and the
record is stamped once the repository accepts it. A record the repository
refuses (a 4xx other than 401, 408 or 429) is set aside until the next
start. It is reported on GET /management/health as audit_delivery, and
the records behind it keep flowing. With the store off, the feed delivers
from an in-memory spool. batch_size and max_retries are gone and stop
startup by name: the forwarder retries until the repository answers.
[audit.fhir_feed.oauth]: authenticating with OAuth 2.0 client credentials
The feed authenticates with HTTP Basic through the credentials in url, or,
with this table, as an OAuth 2.0 client (the IHE IUA authorized-client model):
FerroEHR obtains a token with the client credentials grant, sends it as a
bearer token, renews it before it expires, and when the repository refuses the
held token with 401 fetches a new one and sends the record once more. Set
one of the two, never both: a url with credentials beside this table stops
startup.
| Key | Type | Default | Description |
|---|---|---|---|
token_endpoint | URL | unset | The token endpoint. Set this or issuer. |
issuer | URL | unset | The issuer whose metadata (/.well-known/oauth-authorization-server) names the token endpoint. |
client_id | string | unset | The client identifier. Required. |
client_secret / client_secret_file | secret | unset | The client secret, inline or read from a file. One is required. |
client_auth | enum{client_secret_basic,client_secret_post,private_key_jwt,tls_client_auth,self_signed_tls_client_auth} | client_secret_basic | How the client authenticates to the token endpoint. |
scope | string | unset | The scope of the token request. |
audience | string | unset | The audience parameter, for an authorization server that reads it. |
resource | URL | unset | The resource indicator (RFC 8707) naming the repository. |
private_key_file | path | unset | The PEM private key that signs the client assertion under private_key_jwt (RFC 7523). |
kid | string | the RFC 7638 thumbprint | The kid the client assertion names. |
assertion_alg | string | by key type | The algorithm the client assertion is signed with. |
assertion_audience | enum{token_endpoint,issuer} | token_endpoint | The aud of the client assertion; issuer needs issuer set. |
client_certificate_file / client_key_file | path | unset | The client certificate chain and its key presented over mutual TLS (RFC 8705); set together. |
ca_file | path | unset | PEM trust roots added to the built-in ones, for a token endpoint behind a private certificate authority. |
refresh_margin_ms | int | 30000 | How long before the token expires a new one is fetched. |
timeout_ms | int | 10000 | How long one call to the authorization server may take. |
With the local store on, the feed delivers from the store and loses nothing across a repository outage. With the store off it delivers from an in-memory spool, so a restart loses what the spool holds.
Privacy & data minimisation
[privacy] is what the clinical side refuses to hold. Precedence, the
environment-name grammar, and file discovery are on the
Configuration reference index.
- What this section is for
[privacy][privacy.identifier_scan][cohort]- Interaction with the audit trail
- Protecting national identifiers in the demographic domain
What this section is for
FerroEHR keeps clinical content, demographic parties and the map between them in three separate database schemas reached by separate roles. That separation is only worth something if the clinical side does not carry the subject’s identity anyway, in the subject reference, in a party proxy, or in free text. This section is the three rules that keep it out.
No openEHR specification governs any of them. The Reference Model leaves
EHR_STATUS.subject.external_ref open and only advises against identifying
content on a party proxy (PARTY_IDENTIFIED: “Should not be used to include
patient identifying information”). GDPR
Art. 4(5) and Art. 25(2) are
what make it a duty; this section is where a deployment states its side of it.
All three rules run on the write path, before anything is stored. A refusal is
422 Unprocessable Entity with one errors[] entry per finding (sent when
the request carries Prefer: return=representation),
each naming the RM path and the rule that matched. A refusal never echoes the
offending value — it would otherwise travel into the response body, the
access log and the traces, which is the leak the rule exists to prevent.
[privacy]
subject_namespaces = []
allow_identified_parties_in_ehr = false
[privacy.identifier_scan]
mode = "strict"
rules = ["ch-ahvn13", "ch-epd-pid", "de-kvnr", "fi-hetu", "gb-nhs-number", "nl-bsn", "no-fodselsnummer", "se-personnummer"]
patterns = []
[privacy]
| Key | Type | Default | Description |
|---|---|---|---|
subject_namespaces | list of strings | [] | The pseudonymisation domains this deployment issues subject pseudonyms in. |
allow_identified_parties_in_ehr | bool | false | Accept a PARTY_RELATED whose relationship is self carrying name or identifiers in clinical content. Off already accepts every other party proxy; see the matrix below. |
The subject reference
EHR_STATUS.subject is a PARTY_SELF whose optional external_ref points at
a demographic service. Once subject_namespaces names at least one namespace,
that reference must name one of them and carry a UUID as its id.value:
[privacy]
subject_namespaces = ["urn:ferroehr:pseudonym"]
FERROEHR__PRIVACY__SUBJECT_NAMESPACES=urn:ferroehr:pseudonym,mpi.example
What a deployment gets by default, and what declaring a namespace adds:
subject_namespaces empty (default) | declared | |
|---|---|---|
EHR_STATUS.subject.external_ref written by a client | accepted with any namespace and any identifier, as the openEHR REST API admits | must name a declared namespace and carry a UUID; anything else is a 422 |
| The database | no shape held | a trigger on ehr refuses a non-UUID subject reference whichever session writes it |
| Minting | FerroEHR mints none | FerroEHR mints none: the IHE patient identity source does |
FerroEHR mints no pseudonym. Under the IHE patient identity source
([ehr_index]), the source answers the
pseudonym the clinical domain holds for a patient, and the identity source
mints it on the first ask; that domain’s assigning authority is a declared
namespace. Every value that arrives from elsewhere, a client writing
EHR_STATUS directly, an EHR-Extract, an archive load, meets the same rule,
which is why declaring the namespace is the deployment’s act.
Empty is the default, and it leaves the rule out of force. A pseudonym
namespace is a deployment fact — which service mints the tokens — and there is
no name a server could invent for an operator. Declaring one is the same act as
turning the rule on. An EHR with no subject reference at all is unaffected
either way: that is the default EHR_STATUS the openEHR REST API describes,
and it carries nothing to minimise.
Both spellings of a UUID’s case are accepted; the braced, URN and unhyphenated forms are not, because the promoted subject column is compared as text and four spellings of one pseudonym would be four subjects.
The rule has a second line of defence in the database. On every boot the
server stamps whether namespaces are declared, and a trigger on the ehr
table then refuses a subject reference that is not a UUID whichever code path
or session writes it (ehr_subject_pseudonym_guard). Declaring namespaces
over an existing store does not rewrite stored rows: the boot log names how
many EHRs carry a subject reference that is not a pseudonym, and those need
re-pseudonymising.
Identified parties
A party proxy inside clinical content — the composer, participations, the health care facility and the feeder-audit party slots — is governed by which class it is:
name | identifiers | |
|---|---|---|
PARTY_IDENTIFIED | accepted | accepted |
PARTY_RELATED, relationship a third party (mother, guardian, donor, …) | accepted | accepted |
PARTY_RELATED, relationship self | refused | refused |
The two classes mean different things, so one rule for both was the wrong
shape. PARTY_IDENTIFIED is the Reference Model’s own provider proxy: “Proxy
data for an identified party other than the subject of the record”,
“Typically for health care providers, e.g. name and provider number of an
institution”. A composer name or a performing clinician’s name is that, not
patient identity, and refusing it invented a prohibition the specification
does not contain. ctx/composer_name in the simplified formats produces
exactly this shape, and so does the example composition this server generates
for a template.
identifiers follows the same line. The class is “Used to describe parties
where only identifiers may be known … e.g. name and provider number of an
institution”, so a clinician’s registration number on the composer is the
class’s own paradigm case, and the simplified formats build exactly that from
ctx/participation_identifiers. What the separation between the two database
schemas exists to prevent is the subject’s national identifier on the
clinical side, and that is caught in two places: a self party’s
identifiers slot is refused by this rule, and a national-identifier value
is refused by the identifier scanner below wherever it sits, DV_IDENTIFIER.id
included.
PARTY_RELATED is the “Proxy type for identifying a party and its
relationship to the subject of the record”, and that relationship “is coded
as self” where the party is the patient. A name on a self party is the
subject’s own identity and is refused. A name on any other relationship, the
mother who consented, the guardian, the donor, is a third party the Reference
Model models on purpose, and the openEHR REST API obliges a server to accept a
composition that carries it, so it is accepted. A relationship the server
cannot read counts as self: the rule refuses what it cannot prove harmless,
and the Reference Model validator names the missing attribute on its own.
A refused proxy stays expressible: PARTY_IDENTIFIED’s own validity rule is
satisfied by external_ref alone, so it points into the demographic domain
instead of restating the identity.
The commit’s own AUDIT_DETAILS.committer is not clinical content and is never
touched by this rule.
A deployment that needs a name or formal identifiers on a self party sets:
[privacy]
allow_identified_parties_in_ehr = true
which is announced at boot with a warning naming what it permits.
[privacy.identifier_scan]
Every string leaf of every clinical write is checked against the active
identifier rules: the EHR, EHR_STATUS, COMPOSITION and directory writes,
the CONTRIBUTION path, EHR-Extract import and the admin archive load. The two
replay paths store a record verbatim, so for them a finding refuses the whole
import or load rather than rewriting the content; demographic parties are the
domain that holds identity and are not scanned.
| Key | Type | Default | Description |
|---|---|---|---|
mode | strict or warn | strict | strict refuses the write; warn accepts it and records a warning naming the RM path and the rule. |
rules | list of rule keys | every rule the build ships | The national personal-identifier rules to scan for. An unknown key is a boot error listing the shipped keys. |
patterns | list of regular expressions | [] | Extra patterns for the kinds no build can ship a rule for. |
The shipped rules
Each rule transcribes the checksum its own issuing register publishes. A kind whose current algorithm could not be established from its own register is not shipped, because a rule that guesses is worse than an absent one: it tells an operator their data was scanned.
| Key | Jurisdiction | Identifier | Published by |
|---|---|---|---|
ch-ahvn13 | CH | AHV-Nummer (AHVN13) | AHVV Art. 133 for the structure; the check digit from the BSV Wegleitung VA/IK 318.106.02 d, Anhang 7; matches the plain run and the 756.3047.5009.62 display form |
ch-epd-pid | CH | Patientenidentifikationsnummer (EPD-PID) | EPDV-EDI Anhang 1, vendored at docs/law/ch/epdv-edi/: the fixed prefix 76133761 and a mod-10 check digit |
de-kvnr | DE | Krankenversichertennummer (the unchangeable part) | GKV-Spitzenverband, Richtlinie nach § 290 SGB V, 3.4.1, with Anlage 1; the letter-to-digit convention (A=01 … Z=26) is stated in prose only in the same Richtlinie’s Anlage 2 and fixed for A and C by the KVNR’s own worked examples |
fi-hetu | FI | henkilötunnus | Valtioneuvoston asetus väestötietojärjestelmästä 128/2010, 2 §: the date of birth, the century sign, the individual number and the mod-31 check character |
gb-nhs-number | GB | NHS Number | NHS Data Model and Dictionary |
nl-bsn | NL | burgerservicenummer | Rijksdienst voor Identiteitsgegevens, Logisch Ontwerp BSN |
no-fodselsnummer | NO | fødselsnummer | Skatteetaten |
se-personnummer | SE | personnummer | Skatteverket |
Two are deliberately absent. The Danish CPR-nummer has been issued without its modulus-11 control since 2007, and those numbers are fully valid, so a checksum rule would pass most recent ones through while reporting Denmark as covered. The Belgian rijksregisternummer’s modulo-97 check is widely reproduced but no definition published by the Rijksregister itself could be retrieved.
Why every rule is active by default
Clinical data crosses borders: a Dutch hospital receives referrals carrying a Norwegian fødselsnummer. An operator who has not configured the scanner is exactly the operator who has not yet worked out which identifiers their content carries, so the default covers all of them.
The two costs are not symmetric. A false positive is a loud 422 naming the RM
path and the rule, which an operator answers by narrowing rules or moving to
warn. A false negative is a national identifier stored on the clinical side
indefinitely.
A deployment that knows its jurisdictions narrows the list:
[privacy.identifier_scan]
rules = ["no-fodselsnummer"]
FERROEHR__PRIVACY__IDENTIFIER_SCAN__RULES=no-fodselsnummer
The boot log states which rules are active, so a jurisdiction that is not covered is visible rather than assumed.
False positives, and what narrows them
Every checksum accepts some fraction of random digit runs, and clinical content is full of numbers. Three things narrow it:
- A digit run must be delimited — bounded by something other than a letter, a digit or an underscore. Without that, every hex digest and long numeric identifier produces hits at the checksum’s own rate. It also means a UUID can never match any rule: its groups are 8, 4, 4, 4 and 12 characters, so no delimited run of 9, 10 or 11 digits occurs in one, and the opaque subject pseudonym the boundary mandates passes unconditionally.
CODE_PHRASE.code_stringis skipped. A terminology code system’s identifiers are digit runs by construction, so this one slot is where the collision is systematic rather than incidental. Measured over this repository’s vendored corpora: of the 30 distinct nine-digit values that satisfy the Dutch elfproef, 27 are SNOMED CT concept identifiers, all of them in a code slot. The carve-out names an RM slot, not a country.- Each rule narrows itself with whatever structure it has. Two independent
control digits (
no-fodselsnummer, 1 in 120), an embedded date (se-personnummer, 1 in 139 with the Luhn check), a non-numeric token shape (fi-hetu, 1 in 31 among tokens of that shape).nl-bsnat 1 in 11 is the loosest shipped rule, which is why the code-slot carve-out matters most to it. Every figure here is measured, over 100 000 random tokens of each rule’s own shape, by a test that fails if a rule drifts from the rate it publishes.
A deployment measuring its own content runs warn first, reads the recorded
findings, then moves to strict.
Local patterns
Medical-record numbers, payer references and postcode forms have no single issuing register to transcribe, so a deployment declares them itself as Rust regular expressions:
[privacy.identifier_scan]
patterns = [
"\\bMRN-[0-9]{6}\\b",
"(^|[^0-9A-Za-z])[1-9][0-9]{3} ?[A-Z]{2}[^0-9A-Za-z]{1,3}[0-9]{1,4}([^0-9A-Za-z]|$)",
]
The second refuses a Dutch postcode paired with a house number — the pair is what identifies a household; a postcode alone does not. A pattern that does not compile is a boot error naming it.
[cohort]
Cross-domain cohort queries: select a population in the demographic domain, resolve it to EHRs through the linkage domain, and run AQL over exactly those EHRs. A FerroEHR extension — no openEHR spec governs it. The wire contract and a worked example are in Querying with AQL.
Off until a predicate is bound. Which demographic leaf may be selected on is
a deployment fact about the archetypes in use, never something the server can
infer, so [cohort.predicates] is empty by default and POST /query/cohort
answers 404 until it is not. subjects = true turns the surface on without
a predicate, for cohorts that name their subjects by identifier. That is the one
form an IHE patient identity source serves ([ehr_index] provider = "ihe"):
it holds no party-to-EHR map, so a predicate is refused there.
[cohort]
small_cell_threshold = 5
max_cohort_size = 100000
[cohort.predicates]
city = { archetype = "openEHR-DEMOGRAPHIC-ADDRESS.address.v1", node = "at0012", kind = "text" }
| Key | Type | Default | Description |
|---|---|---|---|
small_cell_threshold | int | 5 | The distinct-EHR floor a result set must reach to be served. Below it the rows are withheld and the response is marked suppressed; 0 disables suppression. |
max_cohort_size | int | 100000 | The largest cohort a predicate may select. A wider one is refused 422 rather than truncated: a silently shortened cohort is a wrong denominator. |
predicates | table | empty | The allow-list, keyed by the name a caller uses. Empty, with subjects off, leaves the surface off. |
subjects | bool | false | Let a request name its subjects by identifier, in any identifier domain, each resolved through the [ehr_index] provider: under the IHE provider by a forward PIXm translation into the server’s clinical domain, never back. |
The allow-list
Exactly five keys are bindable — city, postcode_area, sex, age_band and
organisation — and an unknown key is a boot error. Each binding carries
three fields:
| Field | Description |
|---|---|
archetype | The archetype HRID of the leaf ELEMENT’s nearest archetyped ancestor, e.g. openEHR-DEMOGRAPHIC-PERSON.person.v1. Must be a demographic HRID; anything else is a boot error. |
node | The ELEMENT’s archetype_node_id at-code, e.g. at0012. |
kind | How a caller’s value is matched: text (exact, against value/value), text_prefix (prefix, LIKE-escaped), coded (exact, against value/defining_code/code_string), or birth_date (an inclusive age band in whole years, 40-49). |
age_band must be bound birth_date, and no other key may be — both halves are
boot errors, because a mismatch would bind a predicate that matches nothing
while reporting an empty cohort.
The archetype must be one the node model reaches. A party is decomposed into
its own rows for the party root, for each PARTY_IDENTITY, CONTACT, ADDRESS
and CAPABILITY nested in it, and for every archetyped ITEM_TREE or CLUSTER
under any of their details. An ADDRESS under a CONTACT is therefore
bindable like anything else.
Interaction with the audit trail
The ATNA Patient-Number participant is filled from the promoted subject column, so constraining the subject reference to an opaque pseudonym is also what keeps the audit trail and its forwarding sinks free of the identity. The contribution outbox carries no subject at all. Both are covered by a CI test that fails when any subject identifier other than the opaque UUID appears in an outbox payload, an ATNA message or a trace record.
Protecting national identifiers in the demographic domain
The rules above keep national identifiers off the clinical side. The
demographic side is where a party’s identifiers legitimately live, and
[demographic.identifier_protection] decides how they are held there.
With it on, an identifier of a configured scheme never sits in the versioned
body. The value moves to party.national_identifier, sealed with
AES-256-GCM under a key derived per domain, and the body keeps a reference in
its place. Beside the ciphertext sits an HMAC-SHA-256 digest of the value, which
is what makes “which party holds this identifier” answerable without decrypting
anything — and, because it is keyed, what stops the database, a backup or a read
replica from reversing a nine-digit space.
[demographic.identifier_protection]
enabled = false
schemes = ["nl-bsn"]
#? key_file = "/run/secrets/ferroehr-identifier-key"
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Whether protection is in force. Off leaves identifiers stored as written. |
schemes | list of string | ["nl-bsn"] | The DV_IDENTIFIER.type values to protect. Each must exist in the party.identifier_scheme registry; with protection on, the server refuses to boot when one does not. |
key | secret | unset | The root key, 64 hex characters. Prefer key_file. |
key_file | path | unset | A file holding the root key, read at boot. |
Turning it on with no key, with an empty schemes list, or with a scheme the
registry does not hold, is a boot error.
A server that believes it seals national identifiers and does not is worse than
one that never claimed to.
Four properties worth knowing before you enable it:
- The stored, signed and served body are the same form. Sealing runs before the body is decomposed and signed, exactly like the multimedia offload, so a signature still verifies against the bytes the server holds. What a reader receives carries the reference; the value is available through the resolution path below. A create or update response serves the stored form as well, so the value you submitted does not come back in it.
- Each party version keeps its own sealed rows. A later version retires the rows of the one before it without rewriting them, so an older version still expands to the value it committed. Only live rows take part in resolution.
- Resolution is audited. Going from an identifier to a party is recorded as
a
linkage-domain access naming the scheme and whether it matched — never the value. A miss is recorded too: it says someone asked whether this deployment holds that identifier. - Reading a value back is audited and needs its own role.
GET /ferroehr/rest/extensions/v1/admin/party/identifiersserves a party version with its values put back, for a caller holding both the admin role andauthz.rbac.identifier_reader_role, and records each scheme it expands (reading a party’s protected identifiers). - The key is load-bearing and rotation is a re-encryption. The runbook is in Operations.
Only the demographic writer role reaches the sealed value. The read-only twin sees that a party holds a protected identifier and which party it is, and is refused the ciphertext and the digest by column-level grant; the clinical roles are refused the table outright.
CLI & production checklist
The binary’s command-line surface, what a zero-configuration boot actually gives you, the minimum a production deployment sets, and which material belongs in a mounted file rather than an environment variable. Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.
- The command line
- Zero-config boot and the production checklist
- What belongs in a mounted file (versus the environment)
- Variables outside the server’s namespace
The command line
Two flags are global: they apply to the server and to every subcommand:
| Flag | Description |
|---|---|
--config <path> | The configuration file to load, overriding FERROEHR_CONFIG. Fatal if missing or unreadable. |
--set <key>=<value> | A dotted-path override, repeatable, highest precedence of all layers (e.g. --set db.max_connections=40). |
With no subcommand, or with serve, the binary boots the server. ferroehr --version prints
the version and the manufacturer of the release:
ferroehr <version>
Manufactured by Cadasto B.V., Comeniusstraat 2d, 1817 MS Alkmaar, The Netherlands, info@cadasto.com
https://www.cadasto.com/contact/
ferroehr config …
ferroehr config check [--config P] # validate file + environment + --set
The fully-commented template every key’s default comes from, the starting
point for a real ferroehr.toml, is
app/ferroehr/assets/ferroehr.default.toml.
config check runs the same validation the server runs at boot (the strict
unknown-key sweep, the type pass, the aggregated semantic rules, and the
“authentication enabled needs a mechanism” rule) and touches no database,
so it is safe in CI and before a rollout. It exits 0 when the configuration is
valid and 1 otherwise; on success it prints the effective configuration as TOML
with every secret redacted, and notes on stderr when db.url is still the
built-in development default.
ferroehr db …
ferroehr db migrate # apply the embedded migrations and exit
ferroehr db verify # verify, issuing no DDL, that the schema matches this build
These exist so a least-privilege deployment can separate the two database
identities: run db migrate once under a DSN that holds DDL rights (a
Kubernetes Job, an init container, a CI/CD stage), then boot the server with
db.migrate = "verify" under a DSN with no DDL rights at
all. verify still reads all five _sqlx_migrations bookkeeping tables, which
no least-privilege role can do, so point [db] migrate_url at the credential
that prepares the schema. See
Operations.
ferroehr healthcheck
Asks the running server’s GET /health/readiness and exits 0 on 200, 1
otherwise, with the reason on stderr: the container HEALTHCHECK and the
Kubernetes exec-probe fallback. A draining server answers 503, so it reads
unhealthy. The subcommand loads the same configuration as the server (file,
environment, --set) and asks the server.listen port under
server.base_path, on the loopback when the server listens on every
interface. With [server.tls] set it speaks TLS and accepts exactly the leaf in
server.tls.certificate_file, presenting server.tls.healthcheck_identity_file
to a listener that requires a client certificate. It waits three seconds for
the answer.
ferroehr usage-report --print
ferroehr usage-report --print # the start report
ferroehr usage-report --print --event daily # the daily report, with its metrics
Prints the exact JSON body of the usage report this
instance would send now, on one line to stdout, and sends nothing. It loads the
same configuration as the server and connects to the database to read the
instance id and the shared metrics window, writing nothing. Notes go to stderr:
a sample id when the database holds none yet, and a line saying so when the
report is off. --print is required; printing is the only action.
ferroehr report
ferroehr report # writes ferroehr-report-<UTC time>.json here
ferroehr report --output /tmp/report.json # writes to the path you name
ferroehr report --output - # writes the JSON to stdout
Writes one JSON document naming what this deployment runs. Attach it to a complaint, a non-conformity finding or a serious-incident report: it is the “which FerroEHR does this deployment run” answer those start from. It loads the same configuration as the server and must pass the same validation. It reads the databases and writes nothing to them.
| Part | What it holds |
|---|---|
manifest | every part below with its status (included, partial or unavailable) and, for the last two, the reason |
build | version, commit, build date, rustc, the active spec_profile and the specification versions it selects, the PostgreSQL target, the audit posture, and the manufacturer |
image | named in the manifest only: a running binary cannot read the digest of the image it was started from, so attach docker inspect --format '{{index .RepoDigests 0}}' <image> or the pod’s status.containerStatuses[].imageID |
target | the CPU architecture and operating system the binary was built for, and whether it is a debug build |
features | the optional integrations compiled in (events, fhir, multimedia) |
openehr_crates | each openehr-* crate the binary links, with its version |
licence | the licence summary GET /management/status serves (state, use, licensee, last valid day, what the configured token contributed); never the token |
deployment | the deployment profile, the open gaps and the accepted ones, measured over the databases, or from the configuration alone when they do not answer |
schema | for each database, the highest migration version applied and the highest this build embeds, per schema |
configuration | the effective configuration as config check prints it |
Nothing in it identifies a person: it reads no clinical, demographic or audit
table. Every secret in the configuration shows ***, every URL shows *** in
place of its user name and password, and a database error quoted as a reason
has each configured database user name and password masked too. A database
that does not answer within 15 seconds leaves the schema part out and names
it unavailable with unreachable: … as the reason.
Zero-config boot and the production checklist
With no file and no environment, the effective configuration is: listener
0.0.0.0:8080 at the ITS-REST base path with Swagger UI, reading bodies up to
64 MiB; the database at the
built-in development DSN with migrations applied at boot; RBAC on; signing on in
digest mode with read-time verification strict; the audit trail on with only
the local store; no caller rate limit; logs in auto format at info; and every
integration off. The one outbound connection on by default is the
usage report, which usage_report.enabled = false
switches off.
One thing that configuration does not do is serve requests.
auth.enabled defaults to true, and authentication enabled with no
mechanism configured is a boot error rather than a running server that refuses
everything: RFC 9110 §11.6.1 requires a 401 challenge to name a scheme
applicable to the resource, and a server with no mechanism has none: it could
only refuse every request while advertising a scheme it does not implement. The
error names the three ways out: add [[auth.basic.users]], add an [auth]
issuer, or set auth.enabled = false for development. So a bare docker run of
the image with no configuration stops at startup with that message, while the
downloadable Compose quickstart boots because it ships a user.
For production, set at least:
db.url: the real DSN, viaFERROEHR__DB__URLfrom a secret or aurl_file-mounted value, never inline in a world-readable file. Leaving the development default in place is warned about loudly at every boot.db.migrate = "verify"with the schema applied out of band, so the serving role needs no DDL rights, plusdb.migrate_urlnaming the credential that reads every schema’s migration state at boot. The serving credential cannot.deployment_profile = "production"once the deployment holds real personal data. The server then refuses to start while a separation is open and not accepted by name, instead of running as a sandbox that looks identical.- an authentication mechanism: a Basic user store and/or an
[auth]issuer. telemetry.format = "json"for cluster log collectors.rest.cors_permissivestaysfalse;rest.swagger_uiper posture.rest.system_id: this deployment’s own openEHR system identifier. Choose it before the first EHR is created: it is stored with every EHR, audit entry and version identifier, and changing it later never rewrites what is already committed.management.*per posture. A dedicatedmanagement.portis recommended so the introspection surface is never reachable on the clinical listener, and every endpoint staysoffuntil you name a level for it.- TLS everywhere a transport supports it:
server.tls(or a TLS-terminating ingress),events.tls, HTTPS for the object store; the audit syslog feed always speaks TLS. - real secrets via the environment or a
*_filesibling, never inline.
Before a deployment holding real patient data goes live, work through the go-live checklist: it turns each item above into something you run and read back, and adds the privacy and audit checks a controller is asked for.
What belongs in a mounted file (versus the environment)
The environment cannot carry an array of tables, so the Basic-auth user
store ([[auth.basic.users]]) is file-only.
Genuinely file-shaped material (the PGP signing key, Cedar policies,
ATNA and terminology-server PEMs, a JWKS blob) is referenced by an
in-TOML *_path / *_file key pointing at a mounted path. On Kubernetes the
chart’s config.files map materialises each entry under /etc/ferroehr/,
read-only, from a Secret.
Prefer a *_file sibling over the environment form for any secret in a
container: an environment value is readable through /proc/<pid>/environ and is
inherited by every child process the container spawns.
Everything else is a plain key you can set in the file or override with a
FERROEHR_* variable.
For a worked development example (the server section, CORS, admin, management
and the Basic-auth user store) read the configuration carried inline in the
quickstart docker-compose.yml; see Docker Compose.
Variables outside the server’s namespace
The PostgreSQL init container’s variables are PG_INIT_USER, PG_INIT_PASSWORD
and PG_INIT_DB; they configure the database container, not the server, and
sit outside the server’s reserved FERROEHR_ namespace.
Inside that namespace, a handful of names are deliberately not configuration
keys and pass the strict sweep untouched: FERROEHR_CONFIG (the config-file
pointer), the build-stamp variables, and the Compose parameterization (image tags, host ports,
CPU and memory limits). They keep a single _ by design, which is exactly what
distinguishes them from configuration keys, and why a single-underscore
misspelling of a real key is reported at boot with the uniform spelling it
should have had.
Concepts
This part explains the ideas you need to use FerroEHR effectively. Four chapters; the first two read in either order, the last two go deeper:
- openEHR primer: the standard itself: the Reference Model, archetypes and templates, compositions, versioning, and AQL, with no prior openEHR knowledge assumed. Read this first if the words “archetype” and “composition” are new to you.
- System architecture: how this server is put together and where your data actually lives, so the behaviour you see through the API makes sense: why the specification layer is generated, how storage and versioning work on PostgreSQL, and how an AQL query becomes SQL.
- Storage architecture: the deep-dive on the tables: the temporal version table, the decomposed node model with its nested-set index, the one-transaction write path, and the cold archival tier, each with a diagram.
- The AQL engine: the query pipeline from lexer to
RESULT_SET, what each stage refuses, and the design reasons queries are fast.
None of them is a prerequisite for Getting started; you can commit a composition and run a query without them. Come back when you want to know why the API behaves as it does, or before you make one of the decisions you then live with: a specification generation, a template design, a query shape.
openEHR primer
openEHR is an open standard for storing health records in a way that outlives any single application. Its central idea is to keep what clinical data means separate from the software that stores it. This chapter introduces the pieces you meet when using FerroEHR (the Reference Model, archetypes and templates, compositions, versioning, and AQL) in plain terms. It is enough to follow the rest of this book; the openEHR specifications are the full reference.
- The Reference Model: a fixed vocabulary of shapes
- Archetypes and templates: the meaning layer
- Compositions: the unit of clinical data
- Versioning: nothing is ever overwritten
- AQL: querying by meaning, not by table
- How it fits together
The Reference Model: a fixed vocabulary of shapes
At the bottom is the Reference Model (RM), a fixed, general set of building
blocks that never changes per project. It defines generic structures such as a
COMPOSITION (a clinical document), a SECTION (a heading), an OBSERVATION,
EVALUATION, INSTRUCTION and ACTION (the kinds of clinical statement), and
the data types that carry actual values: DV_QUANTITY (a measured amount with
a unit), DV_CODED_TEXT (a term from a terminology), DV_DATE_TIME,
DV_TEXT, and so on.
The RM is deliberately generic: it knows about “a quantity with a unit” but not about “systolic blood pressure in mmHg”. That specificity comes from the layer above.
FerroEHR carries two Reference Model generations, and one configuration key,
spec_profile, chooses which one a deployment runs: RM 1.2.0 (the
development default) or RM 1.1.0 (stable, the latest released
generation). openEHR’s minor releases are additive, so data written against
1.1.0 reads unchanged under 1.2.0; the reverse is not guaranteed, which is why
the choice is worth making before you commit clinical data. See
spec_profile.
Archetypes and templates: the meaning layer
An archetype is a reusable, computable definition of one clinical concept (“blood pressure”, “body weight”, “medication order”) expressed as constraints over the Reference Model. It says which fields exist, how many times each may occur, what units and value ranges are allowed, and which terminology codes are valid. Archetypes are authored once by clinicians and modellers (often drawn from the international Clinical Knowledge Manager) and shared across systems.
A template assembles and further constrains a set of archetypes for a specific use: a particular form, message, or dataset. It picks the archetypes you need, narrows their optionality (mandatory here, hidden there), and pins down defaults. The template is what a CDR is actually loaded with.
FerroEHR ingests templates as Operational Template (OPT) 1.4 XML, and ADL 2 artefacts as ADL 2 source. Once a template is uploaded, the server derives what it needs to validate incoming data and to describe the data’s shape to client applications; see Templates & validation.
Note
The order is always: agree on archetypes → build a template from them → upload the template to the CDR → commit data that conforms to it. You do not define a database schema; the template is the schema, and it lives in the clinical model, not the code.
Compositions: the unit of clinical data
A composition is the openEHR unit of committed clinical content: one document, conforming to one template, stored inside one patient’s record. A blood-pressure reading, an encounter note, a lab result set: each is a composition. Compositions are grouped and organised inside an EHR, the container that represents a single subject of care.
Every EHR also has an EHR_STATUS (metadata about the record, including whether it is queryable and modifiable, and the link to the subject) and, optionally, a directory, a folder tree for organising compositions.
Versioning: nothing is ever overwritten
openEHR records are versioned and indelible. When you update a composition, the previous version is retained and a new version is created alongside it. You can read any composition as of a point in time, list its full history, and never silently lose clinical data. Deletion is logical: the object is marked deleted but its history remains readable.
Every change is wrapped in a contribution, an atomic change-set that also records an audit entry (who, when, why). A single contribution can commit several compositions together, and either all of them land or none do.
FerroEHR serves both the latest version and all versions of an object, and AQL can query across version history; see Querying with AQL.
AQL: querying by meaning, not by table
The Archetype Query Language (AQL 1.1) is how you get data back out. Instead
of SQL over hidden tables, you query against the clinical model using archetype
and template paths. A query names the RM types and archetypes it wants, uses
CONTAINS to express structural nesting, and selects values by their path
within the archetype:
SELECT
o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic
FROM EHR e
CONTAINS COMPOSITION c
CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140
The same query runs unchanged against any conformant openEHR system holding that archetype. That is the portability payoff. The Querying with AQL chapter is a full walkthrough, including which constructs this engine supports and how it refuses the ones it does not.
How it fits together
flowchart TB
rm["Reference Model<br/>(generic building blocks)"]
arch["Archetypes<br/>(one clinical concept each)"]
tmpl["Template (OPT 1.4 or ADL 2)<br/>(archetypes assembled for a use)"]
comp["Compositions<br/>(committed clinical documents)"]
ehr["EHR<br/>(one subject's record)"]
aql["AQL<br/>(query by clinical path)"]
rm --> arch --> tmpl
tmpl -->|validates| comp
comp --> ehr
aql -->|reads| ehr
With these concepts in hand, the System architecture chapter shows how FerroEHR realises them.
System architecture
This chapter explains how FerroEHR is built and where your data lives, in practical terms. You do not need any of it to use the API, but it clarifies why the server behaves the way it does: why the conformance claims are checkable, why versioning is exact, and why AQL does not degenerate into a document scan. Two ideas run through everything: the openEHR specification layer is generated from the official machine-readable models, and the storage is designed natively for PostgreSQL 18.
- Two layers
- Two specification generations, one selectable set
- The native service API
- Three pools, three roles, one request path
- Storage: the node model on PostgreSQL 18
- Versioning: one table, one interval per version
- The AQL engine
- What this means for you
Two layers
flowchart TB
specs["openEHR machine-readable specifications<br/>(Reference Model · XML schemas · OpenAPI — vendored & pinned)"]
subgraph gen ["Specification layer (generated, never hand-edited)"]
types["Reference Model types (two generations) · canonical JSON & XML<br/>ITS-REST contract (Release-1.1.0) · AQL 1.1 parser · Simplified Formats"]
end
subgraph app ["Application layer (the server)"]
rest["REST adapter (axum)<br/>authentication · authorization · wire mapping"]
sm["Service layer<br/>(our own design)"]
core["Platform: PG18 storage · versioning ·<br/>AQL→SQL engine · validation · signing"]
ext["Optional integrations<br/>(FHIR audit and terminology · events · multimedia — compiled in by cargo feature)"]
end
db[("PostgreSQL 18")]
specs -->|deterministic codegen, drift-checked in CI| gen
rest --> sm
core -->|implements| sm
core --> ext
app --> gen
core --> db
The specification layer is generated. openEHR publishes its Reference Model, serialization schemas, and REST contract as machine-readable models. FerroEHR generates its Rust types, canonical JSON/XML (de)serialization, the REST API contract, and the AQL front end directly from those models. The consequence for you: the server’s data shapes and wire contract cannot silently drift from the standard. A continuous-integration check regenerates everything and fails the build on any divergence. A specification update is a regeneration. That layer is also published for reuse, as standalone Rust crates.
The application layer is the server. It holds everything the generated
layer does not: storage, the query execution engine, validation, and security.
This is where design choices specific to FerroEHR live. The optional
integrations (the FHIR audit rendering and terminology client, change events,
S3 multimedia) sit beside it in their own crate behind additive cargo features,
so a build without them contains none of their code; see
Beyond the core. FHIR R4 resource mapping is not
part of FerroEHR: it is FerroBRIDGE,
a separate server that reaches FerroEHR over ITS-REST. The Helm chart deploys it
in the same release with bridge.enabled=true; see
Kubernetes & Helm.
What you actually deploy is small: one self-contained server binary plus
PostgreSQL. No JVM, no language runtime, and a pure-Rust TLS stack. The
published container image is distroless and non-root, with no shell and no
package manager. (It is not a static binary: the server links the system C
library dynamically, which is why the image is the cc distroless variant. See
Operations.) The
viewer is a separate, optional binary and image
that talks to the server strictly over the public REST API.
Two specification generations, one selectable set
The Reference Model is not pinned to a single version. The generated layer emits
two generations side by side (the latest released one and the development
one), and a single configuration key, spec_profile, picks which set the server
runs: development (Reference Model 1.2.0 with BASE 1.3.0, the default) or
stable (Reference Model 1.1.0 with BASE 1.2.0). Because openEHR’s minor
releases are additive supersets, everything valid under stable is valid under
development; the reverse is not guaranteed, so the profile also acts as an
acceptance boundary in both directions:
- Surface the selected generation does not define is refused: an AQL
FROMclass or a path attribute RM 1.1.0 does not declare is rejected at planning time, with the active profile named in the error. - Released surface the development line later dropped stays accepted under
stable: the request is read by that generation’s own reader at ingress, and the one attribute the newer generation removed is validated and then dropped (the server recomputes it) rather than stored.
Stored content is never silently rewritten to fit another generation. See
spec_profile for the direction
contract and how to change it on an existing deployment.
The native service API
Internally the server is organised around the openEHR Platform Service Model, a standard catalogue of service components (EHR, Composition, Directory, Contribution, Query, Definition, Terminology, Admin, Messaging, System Log, and more), with one module per component and its methods following that component’s own operations. The REST layer is a thin protocol adapter over that native API. Practically, this means the HTTP behaviour you observe maps onto the standard’s own service definitions, and the same core can be driven by adapters other than REST.
Three pools, three roles, one request path
The server holds three connection pools, one per pseudonymisation domain, and which pool a service module uses is fixed by what that module stores. The clinical pool serves the openEHR record, the demographic pool serves parties and their identifiers, and the linkage pool serves the map that says which party is the subject of which EHR.
flowchart LR
rest["REST adapter"]
subgraph clin ["clinical modules"]
ehrsvc["service::ehr · query · admin<br/>definition · message · validity"]
end
subgraph dem ["identity module"]
demsvc["service::demographic<br/>(parties, sealed identifiers)"]
end
subgraph lnk ["linkage module"]
lnksvc["service::linkage"]
end
audit["system_log store"]
pc[("clinical pool<br/>ferroehr_clinical → clinical")]
pd[("party pool<br/>ferroehr_party → party")]
pl[("linkage pool<br/>ferroehr_linkage → linkage")]
rest --> ehrsvc
rest --> demsvc
rest --> lnksvc
ehrsvc --> pc
audit --> pc
demsvc --> pd
lnksvc --> pl
lnksvc -. "resolve_ehr_for_identity, hop 1" .-> demsvc
resolve_ehr_for_identity is the one call that needs two domains. It asks the
demographic pool for the party holding a sealed identifier, by keyed digest
and without decrypting anything, then asks the linkage pool for that party’s
EHR. The two hops are two connections in the application. No statement
performs the join, and under separated credentials no role could issue one.
Each pool takes its own DSN from [storage.<domain>] url, defaulting to
[db] url. Leave every domain unset and all four pools
authenticate as the first, which keeps the schema separation and drops the
credential separation. Preparing the schema spans every schema at once, so it
runs on [db] migrate_url rather than on any of them. The local Audit Record
Repository is written on the clinical pool, into its own audit schema. See
Operations for the
roles and the threat model
for what each credential can reach.
Storage: the node model on PostgreSQL 18
A clinical composition is a deep tree. Storing each as one large JSON blob makes queries slow: extracting a single value forces the database to read and decompress the whole document every time. FerroEHR instead decomposes each versioned object into one row per structural node, in a single unified table:
- Each node carries an integer interval index so that AQL’s
CONTAINS(structural nesting) becomes a fast integer-range join rather than a tree-walk. - Hot query predicates (RM type, archetype, name, path, and the owning EHR) are promoted to indexed columns. The archetype identifier is stored split into its parts as well, which is what lets a query naming a parent archetype match data created with a specialisation of it.
- The node’s own content is stored as canonical openEHR JSON, verbatim (compressed, with the structural children pruned into their own rows). There is no proprietary encoding and no translation step: what the storage holds is exactly what the API serves, which makes both querying and debugging straightforward.
Versioning: one table, one interval per version
Versioning uses a single version table rather than separate “current” and “history” tables. Each version is a row carrying the validity interval during which it was the version of record; the current one is the row whose interval is still open. Branches are modelled explicitly, so an imported or branched version coexists in time with the trunk without ambiguity.
Non-overlap is a property of how writes are performed, not a constraint the database re-checks per row: a partial unique index admits at most one open row per lineage, and every write closes the outgoing row and inserts its successor in the same transaction at the same instant, so the intervals meet exactly. PostgreSQL’s exclusion constraints would enforce the same property directly, but they serialize concurrent inserts on the write path, so this design does not use them.
Because history is just rows in the same table, FerroEHR serves both
LATEST_VERSION and ALL_VERSIONS (the record as it is now, or across its
entire history) from one place. Time-ordered UUIDv7 keys keep inserts
index-friendly, and every write emits a contribution and an audit row in the
same transaction, so the change-control trail is never out of step with the
data.
There is also a cold tier: an administrator can archive EHRs and demographic parties into a separate schema in the same database, shrinking the tables that serve everyday traffic. Archived records stay readable by id and come back automatically on write, but they leave the AQL-visible store until then. The trade is spelled out in Admin & messaging APIs.
Note
openEHR does not define a database schema; it defines semantics (versioning, indelibility, canonical data fidelity). FerroEHR is free to choose the storage design that best serves those semantics on PostgreSQL, and its versioning behaviour is verified against the specification, not against any particular table layout.
The AQL engine
An AQL query is parsed, then its paths are typed against the generated Reference
Model (which types an attribute may hold, whether it is multi-valued, which
concrete types a slot can contain). From that typed form it is lowered to a
single SQL statement: CONTAINS chains become interval joins on the node table,
leaf values are extracted with PostgreSQL’s JSON path functions, and ordered
comparisons on quantities and date/times go through small immutable helper
functions that implement openEHR’s own magnitude and temporal semantics, which
also makes them usable in indexes. The result is assembled into the standard
RESULT_SET shape.
Anything the engine cannot lower is a typed refusal naming the construct, never a silently different answer. See Querying with AQL for the language and its supported feature envelope.
What this means for you
- Checkable conformance. The wire contract and data types are generated from the standard and drift-checked, and the conformance catalogue is executed against a live server with its records committed to the repository, so the conformance claims are machine-derived. See Conformance.
- Exact versioning. Nothing is overwritten; every version and its audit are retained and readable.
- Operational simplicity. One self-contained binary and a PostgreSQL 18 database; see Installation.
Storage architecture
How FerroEHR physically stores clinical data. This page goes one level below
the system architecture: the tables, the write path, the
read paths, and the reasons the layout looks the way it does. If you read the
source, the schema itself is the authority: every column in
app/ferroehr/migrations/ carries a COMMENT ON line with its citation.
One thing up front: openEHR defines no SQL schema. What the specs do
define, and what this storage realizes, are the versioning and change-control
semantics (RM common change control), canonical data fidelity (ITS-JSON),
and the contribution and audit duties. The relational layout below is
FerroEHR’s own PostgreSQL-18-native design, and the RM explicitly sanctions
that freedom: “Although the figure implies physical containment of Versions
by a Versioned object, this is only one possible implementation. Other
implementations (e.g. using orthodox relational structures) might use
references, separate compressed copies, or any other mechanism.”
- The big picture
- Core tables and how they relate
- Versioning: an append-only table and one mutable head row
- Content decomposition: the
nodetable - The write path: one transaction per commit
- Read paths
- The archival tier is a partition
- Why this design
The big picture
Every versioned object (COMPOSITION, EHR_STATUS, EHR_ACCESS, FOLDER, the demographic party kinds) is stored twice over, deliberately, in one transaction:
versionholds the version row: identity, version tree position, commit instant, lifecycle state, and the canonical JSON body bytes served verbatim on point reads. It is written once and never updated.nodeholds the same content decomposed: one row per RM structure node, carrying a nested-set index and promoted predicate columns, so AQL never walks JSON to answer CONTAINS.
flowchart LR
client[REST client] --> rest["ITS-REST adapter"]
rest --> svc["service layer<br/>(validation, versioning)"]
svc --> tx{{"one transaction<br/>per commit"}}
tx --> audit[(commit_audit)]
tx --> contrib[(contribution)]
tx --> vov[(version)]
tx --> head[(vo_head)]
tx --> node[(node)]
vov -. "point read: body bytes verbatim" .-> rest
node -. "AQL: interval joins + promoted columns" .-> rest
The database is PostgreSQL 18, split into five schemas:
| Schema | Holds |
|---|---|
ext | FerroEHR’s own IMMUTABLE helper functions (openehr_magnitude, openehr_timestamp), the runtime roles and the deployment posture |
clinical | the clinical CDR: versions, heads, nodes, EHRs, contributions, templates, queries, tags |
party | the demographic pseudonymisation domain: party versions, heads, nodes, contributions, commit audits, and the sealed national_identifier values |
linkage | the linkage pseudonymisation domain: subject_ehr, which party and which subject identifier name which EHR |
audit | the IHE ATNA Audit Record Repository (audit_event) |
Each carries its own migration set and its own _sqlx_migrations bookkeeping
table, applied in that order. The archival tier is not a sixth schema: it is a
partition of the relations it archives, described below.
The instance is single-tenant. No relation carries a tenant column and no
row policy scopes a read: several organisations are served by several
instances. openEHR puts multi-tenancy at the layer that hosts several logical
EHR systems rather than inside one (BASE architecture_overview
master06-design_of_the_ehr.adoc §The EHR System).
The three pseudonymisation domains
Parties (PERSON, ORGANISATION, GROUP, AGENT, ROLE, PARTY_RELATIONSHIP) live in
party, never in clinical, and the split is enforced by the database in both
directions: each domain’s version relation carries a CHECK admitting only
its own kinds. The clinical record and the
identity of its subject are therefore never in the same schema, the same
archival tier, or the reach of the same runtime role — GDPR Art. 4(5) and
Art. 32(1)(a), https://eur-lex.europa.eu/eli/reg/2016/679/oj. Which role reads
which domain, and how a deployment turns the schema split into a credential
split, is Operations → Database roles.
The mechanism inside the server is deliberately small: the two domains’ change
control and node relations are rendered from one DDL template, so they carry
the same names, the same column shape, the same indexes and the same foreign
keys and cannot drift; and the pool serving each domain sets its own
search_path. One set of storage code — the nested-set node codec, the
versioning engine, the AQL path machinery — therefore serves both domains
unchanged, and no SQL in the server names a domain schema. A test renders both
files and refuses any difference beyond the kind CHECK and the foreign keys
into the ehr relation, which the party domain has none of.
The third domain is one table. linkage.subject_ehr is the EHR id / subject
cross-reference (the EHR index), and
it records which party and which subject identifier name which EHR,
temporally: a merge, a split or an index correction closes the row in force and
opens its successor, and the temporal key
(UNIQUE (party_id, sys_period WITHOUT OVERLAPS)) admits one
open mapping per party. It carries identifiers, their association metadata,
and a validity period, and nothing else, because a row
here is already the additional information that re-attributes a record to a
person. It holds no foreign key into either schema it joins, since PostgreSQL
enforces a foreign key by reading the referenced row and no credential here
may. ferroehr_linkage holds SELECT, INSERT and UPDATE on it and no
DELETE, so a mapping is closed rather than removed — with one exception, and
it is the one the law requires: linkage.erase_ehr is a SECURITY DEFINER
function the role may execute, and deleting an EHR calls it so no row survives
naming a record that no longer exists.
The wire is unaffected. The ITS-REST Demographic API, the RM change-control semantics and every version identifier are exactly what they were; only where the rows physically sit has changed. No openEHR spec governs storage layout — this is FerroEHR’s own design.
Core tables and how they relate
erDiagram
ehr ||--o{ contribution : "owns (NULL for parties)"
contribution ||--|| commit_audit : "its own audit"
contribution ||--o{ version : "change set members"
commit_audit ||--o{ version : "commit_audit"
vo_head ||--o{ version : "the object's current heads"
version ||--o{ node : "decomposed content (per version)"
version ||--o{ vo_attestation : "appended attestations"
template_ref ||--o{ version : "template identity (FK)"
template_store ||--|| template_ref : "registers"
ehr ||--o{ ehr_folder : "folder hierarchies (rank order)"
ehr ||--o{ item_tag : "ITEM_TAGs"
version {
text tier PK "hot | cold, the partition key"
uuid vo_id PK
int sys_version PK "opaque commit ordinal"
text kind "COMPOSITION | EHR_STATUS | ..."
uuid ehr_id FK "NULL for parties"
int trunk_version "VERSION_TREE_ID part 1"
int branch_number "0 = trunk"
int branch_version "0 = trunk"
timestamptz committed_at "validity is derived from it"
text lifecycle_state "532/553/523/800/801"
text creating_system_id "OBJECT_VERSION_ID middle segment"
text preceding_version_uid
text signature "VERSION.signature, 0..1"
jsonb wrapped_original "IMPORTED_VERSION discriminator"
text body "canonical JSON bytes, lz4"
}
vo_head {
uuid vo_id PK
int head_sys_version "latest_version, any lineage"
int trunk_head_sys_version "LATEST_VERSION"
text lifecycle_state "of the trunk head"
text tier "hot | cold"
timestamptz archived_at
timestamptz restricted_at
timestamptz retention_hold_at
}
node {
text tier PK "hot | cold, the partition key"
uuid vo_id PK
int sys_version PK
int num PK "pre-order number, root = 0"
int num_cap "subtree = num..=num_cap"
int parent_num
text rm_type
text archetype "case-folded"
text name_code "promoted name/defining_code"
text path "materialized, COLLATE C"
jsonb data "what no column holds, NULL when nothing remains"
timestamptz context_start "promoted, COMPOSITION root only"
text value_type "typed ELEMENT value"
numeric value_num
text value_text
text value_code
text value_terminology
}
Supporting tables not drawn above: stored_query (stored AQL, qualified name
plus SemVer), archetype_store and adl2_artefact (the two DEFINITION
dialects), and the restriction and retention registers. The ehr table
itself carries the
three creation-immutable values the RM names (system_id, id,
time_created) plus promoted copies of the current EHR_STATUS subject
reference and is_queryable / is_modifiable flags, which back the
one-EHR-per-subject rule, the AQL full-population gate, and the content-write
guard without probing a JSON root per request.
Versioning: an append-only table and one mutable head row
Most CDRs split storage into a “current” table and a “_history” table. FerroEHR does not, and it does not carry a validity interval either.
versionis written once. A version row and its node rows are never updated after commit, which is the property BASEarchitecture_overviewmaster07-security.adoc§Integrity states. A supersession is therefore one insert: no close-out statement, no dead tuple, no index churn on a column that changed.- Validity is derived from
committed_at. The RM already copies the contribution audit into every version (RMcommonchange control, §Committal and Audits), so the commit instant is a column of the version row rather than a join, and version i is valid over[committed_at_i, committed_at_i+1). Time travel is “the trunk row with the greatestcommitted_atat or before the instant”, one descending index probe. vo_headis the one mutable row per object, and the only row a commit updates.trunk_head_sys_versionISLATEST_VERSION(the RM’slatest_trunk_version);head_sys_versionis itslatest_versionacross every lineage. It also carries the lifecycle state, the template, the tier, the archive marker and the legal marks, so “what is current” is one primary-key probe. None of the columns a commit changes appears in an index, which is the condition PostgreSQL 18 §“Heap-Only Tuples (HOT)” states for a heap-only update.ALL_VERSIONSis the unfiltered table;LATEST_VERSIONis the head row’s answer.- The spec-facing version identity is the three-part
OBJECT_VERSION_ID{object_id, creating_system_id, version_tree_id}, stored asvo_id+creating_system_id+ thetrunk_version/branch_number/branch_versiontriple and held unique together.sys_versionis deliberately not that number: it is an opaque per-object commit ordinal (1..n across trunk and branch commits) used as the join key fornodeandvo_attestation. - Generated ids use PostgreSQL 18’s native
uuidv7(), so keys are time-ordered and index-friendly. - A logical delete writes a content-less version with lifecycle state
523; nothing is physically deleted. - An import (EHR-Extract, archive load) stores the wrapped
ORIGINAL_VERSION’s own provenance verbatim inwrapped_original, while the row’s own contribution and audit columns record the local act of committal.NULLthere means a locally createdORIGINAL_VERSION;NOT NULLmeans the row is anIMPORTED_VERSION.
One valid version per lineage at any instant now holds by construction rather than by any constraint: a version is superseded exactly when a later commit ordinal exists under the same branch number, and a fork onto a branch carries a branch number of its own, so it does not supersede the trunk.
flowchart TD
subgraph one_object ["one versioned object (vo_id)"]
v1["sys_version 1<br/>1.0.0 (trunk)<br/>committed_at t1"]
v2["sys_version 2<br/>2.0.0 (trunk)<br/>committed_at t2"]
v3["sys_version 4<br/>3.0.0 (trunk)<br/>committed_at t3"]
b1["sys_version 3<br/>2.1.1 (branch tip)<br/>committed_at t2b"]
head["vo_head<br/>trunk_head_sys_version 4<br/>head_sys_version 4"]
v1 --> v2 --> v3
v2 -.->|branch 1| b1
head -.->|LATEST_VERSION| v3
end
Content decomposition: the node table
At commit, the accepted composition is decomposed into one row per RM
structure node. Each row holds the node’s canonical openEHR JSON fragment
(the ITS-JSON encoding) with its structure children pruned out: no alias
compaction, no synthetic fields. A row stores only the part its own columns do
not hold. _type is the rm_type column, a lowercase archetype_node_id is
archetype, a plain or simply coded name is name with its code columns,
and an ELEMENT value in one of the common basic shapes (a quantity, a count,
an ordinal, a coded or plain text, a date, time or duration, an identifier, a
boolean, a URI) sits in typed value columns. Everything else stays in
node.data verbatim, and data is empty on two rows in three. The database
functions ext.node_fragment, ext.node_name and ext.node_value put the
omitted keys back, so every reader, AQL included, sees exactly the fragment
the API would serve. A filter or projection on a quantity’s magnitude or a
code reads the typed column directly.
A party body decomposes the same way. The party root, each PARTY_IDENTITY,
CONTACT, ADDRESS and CAPABILITY nested in it, and the ITEM_STRUCTURE
under each get their own row, so a predicate over a contact address reaches it
by the same interval join a clinical predicate uses.
The tree shape is captured as a nested-set interval: nodes are numbered
in pre-order (num, root = 0), and each row records the maximum number in
its subtree (num_cap). “B is contained in A” is then the integer test
A.num < B.num AND B.num <= A.num_cap, which makes AQL CONTAINS chains
plain integer range joins instead of JSON tree walks.
flowchart TD
c["COMPOSITION<br/>num 0, cap 5"] --> s["SECTION<br/>num 1, cap 5"]
s --> o1["OBSERVATION<br/>num 2, cap 3"]
o1 --> e1["ELEMENT<br/>num 3, cap 3"]
s --> o2["EVALUATION<br/>num 4, cap 5"]
o2 --> e2["ELEMENT<br/>num 5, cap 5"]
For the tree above, “OBSERVATIONs inside the SECTION” is
section.num (1) < obs.num AND obs.num <= section.num_cap (5): rows 2 and 4
qualify by arithmetic alone.
Beside the interval, each row promotes the predicates AQL actually filters on, so hot paths never open the JSON:
rm_type(full RM type names, never compacted),name,archetype(case-folded at write, because openEHR identifier equality is case-insensitive);- the archetype-subsumption columns
arch_entity/arch_concept/arch_major, parsed from full archetype HRIDs so a query naming a parent archetype matches specialised children through an indexed prefix scan (the major-version boundary stays hard, as the AM requires); citem_num, the nearest archetyped ancestor, for archetype-anchored path resolution;context_start, the promotedEVENT_CONTEXT.start_timeon COMPOSITION roots, serving ordering from two partial indexes: one per EHR for a dashboard, one over the whole population for a query that orders every composition and takes the first page;path, the materialized path from the root (COLLATE "C", so byte order equals tree order), used only for reassembly, never as an AQL predicate.
The write path: one transaction per commit
Every write realizes the openEHR contribution rule: a CONTRIBUTION lists
the affected VERSIONs and carries its own audit, and it commits only if
every member commits. In storage terms, one transaction per service-level
write:
sequenceDiagram
participant R as REST adapter
participant S as service layer
participant PG as PostgreSQL 18
R->>S: commit (COMPOSITION, EHR_STATUS, ...)
S->>S: validate (RM invariants, WebTemplate, terminology)
S->>PG: BEGIN
S->>PG: advisory lock on vo_id (serializes the lineage)
S->>PG: INSERT commit_audit (change_type, committer, time_committed = now())
S->>PG: INSERT contribution (commit_audit_id, ehr_id)
S->>PG: INSERT version (new tip, committed_at = now(), body bytes)
S->>PG: UPSERT vo_head (the object's new heads) — heap-only
S->>PG: INSERT node rows (decomposed fragments, nested-set numbers)
S->>PG: COMMIT
S-->>R: OBJECT_VERSION_ID of the new version
Details that matter:
time_committedis always server-computed, never client-supplied; the RM requires the committal time to reflect the EHR server’s own clock.- There is no close-out statement: the head row advancing past the previous version is what supersedes it, and that update is heap-only because none of the columns it changes is indexed.
- The whole chain above is ONE statement — a data-modifying CTE — so the audit, the contribution, the version row, the head row and every node row commit or roll back together and cost one round trip.
- The body bytes in
version.bodyare materialized from the accepted, uid-stamped value before decomposition, stored astext(notjsonb, which would re-order keys) so a point read serves the canonical_type-first field order verbatim.
Read paths
Point reads (GET composition, EHR_STATUS, a named version) resolve the
version row and serve version.body verbatim: one detoast, no
re-aggregation, zero translation between storage and wire.
AQL plans over node, with one exception: a whole-object projection
loads the matching body rows in a batch instead of reassembling fragments.
CONTAINS chains become nested-set interval joins, class and archetype
predicates hit the promoted columns and their indexes, a COMPOSITION’s
archetype_details/template_id/value compares the indexed
version.template_id, a typed ELEMENT value’s magnitude, units, text or code
reads its typed column, other leaf values are
extracted from the rebuilt fragments with jsonb_path_query_first, arrays
are unnested with jsonb_path_query as a lateral set-returning function, and
comparison and ordering go through ext.openehr_magnitude (the IMMUTABLE
helper realizing DV_ORDERED ordering semantics) and ext.openehr_timestamp
(STABLE, because its result depends on the session time zone). The engine
uses no jsonpath item methods, no JSON_TABLE and no GIN index. The whole
pipeline has its own page.
Time travel (a version at a point in time) is the trunk row with the
greatest committed_at at or before the instant, served by a descending index
on the same one table.
The archival tier is a partition
version, node and vo_attestation are each PARTITION BY LIST (tier) with
a hot and a cold partition and no default partition. Archiving an EHR is one
statement — UPDATE version SET tier = 'cold' WHERE ehr_id = $1 — and
PostgreSQL moves the rows between partitions; the node and vo_attestation
foreign keys carry their rows across with ON UPDATE CASCADE. Restore is the
reverse statement. Archiving never merges the two pseudonymisation domains: each
has its own partitions.
The consequences are deliberate and visible:
- foreign keys hold across the tier, which a separate mirror table could never do, so an archived version still references its contribution and its commit audit;
- every read path reaches cold by naming the parent relation, so an archived object stays retrievable in one statement with no union view, no primary-miss retry and nothing to rebuild when a column is added;
- AQL stays hot-only: the emitter writes
tier = 'hot'as a literal, which PostgreSQL prunes at plan time, so archived content leaves the queryable store until it is restored; - a write to an archived object thaws it back to the hot tier first, so a versioned object is never split across tiers;
- the cold partitions carry the primary key alone, because nothing queries them, and can sit on a cheaper tablespace.
flowchart LR
subgraph version ["version — PARTITION BY LIST (tier)"]
vh[(hot)]
vc[(cold)]
end
subgraph node ["node — PARTITION BY LIST (tier)"]
nh[(hot)]
nc[(cold)]
end
vh -- "UPDATE version SET tier = 'cold'" --> vc
vc -- "the reverse statement" --> vh
nh -. "carried across by ON UPDATE CASCADE" .-> nc
aql["AQL: tier = 'hot' — pruned at plan time"] --> vh
aql --> nh
point["point read: the parent relation, either tier"] --> version
No openEHR spec governs archival tiers; this is FerroEHR’s own design.
Why this design
The shape follows documented PostgreSQL behaviour rather than habit:
- JSONB has no documented partial detoast: a big single-document design pays
whole-document decompression for every leaf access. Decomposed fragments
are small enough to stay under the TOAST threshold, so an AQL leaf access
touches only the rows it needs. A point read takes the other route on
purpose: it serves the whole
bodyin one detoast. - GIN indexes serve neither ranges nor ordering, so CONTAINS and ORDER BY ride integers and promoted btree columns instead.
- An append-only version table replaces current/history pairs, with
ALL_VERSIONSa plain scan of one relation. The reason it is append-only is PostgreSQL’s own rule for a heap-only update: it applies when “the update does not modify any columns referenced by the table’s indexes” (PostgreSQL 18, “Heap-Only Tuples (HOT)”). A validity interval cannot satisfy that, because the currency predicates index it; a separate head row whose updated columns are in no index can, and does. - Archival is a partition rather than a mirror table for the same kind of
reason: an
UPDATEthat changes a partition key moves the row, so the tier becomes a property of a row in one relation instead of a second relation with no foreign keys and a union view over it.
Four comments in the baseline migration cite measurements (a POC-window p99, the share of node rows carrying at-code archetype text, the average fragment size, an index-order profile) whose records live on closed tracker issues rather than in a committed artifact. They are historical notes on the decisions, not claims this page makes; a number reaches this site only through a generated include over a committed record.
The performance this buys is measured, not asserted: see Performance for the earned deployment classes and the committed measurement records behind them.
The AQL engine
How FerroEHR turns an AQL query into SQL, and why the result is fast. This page is about the engine’s internals; for writing queries against the API, see Querying with AQL.
The language is openEHR AQL 1.1. openEHR defines the language, not its execution, so everything after the parse is FerroEHR’s own design.
The pipeline
One query passes through six stages. Each stage has a single job, a typed output, and a typed refusal: a construct the engine does not support is rejected with an error naming the QUERY specification section it comes from, never answered approximately.
flowchart LR
q["AQL text"] --> lex["lexer<br/>(logos)"]
lex --> parse["parser<br/>(chumsky)"]
parse --> ast["AST"]
ast --> an["path analysis + typing<br/>(generated RM model)"]
an --> ir["typed query IR<br/>(cached per query text)"]
ir --> sql["SQL builder<br/>(sea-query)"]
sql --> pg[("PostgreSQL 18")]
pg --> rs["RESULT_SET"]
Lexer and parser
The front end is a hand-written crate (openehr-query) with no ANTLR
runtime: a logos tokenizer transcribed from the official AqlLexer.g4
grammar and a chumsky parser that builds one AST type per AqlParser.g4
rule. AQL keywords are case-insensitive; quoted temporal literals stay
strings at this layer because the QUERY specification resolves their typing
from the path context, not from the literal. The crate stops at the AST and
is validated against the grammar’s own example corpus.
Path analysis and typing
The planner types every identified path
(c/content[...]/data/events[...]/...) against a generated RM attribute
model: the same code generator that produces the RM types from openEHR’s
machine-readable meta-model also emits a static table of every class’s
attributes, their declared types, containers and cardinalities, plus the
abstract-to-concrete descendant sets. Path resolution is therefore a table
lookup, not runtime reflection and not a hand-maintained list, and it cannot
drift from the RM: regenerating the spec layer regenerates the oracle.
This stage answers, per path step: which RM classes can this step land on, is it a structural hop or a data leaf, what value type does the leaf carry, and which archetype predicates bound it.
The typed IR
Analysis and lowering produce a typed query intermediate representation. The IR carries no SQL and bakes in no request state: no parameter values, no paging window, no EHR scope. That purity is deliberate. Because the IR is a pure function of the query text, the query service caches lowered plans keyed on that text, so a stored query is planned once, not once per call; per-request parameter checking runs separately against the cached plan.
SQL generation
The IR lowers to one SELECT built entirely with sea-query’s typed
expression API: no string-concatenated SQL anywhere, every literal bound as
a parameter. The shapes it emits are where the
storage design pays off:
- CONTAINS is integer arithmetic. The node store keeps a nested-set
interval per RM node: “B inside A” is
A.num < B.num AND B.num <= A.num_cap. A CONTAINS chain becomes a chain of integer range joins over one table. No JSON is opened to answer containment. The interval is strict: a node is never its own parent, soCLUSTER CONTAINS CLUSTERreaches nested clusters only, andNOT CONTAINSholds for a node with no such descendant.
flowchart LR
subgraph aql ["AQL"]
contains["COMPOSITION c<br/>CONTAINS OBSERVATION o"]
end
subgraph sql ["generated SQL (shape)"]
join["JOIN node o<br/>ON o.vo_id = c.vo_id<br/>AND c.num < o.num<br/>AND o.num <= c.num_cap<br/>WHERE o.rm_type = 'OBSERVATION'"]
end
contains --> join
- A path step is one attribute.
o/data/eventsreaches theEVENTnodes that are theeventsof theHISTORYthat is thedataofo: a direct child, through that attribute, never the node itself, a deeper descendant, or a sibling attribute of the same type. - A projected path binds its nodes once per row. Every structure step a
SELECTpath descends through is bound as if it were aFROMvariable: one row per matching node, one row withnullcells where nothing matches. Paths that share a prefix share its binding, so two values read below oneEVENTalways come from the same event, and paths through different repeated steps give every combination of their nodes.WHEREandORDER BYread through those bindings. - The
WHEREclause binds its own steps once. Every repeated step aWHEREpath descends below the projected bindings is bound once for the whole clause, as aSELECTstep is (one node per binding, onenullbinding when nothing matches), and the clause becomes oneEXISTSover the assignments of those bindings: a row is kept when some assignment makes the clause true under three-valued logic, soAND,ORandNOTkeep their boolean laws per assignment. Elements of a list attribute inside one node’s data are not bindings; a test of one holds when any element satisfies it and is unknown over an empty list.EXISTS <path>is path-level: it reads through the projected bindings and checks the rest of the path in its ownEXISTS, outside the clause’s assignment. AnORDER BYkey below the bindings takes the first match in document order. - A variable of an
ORbranch binds like a projected step. A branch operand a path reads is bound under the object the branches share: one row per node that matches the branch, one row withnullcells where it does not, and two branches that both match pair up as two independent steps do. A branch that is its own versioned object (aCOMPOSITIONdirectly under theEHR, or one aFOLDERreferences) binds its node together with its version row, in the scope the same object has outside theOR: the latest version underLATEST_VERSION, never a deleted or restricted one, with itsuidread from that version. - An aggregate binds nothing. It reads every item of its path per row in
a correlated subquery and combines the rows, so
COUNT(*)beside it is unaffected; inside that subquery theWHEREclause is evaluated again with the steps the item shares fixed to its own nodes, so an item counts when some assignment of the remaining bindings makes the clause true, the items a plain projection of the path would keep. Elements of a list attribute inside one node are not filtered one by one: a row the clause keeps contributes every element. - Class and archetype predicates hit promoted columns.
rm_type, the parsed archetype identifier columns and names are plain indexed columns; a predicate naming a parent archetype matches specialised children through an indexed prefix scan. - Leaf values come out through SQL/JSON path functions. Data values are
extracted from the canonical node fragments with
jsonb_path_query_first, and arrays are unnested withjsonb_path_queryas a lateral set-returning function. Ordering and comparison on clinical magnitudes useopenehr_magnitude, anIMMUTABLEhelper realizing DV_ORDERED ordering semantics; date/time comparison usesopenehr_timestamp, which isSTABLEbecause its result depends on the session time zone. The engine uses no jsonpath item methods and noJSON_TABLE. - Version scope is a predicate, not a join through history tables.
LATEST_VERSIONis a partial-index predicate on the one temporal version table;ALL_VERSIONSis the same table unfiltered.
Execution and RESULT_SET
The built statement executes with bound parameters, and rows assemble into
the ITS-REST RESULT_SET shape. Whole-object projections serve the
version’s stored canonical body bytes; leaf projections serve the extracted
values.
What the engine refuses
Every AQL construct outside the accepted envelope is a typed error carrying
its QUERY specification reference. The same strictness applies inside the
pipeline: an unknown class, an unresolvable attribute, a type mismatch, or
an unbound $parameter refuses the query at plan time, before any SQL
exists. A query never degrades into a silently wrong answer.
Why it is fast
The speed is a property of the storage and planning design, not of tuning flags:
- No JSON tree walking on the hot path. The classic CDR cost is walking large JSON documents to test containment and extract predicates. That cost is gone by construction: containment is integer math, predicates are indexed columns.
- Decomposed fragments stay small. Node fragments average a few hundred bytes, so the rows a query touches decompress cheaply, and point reads of whole compositions bypass reassembly entirely by serving stored canonical bytes.
- Plans are cached. The IR is request-independent and cached on query text; repeated and stored queries skip lexing, parsing and typing.
- PostgreSQL 18 does the heavy lifting. B-tree skip scan,
ORto= ANYrewriting, self-join elimination, asynchronous I/O and the SQL/JSON function family are exactly the features the generated SQL leans on. - Everything is measured, nothing declared. The performance FerroEHR publishes comes from committed, re-checkable measurement records under the conformance instrument: see Performance and Benchmarks. This page explains the design; those pages carry the numbers.
Using the API
FerroEHR exposes the openEHR REST API (ITS-REST Release-1.1.0, the version
the server reports and is conformance-tested against): a resource-based HTTP
interface for creating EHRs, committing and retrieving versioned clinical
documents, managing folders and contributions, and running queries. This part is
the practical reference for client developers: the resources and their
operations, the headers that drive versioning and content negotiation, and the
error contract. For the complete endpoint reference (every path, parameter, and
schema), open the Swagger UI of the live sandbox at
https://sandbox.ferroehr.eu/ferroehr/rest/swagger-ui and sign in with the
public demo credentials ferroehr / ferroehr. That server generates the
document from its own handlers, so it describes the running release and its
“Try it out” buttons issue real requests. The sandbox’s landing surface,
https://sandbox.ferroehr.eu, is the viewer over the same server and
takes the same credentials. This book explains how to use the API.
Base path
The openEHR REST API hangs off a configurable base path, which defaults to:
/ferroehr/rest/openehr/v1
Only what openEHR ITS-REST 1.1.0 defines is served there. FerroEHR’s other interfaces sit beside it, under the same REST root:
| Namespace | Default path | Holds |
|---|---|---|
| openEHR REST API | /ferroehr/rest/openehr/v1 | the ITS-REST 1.1.0 routes: EHR, composition, directory, contribution, demographic parties, definition, query, the admin EHR deletes |
| FerroEHR extensions | /ferroehr/rest/extensions/v1 | FerroEHR’s own routes, on their own version line: terminology, event subscriptions, party relationships, the archetype library, EHR Extract messages, the cohort query and the admin extensions |
| FHIR and IHE | /ferroehr/rest/fhir/r4 | the IHE ITI-81 AuditEvent retrieval |
Every path in these chapters is relative to the openEHR base unless the page
says otherwise. So “POST /ehr” means
POST http://your-host:8080/ferroehr/rest/openehr/v1/ehr. The base path is set
with FERROEHR__REST__BASE_PATH, and it may be shortened as far as
/ferroehr/v1 (see
Shortening the REST base path).
The status and documentation routes hang off the REST root, which the server
derives from the base path by dropping the segments that name the openEHR API:
/ferroehr/rest by default. The public, unauthenticated status probe is at
/ferroehr/rest/status and interactive docs at /ferroehr/rest/swagger-ui at
the access level swagger_ui names (by default any authenticated principal, so
a browser prompts for the API credential; the documents list the whole enabled
operation surface). Every deployment serves that UI from its own routes, so your
own server always documents its own surface. The health probes stay at the
process root whatever the base path is: /health, /health/liveness,
/health/readiness.
Capability discovery
An OPTIONS request to the API base path returns the server’s
conformance manifest: the product name and version, the vendor, the openEHR
REST API version it implements, the conformance profile it claims, and the API
groups this deployment actually mounts.
curl -X OPTIONS -i \
http://localhost:8080/ferroehr/rest/openehr/v1
The response carries an Allow header and a JSON body:
{
"solution": "FerroEHR",
"solution_version": "…",
"vendor": "Cadasto B.V.",
"restapi_specs_version": "1.1.0",
"conformance_profile": "…",
"endpoints": ["/ehr", "/definition", "/query", "/demographic"]
}
Two things to know before you build discovery on it:
endpointsis the live set, not a fixed list:/adminappears only when the admin API is enabled. It covers the standardised openEHR groups only; FerroEHR’s own extension families (health, management, messaging, item tags, the archetype-source routes) declare themselves through the served OpenAPI document instead.- The identity fields are configurable (
[rest.identity], see Server, database & telemetry); their defaults are the build’s own provenance and the last machine-computed conformance verdict, so an unmodified deployment never over-claims.
Note
The manifest lives at the API base path and nowhere else. A bare
/alias existed in earlier versions and was removed. Point discovery at the base path.
Authentication
Requests are authenticated unless auth is explicitly disabled. Two mechanisms ship:
- HTTP Basic: a configured user store; send
Authorization: Basic …. The examples in this book use-u user:passwordwith curl. - OAuth2 / OIDC bearer tokens: send
Authorization: Bearer <token>, validated against a configured issuer (Keycloak, Entra ID, any standards-compliant provider).
Authorization is coarse role-based access control by default (a USER role for
clinical operations, an ADMIN role for admin operations), with optional
attribute-based policies on top. The full picture (mechanisms, roles, the
pseudonymisation boundary) is in Security.
Which status a credential problem gets
The distinction matters when you are writing a client, because only one of these means “fix your credential”:
| Situation | Status | What it means |
|---|---|---|
No Authorization header | 401 | with a WWW-Authenticate challenge listing the schemes this server implements, and no error= code — nothing has gone wrong yet (RFC 6750 §3.1) |
| Credential presented and rejected | 401 | the challenge carries error="invalid_token": expired, revoked, malformed (a bearer token outside the RFC 6750 §2.1 b64token alphabet included), or simply wrong |
Authorization header malformed | 400 | two Authorization headers, or a header that is no scheme followed by a space and a credential. The server never got as far as a credential, so this is a request defect (error="invalid_request") |
| Authenticated, not permitted | 403 | for a bearer caller the challenge carries error="insufficient_scope", naming what is missing |
| The token issuer is unreachable | 503 | with Retry-After. No token can be validated, so the server cannot decide; it is not a statement about your credential (RFC 9110 §15.6.4). Retry; do not discard the token |
Two Basic-auth details worth knowing: the credential must be padded base64 (RFC 7617 §2 defers to RFC 4648, whose §3.2 requires the pad characters; an unpadded credential is refused), and an unknown username costs the same time as a known one, so response timing reveals nothing about which accounts exist.
Warning
The quickstart ships a throwaway Basic user (
ferroehr/ferroehr), and so do the examples in this book. Replace it before any real use.
The chapters here
- Resource walkthroughs: EHR, EHR_STATUS, COMPOSITION, DIRECTORY, and CONTRIBUTION, each with real curl examples, the headers they need, and the status codes they return.
- Content negotiation & errors: choosing JSON or
XML, the simplified formats, the
Preferheader,ETag/If-Matchoptimistic concurrency, the commit-metadata headers, and the error response shape.
For querying, see Querying with AQL; for loading templates, Templates & validation; for the admin, messaging and management surfaces, Admin & messaging APIs.
Resource walkthroughs
This chapter walks through the core openEHR resources (EHR, EHR_STATUS,
COMPOSITION, DIRECTORY, CONTRIBUTION and ITEM_TAG) with real
curl examples you can adapt. For each resource it shows the operations, the
headers they need, and the status codes they return. Paths are relative to the
base /ferroehr/rest/openehr/v1 (see Using the API); examples use
Basic auth (-u ferroehr:ferroehr) and JSON. Content negotiation, the Prefer
header, and ETag/If-Match versioning are cross-cutting and get their own
chapter, Content negotiation & errors; this chapter
uses them in context.
Datetime parameters. Several operations below take a point in time
(version_at_time, the CONTRIBUTION time_range bounds). Write it in the
extended ISO 8601 form: YYYY-MM-DDThh:mm:ss.sss[Z|±hh:mm], e.g.
2016-06-23T13:42:16.117+02:00. The timezone is optional: leave it off
(2016-06-23T13:42:16) and the value is read in the server’s local
timezone, so supply Z or an explicit offset whenever the client’s timezone may
differ from the server’s. The time itself is required: a bare date
(2016-06-23), the compact “basic” ISO form (20160623T134216Z), a
zone-annotated form (2016-06-23T13:42:16[Europe/Amsterdam]), and anything
unparseable all return 400 Bad Request.
EHR
An EHR is the top-level container for one subject’s health record.
Create an EHR
curl -u ferroehr:ferroehr -X POST -i \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr
POST /ehr — the body is optional; you may supply an EHR_STATUS to set the
subject and flags at creation. Returns 201 Created with the new EHR id in
ETag and a Location header. With Prefer: return=representation the body is
the full EHR; otherwise it is empty. Supplying an EHR_STATUS whose subject
already has an EHR returns 409 Conflict.
With no body the server mints the spec’s default EHR_STATUS
(is_queryable: true, is_modifiable: true, a PARTY_SELF subject, and an
archetype_details block naming openEHR-EHR-EHR_STATUS.generic.v1). A
supplied EHR_STATUS must be a complete, RM-valid instance: EHR_STATUS is
always an archetype root, so archetype_details (with its archetype_id) is
mandatory, and a body without it is refused with 422 naming the violated
invariant. Some servers accept such a partial status and fill the gap
silently; FerroEHR validates the supplied resource exactly as the Reference
Model defines it, so either send the complete status or omit the body and let
the server mint the default.
To create with a specific id, use PUT /ehr/{ehr_id} (also 201; 409 if that id
is already used).
Once the deployment declares [privacy] subject_namespaces, a supplied
EHR_STATUS.subject.external_ref must name one of those namespaces and carry a
UUID; anything else is a 422 naming the RM path and never echoing the value.
Left undeclared, that rule is out of force and whatever you send is stored. See
Privacy & data minimisation.
Retrieve an EHR
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID
GET /ehr/{ehr_id} returns 200 with the EHR, or 404 if unknown. You
can also look one up by subject: GET /ehr?subject_id=…&subject_namespace=…
(both parameters required).
The EHR root object is not itself versioned, so its reads carry a weak ETag
built from EHR.ehr_id.value but no Last-Modified.
EHR_STATUS
EHR_STATUS holds the record’s metadata: the link to the subject, and the
is_queryable / is_modifiable flags. It is itself versioned.
Setting is_modifiable to false deactivates the EHR: any attempt to
create, update, or delete its content (a composition, the directory, or a
folder) is refused with 409 Conflict, through every write path including a
CONTRIBUTION commit. The EHR_STATUS itself stays writable (so you can set the
flag back to true to reactivate), and reads and queries are unaffected.
Read the current status
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/ehr_status
GET /ehr/{ehr_id}/ehr_status returns the current EHR_STATUS, its version id
in ETag. Add ?version_at_time=<ISO 8601> to read it as of a point in time.
GET …/ehr_status/{version_uid} reads a specific version.
Update the status
Updates require the current version id in an If-Match header (optimistic
concurrency):
curl -u ferroehr:ferroehr -X PUT \
-H 'Content-Type: application/json' \
-H 'If-Match: "<current-version-uid>"' \
-H 'Prefer: return=representation' \
--data-binary @ehr-status.json \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/ehr_status
PUT /ehr/{ehr_id}/ehr_status returns 200 (with representation) or 204
(minimal), plus the new ETag. A stale or wrong If-Match returns 412
Precondition Failed with the current version id in ETag.
Status version history
The versioned_ehr_status sub-resource exposes the full version history:
GET …/versioned_ehr_status: theVERSIONED_EHR_STATUSobject,GET …/versioned_ehr_status/revision_history: the revision history,GET …/versioned_ehr_status/version(optionally?version_at_time=) and…/version/{version_uid}— a specific version.
COMPOSITION
A COMPOSITION is a committed clinical document, validated against its template.
Create a composition
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
-H 'Prefer: return=representation' \
--data-binary @composition.json \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition
POST /ehr/{ehr_id}/composition returns 201 Created with the version id in
ETag. A body that cannot be constructed as a COMPOSITION (malformed JSON,
an undeclared or repeated member, a missing mandatory attribute, an empty list
the model requires non-empty, a _type foreign to its slot) is 400 Bad
Request: parsing is the shape check, so structural defects never reach
validation. A body that constructs but fails semantic validation (template
constraints, RM invariants, terminology bindings) returns 422 Unprocessable
Entity with the errors; an unknown EHR, 404.
Retrieve a composition
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition/$UID
GET /ehr/{ehr_id}/composition/{uid_based_id} accepts either a full version id
(<uuid>::<system>::<n>) or a bare object uuid (in which case add
?version_at_time= to pick a point in time). It returns 200 with the
composition, 204 if the composition was (logically) deleted at that time, or
404.
Update and delete
# Update — If-Match is the CURRENT version id; the URL uses the bare object uuid
curl -u ferroehr:ferroehr -X PUT \
-H 'Content-Type: application/json' \
-H 'If-Match: "<current-version-uid>"' \
--data-binary @composition.json \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition/$OBJECT_UUID
# Delete — the URL uses the FULL version id
curl -u ferroehr:ferroehr -X DELETE \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition/$VERSION_UID
PUT returns 200/204 (per Prefer) with the new version id, 412 on
an If-Match mismatch, 422 on validation failure. DELETE is a logical
delete (the history is retained), returning 204 with the new deleted
version’s ETag; deleting something already deleted returns 400, and a
version id that is not the latest returns 409.
Warning
Watch the id you pass.
PUTtakes the object uuid (the versioned object), whileDELETEtakes the full version id (the version you are superseding).GETaccepts either.
Note
A version’s lifecycle state (set through the
openehr-version: lifecycle_state.code_stringheader; see Content negotiation & errors; the default on a commit is532|complete|) must follow the openEHR version-lifecycle state machine. An illegal transition is rejected with 422 Unprocessable Entity naming the states. In particular, a version left in the801|abandoned|state cannot be updated straight tocomplete: you must first retrieve it back to553|incomplete|, then complete it.Two further rules apply to the state a commit may claim:
523|deleted|belongs toDELETEalone. Deleting is one act (a new version whose data is removed and whose state isdeleted), so aPUTorPOSTthat carries content may not claim it. Such a request is rejected 422. Conversely, aDELETEthat supplies a lifecycle other than523|deleted|is rejected 400: the value would have to be discarded, and the server tells you rather than pretending to honour it. ADELETEwith no lifecycle header at all is the normal case and is unaffected.553|incomplete|relaxes what a commit must contain. Content committed asincompletemay leave mandatory attributes absent and1..*containers empty, for compositions, folders, and demographic parties and relationships alike. Everything else is still checked: types, terminology codes, patterns and archetype constraints. Missing content is allowed here; wrong content is still rejected 422. TheEHR_STATUSresource is the one exception: it does not accept theincompletestate.
Composition version history
GET …/versioned_composition/{versioned_object_uid} and its revision_history,
version, and version/{version_uid} sub-resources mirror the EHR_STATUS
history endpoints.
DIRECTORY
The DIRECTORY is an optional FOLDER tree for organising compositions
within an EHR. The /directory endpoints manage the EHR’s primary hierarchy
(the openEHR EHR.directory, which is always the first member of
EHR.folders).
An EHR can also index additional folder hierarchies beyond the directory:
commit further root FOLDERs through the CONTRIBUTION endpoint (the openEHR
REST API defines no dedicated endpoint for them). The EHR resource then lists
every live hierarchy in its folders attribute, in creation order, with
directory always equal to the first member; deleting the directory promotes
the next live hierarchy.
# Create the directory
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
--data-binary @folder.json \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/directory
# Read it (optionally at a time, or a sub-path)
curl -u ferroehr:ferroehr \
'http://localhost:8080/ferroehr/rest/openehr/v1/ehr/'$EHR_ID'/directory?path=episodes/2024'
POST /ehr/{ehr_id}/directory: create the root folder; 201.PUT /ehr/{ehr_id}/directory: update it; requiresIf-Match; 200/204.DELETE /ehr/{ehr_id}/directory: logical delete; requiresIf-Match; 204.GET /ehr/{ehr_id}/directory: the current folder tree, optionally filtered by?version_at_time=and?path=(slash-separated folder names). 204 if deleted at that time.GET /ehr/{ehr_id}/directory/{version_uid}: a specific version, optionally?path=.
Folder items are OBJECT_REFs, and the server validates the ones that claim
this system: a reference whose namespace is local (or the server’s
configured system id) must resolve to a versioned object in that EHR, or the
commit is refused with 422 naming each unresolvable reference at its tree
path. References into other namespaces (another system’s id, or unknown)
are stored verbatim without a resolvability check, since openEHR object
references are explicitly allowed to point outside the current system. The
same rule applies to folder hierarchies committed through the CONTRIBUTION
endpoint.
CONTRIBUTION
A CONTRIBUTION is an atomic change-set: a group of versioned-object changes (compositions, statuses, folders) committed together with one shared audit. Use it when several changes must land as a unit.
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
--data-binary @contribution.json \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/contribution
POST /ehr/{ehr_id}/contribution takes a contribution whose versions array
each describe a change (the RM object, its change_type, its lifecycle_state,
and per-version commit_audit) plus a shared audit. It returns 201 with
the contribution id in ETag, or 400/404/409/422 on invalid
input, unknown EHR, a uid conflict, or a change set that is well-formed but
cannot be followed.
Five things about the payload are worth calling out:
- The shared
auditmust carry its ownchange_typeandcommitter. They are your account of the change set as a whole and are never derived or invented by the server. A contribution without anaudit, or whoseauditomits either one, is refused with a 400. The server fills intime_committed, andsystem_idwhen you do not supply one. - Every
commit_auditnames its owncommitter, an attestation included. The server never copies the shared audit’s committer into a version that leaves it out: that version is refused with a 400 naming its index. A committer that is not aPARTY_PROXYobject at all, such as a bare string, is a 400 too; one that is aPARTY_PROXYbut breaks its own rules, such as aPARTY_IDENTIFIEDwith no name, identifiers or reference, is a 422. A version’s committer is stored as you send it, even when it differs from the shared audit’s. A version that omitssystem_idgets the shared audit’s, and every audit in the contribution carries the sametime_committed. lifecycle_stateis required on every version and is not defaulted. Omitting it is a 400, on an attestation entry (see below) as well, even though an attestation commits no new version. A code outside the openEHR version lifecycle state group is a 422.- A version entry carries exactly the six declared members
(
preceding_version_uid,signature,lifecycle_state,attestations,data,commit_audit) plus an optional_typeself-tag. Anything else is refused 400 naming the offending key and its index, never silently ignored. other_input_version_uidsanditemare not accepted on a commit. Merge provenance is read-only (it appears onORIGINAL_VERSIONreads and is preserved when a version arrives through an EHR-Extract import or an archive load), anditemis the shape of an imported version, which only the import route produces. Either one on a version entry is a 400.
A commit_audit may instead be an ATTESTATION: set its _type to
ATTESTATION (or the wire form UPDATE_ATTESTATION) and add reason
(required) plus is_pending (required), and optionally proof, items and
attested_view. This is how content is committed already signed, or marked as
awaiting signature (is_pending: true). A coded reason must be a member of
the openEHR attestation reason group, and items, when present, must be
non-empty. The attestation is stored as part of that version’s commit audit and
read back on the version envelope, in the revision history, and in exports. A
description may be a plain string, a DV_TEXT, or a DV_CODED_TEXT; a coded
description keeps its defining_code.
An attestation entry signs or reviews a version that is already stored,
without committing a new one. Give it the change type 666 (attestation),
name the attested version in preceding_version_uid, and leave data out. Its
commit_audit is the UPDATE_ATTESTATION itself, with its own committer,
reason and is_pending. The entry still needs a lifecycle_state, which the
server checks against the version lifecycle state group (a 422 when it is
outside it) but does not store, because no new version carries it.
GET /ehr/{ehr_id}/contribution/{contribution_uid} returns 200 with the
contribution, or 404. Add Prefer: resolve_refs to get full VERSION objects
instead of OBJECT_REFs (see
Content negotiation & errors).
GET /ferroehr/rest/extensions/v1/ehr/{ehr_id}/contribution (no uid) lists the
EHR’s contributions, newest first. It is a FerroEHR extension (the openEHR REST
API defines only the by-uid read), so it sits under the extensions root beside
the openEHR API. Paginate with ?offset= (default 0) and ?fetch= (default 20, capped at
100). It returns 200 with a JSON summary, or 404 for an unknown EHR:
{
"rows": [
{
"uid": "…",
"time_committed": "…",
"committer": "…",
"change_type": "251",
"change_type_rubric": "amendment"
}
],
"total": 123
}
committer is the audit committer’s name only; the by-uid read returns the
full PARTY_PROXY. change_type is the openEHR audit-change-type code and
change_type_rubric its display rubric from the same terminology the by-uid
read uses, so a client never maps codes locally. total counts all of the
EHR’s contributions, not just the returned window.
Note
The contribution envelope is canonical JSON only: openEHR publishes no CONTRIBUTION XML document, so an XML
Accepton these routes is a 406 and an XMLContent-Typea 415. The FLAT and STRUCTURED formats, when used, apply only to the inner compositiondataof each version, never to the envelope.
ITEM_TAG
ITEM_TAGs are small key/value annotations on a versioned object or on
one specific version, optionally pointing at a node inside the data through
target_path. They carry no clinical meaning and do not create a new version.
Use them for workflow state, review flags, and integration bookkeeping.
# Every tag in an EHR, optionally filtered
curl -u ferroehr:ferroehr \
'http://localhost:8080/ferroehr/rest/openehr/v1/ehr/'$EHR_ID'/tags?tag_key=flag'
# Replace the tag list of one composition (container-wide, by object uuid)
curl -u ferroehr:ferroehr -X PUT \
-H 'Content-Type: application/json' \
-H 'Prefer: return=representation' \
--data-binary '[{"key":"flag","value":"follow-up"}]' \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition/$OBJECT_UUID/tags
# Delete every tag under one key
curl -u ferroehr:ferroehr -X DELETE \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition/$OBJECT_UUID/tags/flag
GET /ehr/{ehr_id}/tags: every tag in the EHR, whatever it targets. The optionaltag_key,tag_valueandtag_target_pathfilters are exact, case-sensitive, and AND-combined; an omitted filter constrains nothing. An EHR with no matching tag answers 200 with[], never 404.GET|PUT /ehr/{ehr_id}/composition/{uid_based_id}/tagsand the same pair under…/ehr_status/{uid_based_id}/tags— read or replace the addressed collection. ThePUTbody is a bare JSON array of tags (keyrequired,valueandtarget_pathoptional;targetandowner_idcome from the route, and a body that supplies them, or any other undeclared member, is a 400). An empty array[]is the clear-all form, never an error.PUTanswers 204 by default, 200 with the resulting list underPrefer: return=representation.DELETE …/tags/{key}: delete every tag under one key on the addressed collection; 204.
Two properties shape how you address them:
- The id you pass selects which collection. A full version id
(
OBJECT_VERSION_ID) addresses that one version’s tags; a bare object uuid (HIER_OBJECT_ID) addresses the versioned-object container’s tags. - The two collections are disjoint. A tag has exactly one target, so replacing the container’s list never touches any version’s list, and replacing one version’s list never touches the container’s or a sibling version’s.
Every returned tag carries a server-assigned target (the bare uid of what it
tags) and owner_id (an OBJECT_REF to the owning EHR). Tags can also ride
along with a content write instead of taking a second round trip; see
Item tags via headers.
Status-code summary
| Code | Meaning across these resources |
|---|---|
| 200 | Retrieved, or updated with Prefer: return=representation / return=identifier. |
| 201 | Created (EHR, composition, directory, contribution). |
| 204 | Success with no body (return=minimal), or deleted / deleted-at-time. |
| 400 | Malformed request, missing required header/parameter, an undeclared payload member, or already-deleted. |
| 404 | Unknown EHR, object, version, or no version at the requested time. |
| 405 | Method not allowed on this resource (always with an Allow header), or the resource is switched off by configuration. |
| 406 | The Accept header names no representation this resource has. |
| 409 | Conflict: a duplicate subject or id, a version that is not the latest, or a content write to a deactivated (is_modifiable = false) EHR. The deactivation check runs before any content is validated, so a defective body on a deactivated record reports the state conflict rather than a 422. |
| 412 | If-Match did not match the latest version (current id returned in ETag). |
| 415 | The request Content-Type names a format this resource cannot process. |
| 422 | Well-formed but unfollowable: failed template or semantic validation, a body the data-minimisation rules refuse, an illegal version-lifecycle transition, or a change set the server cannot apply. |
| 429 | Too many requests, with Retry-After and the x-ratelimit-* headers the limiter computed. |
| 503 | The server cannot decide or cannot serve right now, with Retry-After: the admission cap is full, the token issuer is unreachable, or (under [audit] fail_mode = "closed") the access record could not be taken. Retry; none of these is a statement about your request. |
The Content negotiation & errors chapter covers the error body shape and the headers referenced above in full.
Content negotiation & errors
A handful of HTTP mechanisms cut across every openEHR resource: choosing the
wire format (JSON or XML), controlling how much a write returns (the Prefer
header), versioned optimistic concurrency (ETag and If-Match), and the
request headers that enrich a commit (audit metadata and item tags). This
chapter explains them all, plus the shape of error responses, so the examples in
Resource walkthroughs make sense in general.
- JSON and XML
- Simplified formats (FLAT and STRUCTURED)
- The
Preferheader ETagandIf-Match— optimistic concurrencyLast-Modified— when the version was committed- Imported versions
- Commit metadata headers
- Item tags via headers
- Error responses
JSON and XML
FerroEHR speaks canonical JSON and canonical XML for the RM-typed resources. Choose with the standard HTTP headers:
- Request body: set
Content-Type: application/jsonorapplication/xml(text/xmlworks the same asapplication/xml). - Response: set
Accept: application/jsonorapplication/xml. With noAccept, or with several acceptable formats at equal quality, JSON wins.
JSON is wired end to end for every operation. XML is supported for the
spec-typed RM objects whose canonical XML shape the openEHR ITS-XML schemas
define: a single composition, EHR_STATUS, EHR, FOLDER, the demographic
party types, and the version family (versioned objects and revision history).
Responses that are not a spec-typed RM value (collections, item tags, and the
query and terminology DTOs) are JSON-only, as is the CONTRIBUTION envelope.
# Commit a composition as XML, ask for XML back
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/xml' \
-H 'Accept: application/xml' \
--data-binary @composition.xml \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition
Choosing the XML namespace
openEHR publishes its canonical XML schemas in two lineages, each declaring its own namespace:
| Lineage | Root namespace | Status |
|---|---|---|
| v1 | http://schemas.openehr.org/v1 | The schema release openEHR labels stable, frozen against an older Reference Model |
| v2 (default) | http://schemas.openehr.org/v2 | The newer schema release, still marked trial by openEHR; the only one that models the current Reference Model |
Pick one per request with a version parameter on the XML media type:
# Read a composition in the v1 namespace
curl -u ferroehr:ferroehr \
-H 'Accept: application/xml; version=1' \
"http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition/$UID"
# Commit one whose payload already uses the v1 namespace
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/xml; version=1' \
-H 'Accept: application/xml; version=1' \
--data-binary @composition-v1.xml \
"http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition"
What to expect:
- No parameter (or
version=2) means the v2 default, and the response header staysContent-Type: application/xml. - A v1 response says so:
Content-Type: application/xml; version=1. - On requests the parameter is a courtesy, not a requirement. The reader
dispatches on element names and
xsi:typeand never inspects the root namespace, so both lineages parse identically whatever you declare; you only need the parameter when you want the declaration to be accurate. - Asking for a lineage the server does not serve (
version=3, say) is 406 Not Acceptable onAcceptand 415 Unsupported Media Type onContent-Type. The refusal is deliberate: the parameter exists so you can be specific about the representation you can consume, and quietly substituting a different one would defeat that. - Operational templates are always v1.
GET …/definition/template/adl1.4/{template_id}returns OPT XML in the v1 namespace and ignores the parameter; it is a template document, not a canonical RM resource.
The documents FerroEHR writes for the two lineages are byte-identical apart
from that root namespace: the same elements, the same order, the same xsi:type
dispatch. The two schema bundles are a different story, and that matters if
you intend to validate.
If you validate responses against the published XSDs
Both published bundles have a problem today, and knowing which one you are hitting saves a long debugging session.
The v1 bundle is frozen at an older Reference Model, so it does not describe
everything a current openEHR document may contain. It declares no schema at all
for EHR, EHR_STATUS, CONTRIBUTION or the demographic party types, and it omits
attributes that later RM releases added: FOLDER.details,
ELEMENT.null_reason, DV_QUANTITY.units_system and units_display_name,
CODE_PHRASE.preferred_term, and ENTRY.workflow_id among them. A perfectly
valid directory or blood-pressure response can therefore fail v1 validation.
FerroEHR does not drop clinical content to fit the older bundle. That content gap
is why v2 is the default: the lineage a schema-validating client receives
without asking has to be the one that models what the server actually emits.
The v2 bundle currently cannot be compiled at all. One of its base
simple types constrains archetypeNodeId with a Perl-flavoured pattern (using
(?:…) non-capturing groups), and the XML Schema pattern language admits no
such construct, so a conformant XSD processor rejects the schema set rather
than the document. The defect sits in every v2 Reference Model folder, not just
the newest, and it is upstream: nothing in a response can work around it.
Warning
As a result there is no bundle today that both compiles and models current RM content. If you must schema-validate right now, request
version=1and accept that it will reject valid content it never modelled; otherwise validate structurally against your own profile and treat the published XSDs as documentation. FerroEHR tracks the v2 defect and will say so here when upstream fixes the facet.
Note
The
versionparameter is a FerroEHR extension: the openEHR REST specification predates the two schema lineages and says nothing about selecting one. It never changes the media type itself (responses are alwaysapplication/xml), so a client that ignores it behaves exactly as the specification describes.
Simplified formats (FLAT and STRUCTURED)
Beyond the canonical formats, the server implements the openEHR Simplified
Formats: template-driven JSON representations that use friendly field
identifiers (vital_signs/body_temperature:0/any_event:0/temperature|magnitude)
instead of full RM paths. Select them the same way as JSON/XML, with these media
types:
| Media type | Meaning |
|---|---|
application/openehr.wt.flat+json | FLAT — one flat JSON object of path: value pairs |
application/openehr.wt.structured+json | STRUCTURED — the same data as nested JSON |
application/openehr.wt+json | A template rendered as Web Template JSON (template endpoints only) |
Where they work:
- Compositions: full round-trip. Commit with
Content-Type: application/openehr.wt.flat+json(or…structured…) and read back with the matchingAccept. - Template examples:
GET …/definition/template/adl1.4/{id}/example(and the ADL2 form) return the generated example in canonical JSON or XML, FLAT, or STRUCTURED, chosen viaAccept. The example party of a demographic template comes in canonical JSON or XML only. - Template definitions:
GET …/definition/template/adl1.4/{id}withAccept: application/openehr.wt+jsonreturns the Web Template document.Accept: application/jsonreturns the same document (it is the only JSON representation of a template) underContent-Type: application/json: the response always carries the media type you asked for. - Contributions: the CONTRIBUTION envelope itself stays canonical JSON; a
simplified media type applies only to each composition payload inside
versions[].data.
Two rules to know when committing a composition in a simplified format:
- A FLAT/STRUCTURED payload cannot carry its own template id, so the
openehr-template-idrequest header is required; the commit is rejected with 422 without it. - There is no
?format=query parameter: format selection is done exclusively through the standardAcceptandContent-Typeheaders.
Requests naming a media type the endpoint does not support are answered with 415 Unsupported Media Type (request body) or 406 Not Acceptable (response format), with a body naming the formats that endpoint does support. EHR, EHR_STATUS, directory, and demographic resources have no simplified representation (the format is generated from an operational template, which those resources do not have). They speak canonical JSON/XML only.
The query API is JSON only: it does not accept XML or the simplified media types.
The Prefer header
Write operations (create/update) accept a Prefer header controlling the
response body. Its default is return=minimal:
Prefer value | Effect |
|---|---|
return=minimal (default) | Empty body; the identifier is in ETag/Location. Status 204 on update, 201 on create. |
return=representation | The full created/updated resource in the body, status 200/201. |
return=identifier | Just the resource identifier object — {"uid": "…"} (templates: {"template_id": "…"}). Status 200/201, never 204. |
Use return=representation when you want the server-completed object back (with
its assigned version id and any server-set audit fields); use return=minimal
for throughput when you only need the id.
return=identifier always comes back with a body, so it never uses 204: an
update that would answer 204 under return=minimal answers 200 with the
identifier object instead. Under an XML Accept the identifier body is the
equivalent single element, <uid>…</uid>.
Every write response names the preference the server actually applied in a
Preference-Applied header (return=representation, return=identifier, or
return=minimal), so a client can tell what it got without sniffing the body.
The header reports what the response did: a request with no Prefer gets
return=minimal (the default behaviour), and where an identifier cannot be
produced at all (an item-tag collection has no uid, for instance) the server
applies and reports return=minimal rather than claiming an identifier response
it did not send.
Prefer: resolve_refs
Contribution reads return their versions as OBJECT_REFs by default. Add
resolve_refs to the Prefer header (it combines with the return=… token,
e.g. Prefer: return=representation, resolve_refs) and the response carries the
full VERSION objects instead: one round trip instead of one per version. A
version created here resolves to an ORIGINAL_VERSION; one this server received
from another system resolves to the IMPORTED_VERSION that wraps it (see
Imported versions).
ETag and If-Match — optimistic concurrency
openEHR objects are versioned, and updates use HTTP preconditions to prevent lost updates:
-
Every read and successful write returns an
ETagheader carrying the object or version identifier as a weak ETag,W/"…". -
Updating or deleting a versioned object requires an
If-Matchheader set to the current version id. Both the weak form and a bare quoted value are accepted; echoing theETagyou received works either way:If-Match: W/"8849182c-82ad-4088-a07f-48ead4180515::your.system::2" If-Match: "8849182c-82ad-4088-a07f-48ead4180515::your.system::2" -
If the object has moved on since you read it, the write fails with 412 Precondition Failed and the current version id in the response
ETag. Re-read, reconcile, and retry against the new version.
A Location header is emitted only when a resource is created. Reads
and deletes identify the version through ETag alone, so do not expect
Location on them; the ETag is the authoritative identifier.
Last-Modified — when the version was committed
Alongside the ETag, versioned responses carry a Last-Modified header in
the standard HTTP-date form (Wed, 22 Jul 2009 19:15:56 GMT). Its value is the
commit time of the version being served (the audit time_committed of that
VERSION), so it changes exactly when the ETag does.
You get it on:
- every
VERSIONread (…/versioned_composition/{uid}/version[/{version_uid}],…/versioned_ehr_status/version[/{version_uid}]) and revision history read; - every COMPOSITION,
EHR_STATUS, and DIRECTORY read, including the FLAT and STRUCTURED representations, which describe the same version; - every write of those resources (create, update, and the delete
204), and the EHR create201; - every CONTRIBUTION, both the read and the commit
201, where the value is the contribution audit’s commit time. On a contribution commit you get the header under eitherPrefersetting; withreturn=minimalthere is no response body, so the header is the only place the commit time appears.
Resources that are not versioned do not carry it: GET /ehr/{ehr_id} returns the
weak ETag (built from EHR.ehr_id.value) but no Last-Modified, because the
EHR root object has no commit audit of its own.
Template responses (ADL 1.4 and ADL2) carry a weak ETag keyed on the template
identifier; for ADL2 it is the resolved artefact id, so requesting a template
by a partial id or a major-version prefix still gives you an ETag that changes
when the served artefact changes.
Note
Version ids normally end in a plain trunk number (
…::2), but openEHR version trees can branch: when a version that was created on another system is modified locally, the server forks a branch and the new version id ends in a three-part tree id (…::2.1.1). Treat the version id as an opaque token (echo it back inIf-Matchexactly as received) and it works the same for trunk and branch versions.ALL_VERSIONSqueries and version reads return branch versions alongside trunk ones; the latest version of an object is always the latest trunk version.
Imported versions
Most versions are created here, and a VERSION read returns them as an
ORIGINAL_VERSION. A version that arrived from another system (through an
EHR Extract import) is a copy, and openEHR wraps a copy: the server commits it
as an IMPORTED_VERSION and the version resource serves that wrapper. Both
shapes appear at the same URLs
(…/versioned_composition/{uid}/version[/{version_uid}],
…/versioned_ehr_status/version[/{version_uid}]) and in a resolve_refs
contribution read, so branch on _type:
{
"_type": "IMPORTED_VERSION",
"contribution": { "_type": "OBJECT_REF", "type": "CONTRIBUTION", "…": "…" },
"commit_audit": { "_type": "AUDIT_DETAILS", "…": "…" },
"item": {
"_type": "ORIGINAL_VERSION",
"uid": { "_type": "OBJECT_VERSION_ID", "value": "…::source.example.org::1" },
"contribution": { "_type": "OBJECT_REF", "type": "CONTRIBUTION", "…": "…" },
"commit_audit": { "_type": "AUDIT_DETAILS", "…": "…" },
"data": { "…": "…" }
}
}
Two acts, kept apart on purpose:
- the wrapper’s
contributionandcommit_auditare this server’s act of importing: our system id, the importing user, the instant the import landed here, change type249|creation|; itemis the receivedORIGINAL_VERSION, byte-for-byte as it was sent, keeping the source system’s contribution reference, commit audit and signature.
Three consequences worth designing for:
- An
IMPORTED_VERSIONhas nouidof its own. It shares the wrapped version’s identity, so read the version id fromitem.uid.value. TheETagstill carries that id, so theIf-Matchround trip is unchanged. - Times are local.
VERSIONED_OBJECT.time_created,Last-Modified, the revision history and every as-of-instant read report when the version became available here, never the source system’s earlier clock, so a query for the record’s state at a past instant returns what this repository actually held then. The original committal is still there, insideitem.commit_audit. - An export unwraps. EHR Extracts carry
ORIGINAL_VERSIONs, so exporting an imported version ships the wrapped original verbatim; the receiving system creates its own wrapper. Wrappers never nest.
Commit metadata headers
When you commit through the direct resource endpoints (EHR creation,
composition, EHR_STATUS, directory), the server builds the version’s audit for
you. Two request headers let you set parts of it: openehr-version for the
version’s own attributes and openehr-audit-details for the commit audit.
The value is a comma-separated list of attribute.subkey="value" pairs (quoted
values may contain commas; the header may repeat, and repeats are merged):
# Commit a composition as a draft (lifecycle state "incomplete", code 553)
openehr-version: lifecycle_state.code_string="553"
# Name the committer, describe the change, and stamp the source system
openehr-audit-details: committer.name="John Doe",description.value="Corrected dosage",system_id="pas.example.org"
The attributes the server merges:
| Header | Attribute | Sub-keys |
|---|---|---|
openehr-version | lifecycle_state | code_string |
openehr-audit-details | change_type | code_string |
openehr-audit-details | description | value (or a bare value) |
openehr-audit-details | committer | name, external_ref.id, external_ref.namespace, external_ref.type (defaults to PERSON) |
openehr-audit-details | system_id | (bare value) |
A client-supplied system_id is merged into the commit audit, useful when a
gateway commits on behalf of a source system; when absent, the server stamps its
own system id.
Both EHR creates (POST /ehr and PUT /ehr/{ehr_id}) accept the headers too:
creating an EHR commits its EHR_STATUS and EHR_ACCESS in a contribution, so the
supplied description, committer, and system id land on that commit, and
openehr-version sets the new EHR_STATUS version’s lifecycle state. The
change_type on a create is constrained to 249|creation| (a create commits a
first version), so restating 249 is accepted while any other change type is
rejected. A DELETE accepts the headers as well: a logical delete commits a
523|deleted| version, and the audit metadata rides with it.
Note
openEHR itself deprecated an earlier spelling of these headers in Release-1.0.3: the attribute sat in the header name (
openEHR-VERSION.lifecycle_state: code_string="553",openEHR-AUDIT_DETAILS.committer: name="John Doe", and the bareopenEHR-AUDIT_DETAILS). The specification keeps them available for backward compatibility and so does this server, so an older client keeps working. If both forms appear, the value-form header above wins.
Item tags via headers
Item tags (small key/value annotations, optionally pointing at a node inside
the data via target_path) can ride the same request as a write, so tagging
does not need a second round trip (the dedicated endpoints are in
Resource walkthroughs). Two headers carry them:
openehr-item-tag: tags targeting the versioned object;openehr-version-item-tag: tags targeting the version being committed.
The value is a ;-separated list of tags, each a comma-separated set of
key="…", value="…", and optional target_path="…" pairs:
openehr-version-item-tag: key="diagnosis",value="confirmed",target_path="/content[0]"; key="reviewed",value="true"
They are accepted on the EHR-group change-controlled writes (composition create/update, EHR_STATUS update, directory create/update) and on demographic party writes. Sending the header with an empty value removes all tags. A defective tag refuses the whole request before the content is committed, so a bad tag never leaves a half-applied write behind.
The two headers address different targets, so the response echo keeps them
apart: openehr-item-tag confirms the tags now stored on the versioned object,
openehr-version-item-tag those stored on the version just committed, and a
header you did not send is not echoed at all. (Demographic parties store tags
against the versioned object only, so both headers carry the same list there.)
Error responses
Errors use conventional HTTP status codes (see the summary in
Resource walkthroughs) with one body
shape on every openEHR and extension route: RFC 9457
problem details, media type
application/problem+json. The body is JSON, so a client that sends
Accept: application/json reads it as well. The FHIR routes under
/ferroehr/rest/fhir/r4/ answer a FHIR OperationOutcome instead.
{
"type": "https://ferroehr.eu/docs/latest/using-the-api/problems.html#validation-failed",
"title": "The content fails validation",
"status": 422,
"detail": "the content fails validation at 2 path(s); a request with `Prefer: return=representation` receives each path and its violation",
"instance": "urn:uuid:0190a6f3-7c1e-7d7a-9f43-1c5b8a2e4d10"
}
typenames the problem. A refusal you can act on has a FerroEHR type, documented on Problem types; every other error isabout:blank, whosetitleis the status phrase, such asNot Found.titleis the same for every occurrence of a type;detailexplains this occurrence.statusrepeats the HTTP status.instanceis the server’s request id, the one theX-Request-Idresponse header and the server log carry, so an operator can find the request.
The openEHR REST specification lets a server return additional error details
when the request carries Prefer: return=representation. FerroEHR does: with
that header, a problem carries its extension members, and the response says
Preference-Applied: return=representation.
-
validation-failedlists every violation inerrors, as{"path": …, "detail": …}, so a client can point the user at the exact offending node:"errors": [ { "path": "/content[0]/data/events[0]/data/items[1]/value/magnitude", "detail": "value out of range" }, { "path": "/content[0]/data/events[0]/data/items[2]/value/defining_code", "detail": "code not in group" } ]A refusal from the data-minimisation rules uses the same list, with one entry per finding, and deliberately never echoes the value that matched: repeating it would carry the identifier into the response body, the access log and the traces.
-
aql-invalidgives the byte range of the AQL text it points at asposition,{"start": …, "end": …}. -
version-conflictnames the latest version ascurrent_version, the same value itsETagcarries.
No problem carries a stack trace, SQL, a server path or a value from the
store: a server fault is a 500 with a fixed detail.
Match on the HTTP status first, then on type; read detail for the
human-readable explanation.
405 always names the allowed methods
Every 405 Method Not Allowed carries an Allow header listing the methods the
target resource currently supports, as
RFC 9110 §15.5.6
requires, so a client can discover the right method without guessing:
HTTP/1.1 405 Method Not Allowed
Allow: GET,HEAD,PUT
Content-Type: application/problem+json
{ "type": "about:blank", "title": "Method Not Allowed", "status": 405, "detail": "the request method is not allowed on this resource" }
Whether a method is served depends on the resource, never on your credential.
With authentication on, an authenticated request with a method the resource
does not serve gets this 405 whatever its token grants; the role and SMART
scope checks apply only to a served method. A request without a credential is
still 401.
When a resource is switched off by configuration (the
admin API with FERROEHR__ADMIN__ENABLED=false),
the header is present but empty, which
RFC 9110 §10.2.1
defines as “the resource allows no methods”: nothing you can send to that path
will be served until the gate is opened.
A method the server does not recognize at all is answered 405 as well (with
Allow), not 501. The openEHR spec suggests 501 there, but the two rules it
states overlap for any method outside its own list, and a blanket 501 would
also mislabel requests to paths that simply do not exist and are owed a 404.
501 Not Implemented remains reserved for a recognized operation this server
does not implement.
Problem types
Every error on FerroEHR’s openEHR and extension routes is an RFC 9457
problem details document (Content negotiation & errors).
Its type is either about:blank, an error that means no more than its HTTP
status, or one of the FerroEHR types below. Each type’s URI is this page at
the type’s anchor, such as
https://ferroehr.eu/docs/latest/using-the-api/problems.html#aql-invalid.
The title is the same for every occurrence; the members marked “with
Prefer: return=representation” appear only when the request carries that
header.
aql-invalid
Status: 400 Bad Request. Title: The AQL query is not valid.
The AQL text does not parse, or the query engine refuses it (a construct
outside the supported AQL, a path it cannot type, a parameter it cannot bind).
Correct the query; detail says what was refused.
With Prefer: return=representation: position, {"start": …, "end": …},
the byte range of the query text a parse refusal points at.
query-timeout
Status: 408 Request Timeout. Title: The query ran longer than the
server allows.
The query ran past query.timeout_ms and the server aborted it. Narrow the
query (a tighter WHERE, a LIMIT, fetch and offset) or ask the operator
to raise the limit.
validation-failed
Status: 422 Unprocessable Content. Title: The content fails
validation.
The payload parsed but fails its template, the Reference Model, the terminology, or the data-minimisation rules. Correct the content at each path.
With Prefer: return=representation: errors, one
{"path": …, "detail": …} per violation, keyed by the RM path of the
offending node.
content-invalid
Status: 422 Unprocessable Content. Title: The content cannot be
processed.
The payload is well-formed but cannot be processed, such as a template or an
archetype the server cannot accept. detail names the rule.
conflict
Status: 409 Conflict. Title: The request conflicts with the
resource’s current state.
The resource already exists (an EHR for the subject, a composition with the given id) or the write conflicts with a concurrent one. Read the resource and decide whether to retry.
version-conflict
Status: 412 Precondition Failed. Title: The version named is not the
latest.
The If-Match header names a version other than the latest, so another write
came first. The ETag header carries the latest version; read it, merge, and
write again against it.
With Prefer: return=representation: current_version, the latest version,
the same value as the ETag.
unauthenticated
Status: 401 Unauthorized. Title: The request needs credentials.
The request carried no credentials, or credentials the server did not accept.
The WWW-Authenticate header says which scheme to use.
forbidden
Status: 403 Forbidden. Title: The caller may not perform this
operation.
The caller is authenticated, and the access policy (roles, SMART scopes, EHR
access, assurance level) refuses the operation. detail names the missing
grant.
processing-restricted
Status: 403 Forbidden. Title: Processing of this record is
restricted.
The record carries a restriction of processing (GDPR Art. 18), so the server keeps it stored and refuses other processing until the restriction is lifted.
unavailable
Status: 503 Service Unavailable. Title: The server is temporarily
unavailable.
The server or its database is overloaded or draining. Retry after the
Retry-After header’s delay.
rate-limited
Status: 429 Too Many Requests. Title: The caller sent more requests
than its rate allows.
The caller passed [server.caller_rate]. Retry after the Retry-After
header’s delay.
Querying with AQL
The Archetype Query Language (AQL 1.1) is how you read data out of FerroEHR.
Instead of querying hidden database tables, you query the clinical model
directly: you name the RM types and archetypes you want, express structural
nesting with CONTAINS, and select values by their path within an archetype. The
same query runs unchanged on any conformant openEHR system. This chapter is a
practical walkthrough: the language, how to run queries over HTTP, parameters,
stored queries, version scope, terminology, pagination and limits, and the
supported feature envelope.
- The shape of a query
- Running a query over HTTP
- The result set
- Parameters
- Stored queries
- Cohort queries across the pseudonymisation boundary
- Queries rooted at a demographic class
- Version scope: LATEST_VERSION and ALL_VERSIONS
- Terminology in queries
- Pagination and limits
- What is supported
The shape of a query
An AQL statement has the familiar SELECT … FROM … WHERE … ORDER BY skeleton,
but the “tables” are RM types and the “columns” are archetype paths:
SELECT
c/name/value AS composition_name,
o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic
FROM EHR e
CONTAINS COMPOSITION c
CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140
ORDER BY systolic DESC
-
FROMbinds variables to RM types (EHR e,COMPOSITION c,OBSERVATION o). A type can be constrained by archetype id in square brackets (OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]). Naming a parent archetype also returns data recorded under its specialisations, as openEHR requires: for ADL 1.4 identifiers the specialisation is the hyphen-extended concept (…blood_pressurematches…blood_pressure-cuff), and for ADL 2 identifiers (where the hyphen carries no such meaning) the lineage is read from the ADL 2 archetypes and templates you have uploaded, so the parent matches every stored specialisation of it whatever its concept is named. In both cases the major version is a hard boundary:.v1never matches.v2data. -
CONTAINSexpresses structural containment: “an EHR that contains a composition that contains a blood-pressure observation”. Chains can nest several deep, and combine withAND,OR, andNOT. Folder containment follows the RM’s reference model:FOLDER f CONTAINS COMPOSITION cmatches the compositions a folder’sitemsreference, transitively over the folder’s sub-tree, andFOLDER f1 CONTAINS FOLDER f2matches strict sub-folders plus the folders of a versioned folder theitemsreference — one reference hop, so a chain of references is expressed by chainingCONTAINS. A pair the RM defines no containment relationship for (sayCOMPOSITION CONTAINS COMPOSITION) is refused with a typed error. Containment is strict: a node does not contain itself, soCLUSTER cl1 CONTAINS CLUSTER cl2binds only clusters nested inside another cluster. A variable declared inside anORbranch can be read inSELECT,WHERE, andORDER BY: a row where its branch did not match readsnullfor it, and when both branches match, each match of one pairs with each match of the other. This holds for a branch that is a versioned object of its own too:EHR e CONTAINS (COMPOSITION c1[...] OR COMPOSITION c2[...])returns the current version of each matching composition, itsc1/uid/valueincluded, and aFOLDERbranch reads the compositions the folder references. -
SELECTprojects values by path. Paths use archetype node ids (at0004) and RM attribute names (value/magnitude);ASnames a column. A path through a repeated node returns one row per node it reaches: selecting the systolic and the diastolic of a blood-pressureEVENTreturns one row per event, each pairing that event’s own two values, withnullwhere an event lacks one. Paths that share a prefix share that node in a row, and a path that reaches nothing readsnullwithout removing the row. The AQL specification does not define this row shape; it is this engine’s documented convention. -
WHEREfilters on typed leaf values, with comparisons,EXISTS,LIKE,MATCHES, and boolean combinators. The AQL specification does not say how a test behaves on a path that reaches several nodes; this engine’s rule is:- Every repeated step a
WHEREpath descends, and theSELECThas not already bound, is bound once for the wholeWHEREclause, the way aSELECTpath binds it: one node per binding, and onenullbinding when nothing matches. Steps written identically (predicate included) are one binding, soevents[at0006]andeventsare two. - A row is kept when some assignment of those bindings makes the whole
clause true, with SQL’s three-valued logic: a test of a
nullvalue is unknown, and an unknown clause does not keep the row. - A path that shares a prefix with a selected path reads the node that row is bound to.
The boolean laws therefore hold.
<systolic> >= 140 AND <diastolic> >= 90holds only for an event carrying both values, also underORandNOT, whether or not theSELECTreads the event;NOT (<systolic> > 140)holds when some event’s systolic is 140 or lower, and is unknown when the path reaches nothing;NOT (A OR B)equalsNOT A AND NOT B; adding a test withANDnever adds rows. Paths compared with each other, and paths inside a function call, read through the same bindings. Elements of a list attribute inside one node’s data (links,participations,identifiers, …) are not bindings: a test of such a path against a value holds when any element of the bound node satisfies it, and is unknown when the list is empty or absent, soNOT (c/links/target/value = 'x')drops a composition without links. Beside another path or inside a function, such a path reads its first element.EXISTS <path>asks whether the data at that path exists (the AQL specification’s own definition). It reads through theSELECTbindings, and otherwise looks at the whole path on its own, outside the clause’s bindings:EXISTSholds when any node on the path carries the data andNOT EXISTSwhen none does, whatever the other tests bind.A list attribute in a projected path is read by one of two rules, depending on what the list holds:
- Row rule: a list that holds structure nodes (
c/content,p/identities,.../items,.../events) gives one row per node.SELECT o/data[at0001]/events/time/valueover an observation with two events answers two rows, one time each. - Cell rule: a list inside one node’s data (
links,context/participations,composer/identifiers,mappings,feeder_audit/originating_system_item_ids) gives one cell. A path that ends on the list answers the list itself, and a path that continues below it answers the array of its matches. Both arenullwhere nothing matches. Over a composition with a placer and a filler order number,c/feeder_audit/originating_system_item_idsanswers[{"_type": "DV_IDENTIFIER", "id": "ORD-PLACER-1", ...}, {"_type": "DV_IDENTIFIER", "id": "ORD-FILLER-1", ...}]andc/feeder_audit/originating_system_item_ids/idanswers["ORD-PLACER-1", "ORD-FILLER-1"].
Paths through different repeated steps of one variable are bound independently, so selecting both returns every combination of their nodes.
- Every repeated step a
-
Aggregates read every data item of their path in the rows
FROMandWHEREselect:MAX(<systolic>)over an observation measured twice sees both events, andCOUNTover a list attribute counts its elements. An item counts when some assignment of theWHEREbindings, with the steps the item shares fixed to its own nodes, makes the clause true, soSELECT COUNT(<systolic>) ... WHERE NOT (<systolic> < 140)counts exactly the readings that selecting<systolic>would return. List elements inside one node are not filtered one by one:COUNT(<links path>)under aWHEREon that path counts every link of a row the clause keeps. An aggregate adds no rows, soCOUNT(*)counts the rows theWHEREclause keeps whatever other aggregates sit beside it.COUNT(DISTINCT)compares numbers by value (120and120.0are one value), a stored JSONnullcounts as no value, andAVGreturns a decimal mean. -
ORDER BY,LIMIT, andOFFSETbehave as you expect; quantities order by their openEHR magnitude semantics. -
Date/time comparisons accept reduced precision on both sides: openEHR admits partial values (
2019,1985-06), and the engine compares them by flooring to the first instant they contain (a partial date assumes the first month/day, a partial time assumes zero). A comparison value that is not an ISO 8601 date/time at all is refused with a 400.
Running a query over HTTP
The query API lives under the base path at /query/aql. The simplest form is a
POST with a JSON body:
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
-d '{"q":"SELECT e/ehr_id/value FROM EHR e"}' \
http://localhost:8080/ferroehr/rest/openehr/v1/query/aql
The body fields are:
| Field | Meaning |
|---|---|
q | The AQL text (required). |
offset | Rows to skip (default 0). |
fetch | Maximum rows to return. |
query_parameters | An object of named parameter values (see below). |
There is also a GET /query/aql form taking q, offset, fetch, an optional
ehr_id, and query_parameters as query-string parameters, convenient for
simple, cacheable reads.
offset, fetch, ehr_id and named parameters are accepted in the query string
on the POST forms too. A value supplied in both the body and the URL must
agree; a disagreement is a 400 Bad Request rather than a silent choice
between them.
The query API is JSON only (Accept: application/json).
Scoping to an EHR
You can restrict a query to one EHR without writing the constraint into the AQL:
pass an ehr_id query-string parameter, or the openehr-ehr-id request header.
Both forms work on every execution endpoint: ad-hoc and stored, GET and
POST alike. (openEHR-EHR-id is the deprecated spelling of the same header and
still resolves, HTTP header names being case-insensitive.)
If a request carries both forms they must name the same EHR; a request whose parameter and header name different EHRs is self-contradictory and is rejected with a 400 Bad Request. So is a request repeating the header with two different values. An empty header value counts as “not supplied” and conflicts with nothing.
The id must exist: a malformed id is a 400, and a well-formed id that matches no EHR is an honest 404 Not Found rather than an empty result set, so a typo cannot masquerade as “no data”.
The result set
A query returns a RESULT_SET: a description of the columns and an array of row
tuples.
{
"meta": {
"_type": "RESULTSET",
"_schema_version": "1.0.0",
"_created": "…",
"_executed_aql": "SELECT e/ehr_id/value FROM EHR e"
},
"q": "SELECT e/ehr_id/value FROM EHR e",
"columns": [
{ "name": "#0", "path": "/ehr_id/value" }
],
"rows": [
[ "7d44b88c-4199-4bad-9764-5da0e2a97441" ],
[ "b1e2c3d4-5678-90ab-cdef-1234567890ab" ]
]
}
Each entry in columns names the column (the AS alias, or #<index> when you
did not alias it) and its path. Each row in rows is an array of cells, one
per column in column order. A cell can be a scalar or a full RM object (for
example {"_type":"DV_TEXT","value":"Labs"}) depending on what you selected. A
stored-query execution additionally carries the query’s name.
The meta block’s _executed_aql field is the AQL the server actually ran, with
your named parameters substituted in as literals. Paste it straight back into an
ad-hoc query when debugging a parameterised call. The top-level q keeps the
text exactly as you submitted it, and _created stamps when this response was
produced.
Query responses carry a weak ETag that is a content digest of the result
set: two runs returning identical results carry the identical tag, so a client
can cheaply detect “nothing changed” between polls. The digest deliberately
covers the query, the executed AQL, the columns and the rows, and not the
per-response _created stamp, so an unchanged result does not mint a new tag
every second.
Parameters
Parameterise a query with named placeholders (a name preceded by a dollar sign)
and supply the values in query_parameters. This is the safe way to inject
values, with no string concatenation:
curl -u ferroehr:ferroehr -H 'Content-Type: application/json' -d '{
"q": "SELECT c FROM EHR e CONTAINS COMPOSITION c WHERE c/name/value = $name",
"query_parameters": { "name": "Vital signs" }
}' http://localhost:8080/ferroehr/rest/openehr/v1/query/aql
On the URL, each parameter can also be its own query-string key, which is the form the openEHR request documentation shows and is usually easier to build by hand:
curl -u ferroehr:ferroehr --get \
--data-urlencode 'q=SELECT c FROM EHR e CONTAINS COMPOSITION c WHERE c/name/value = $name' \
--data-urlencode 'name=Vital signs' \
http://localhost:8080/ferroehr/rest/openehr/v1/query/aql
Four details about the URL form: the leading $ is optional ($name and name
bind the same parameter); a value in double quotes binds the JSON string it
denotes, so uid="123" binds the text 123, as {"uid": "123"} does in a
POST body (a value that only starts with a quote keeps its text); any other
value that parses as a JSON scalar binds as that type (36 as a number, true
as a boolean) while everything else binds as text; and the reserved keys q, offset, fetch, ehr_id and query_parameters are
request controls, never parameters. Where the same name appears both as its own
key and inside query_parameters, the named key wins.
Stored queries
You can register a query once, under a qualified name and version, and execute it by name later. Storing is done through the definition API with the AQL as a plain-text body; executing is done through the query API.
# Store a query as org.example::bp_over, version 1.0.0
curl -u ferroehr:ferroehr -X PUT -i \
-H 'Content-Type: text/plain' \
--data-binary 'SELECT o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude FROM EHR e CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]' \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/query/org.example::bp_over/1.0.0
# List and fetch stored queries
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/query/org.example::bp_over
# Execute it
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/query/org.example::bp_over/1.0.0
Storing. Both store forms answer 200 OK with an empty body and a
Location header naming exactly the version that was written:
PUT /definition/query/{name}/{version}stores at that exact SemVer. The pair is immutable (re-storing an existing(name, version)is a 409 Conflict, never an overwrite) and a partial or malformed version segment is a 400.PUT /definition/query/{name}(no version) stores or updates at the default version1.0.0.
The body must be text/plain: declaring another media type is a 415, and an
absent Content-Type reads as the plain-text body. The AQL is parsed at store
time, so a syntactically invalid query is rejected 400; the server never
stores a query it cannot execute. An optional query_type parameter names the
formalism, default AQL (case-insensitive); anything else is rejected 400
with an honest “unsupported formalism” message rather than a misleading “invalid
AQL”.
Naming. The qualified name is [{namespace}::]{query-name}, and the
three-part {namespace}::{formalism}::{query-name} form is recognised as well.
The namespace is optional: a bare name is stored under the assumed namespace
misc, so my_compositions and misc::my_compositions are the same query and
listings show the qualified form. Identity is case-insensitive while the casing
you stored is preserved. The query-name aql is reserved (case-insensitive; it would collide with
the ad-hoc /query/aql route) and is rejected with a
400.
Reading and executing.
GET /definition/query/{name}lists the queries under that name as a prefix pattern;GET /definition/query(no name at all) lists every stored query, a FerroEHR convenience, since the openEHR API defines only the named form.GET /definition/query/{name}/{version}fetches one, by exact SemVer or by prefix (1,1.0→ the highest matching stored version).GET|POST /query/{name}executes the latest version;GET|POST /query/{name}/{version}executes that version, again by exact SemVer or prefix. Both take the sameoffset,fetch,ehr_idand named parameters as ad-hoc queries; aPOSTbody of{}executes a parameterless stored query.
Deleting a stored query is an admin operation; see Admin & messaging APIs.
Cohort queries across the pseudonymisation boundary
Some questions are about a population rather than a patient: the blood pressures of everyone in a city, the medication history of an age band, the readmissions of the people one organisation cares for. Answering them normally means someone producing a list of patient identifiers first, which is exactly the disclosure the split between clinical and demographic storage exists to avoid.
POST /ferroehr/rest/extensions/v1/query/cohort answers them without producing that list. It is a FerroEHR
extension — no openEHR spec governs it: AQL is defined over the clinical domain
and has no demographic source, and ITS-REST publishes an ad-hoc and a stored
query and nothing else.
How it runs. Three steps, each on its own database credential:
- A predicate from the deployment’s allow-list selects parties in the demographic domain. It reads one archetype leaf and returns identifiers, nothing else — no name, address or national identifier leaves that domain.
- The linkage domain resolves those parties to the EHRs they are the subject of. Identifiers in, identifiers out.
- Your AQL runs on the clinical pool, scoped to exactly those EHRs.
The three statements never meet. With [storage.party] url and [storage.linkage] url
set, each pool authenticates as a role revoked from the other two, and the
server refuses to boot if one can read across. You never receive a party
identifier: the response carries what your AQL projected, plus counts.
The allow-list. A cohort predicate can only name something the deployment
declared, under [cohort.predicates] — the surface is off until at least one is
bound, and the route answers 404 until then. Five keys are bindable:
city, postcode_area, sex, age_band and organisation. Each binding
names the archetype of the leaf’s nearest archetyped ancestor, the leaf’s
at-code, and how a value is matched. See
Privacy & data minimisation for the
keys and their defaults.
Naming subjects by identifier. With [cohort] subjects = true a request
may carry subjects, a list of { "id": …, "namespace": … } identifiers in
any identifier domain (an optional type defaults to PERSON). Each one is
resolved to its EHRs through the [ehr_index] provider, and the result
intersects with what cohort selects. Under the local map the map answers.
Under an IHE patient identity source FerroEHR asks the PIX Manager to translate
the identifier forward into its own clinical domain (ITI-83 with
targetSystem set to that domain) and finds the EHR whose EHR_STATUS
carries that pseudonym. A subject no EHR belongs to adds nothing. A subject
that cannot be resolved, because the identity source fails or no provider is
installed, fails the whole query rather than shrinking the cohort.
Under an IHE patient identity source, cohorts name their subjects. That
provider keeps no party-to-EHR map, and FerroEHR never asks it to translate a
pseudonym back into another domain (openEHR RM EHR_STATUS.subject; GDPR
Art. 4(5) and Art. 25). A demographic predicate would need one or the other,
so a request with a non-empty cohort is refused 400 there, before any
domain or the PIX Manager is asked. The cohort digest takes each subject by
its namespace alone, so the access record carries no identifier value.
Running one.
curl -u ferroehr:ferroehr -X POST \
-H 'Content-Type: application/json' \
-d '{
"q": "SELECT c/uid/value FROM EHR e CONTAINS COMPOSITION c",
"cohort": [{ "name": "city", "value": "Groningen" }],
"purpose": "secondary-use",
"fetch": 100
}' \
http://localhost:8080/ferroehr/rest/extensions/v1/query/cohort
q, offset, fetch and query_parameters mean what they mean on the ad-hoc
body. There is no ehr_id: the cohort is the scope, and supplying one is a
400. Several cohort entries intersect — city and sex, not city or
sex.
The response is an ordinary RESULT_SET with one extra block:
{
"meta": {
"_type": "RESULTSET",
"cohort": {
"size": 412,
"served_ehrs": 388,
"suppressed": false,
"definition": "9f2c…"
}
},
"rows": [["8849fd1e-…::local::1"]]
}
size is how many EHRs the cohort resolved to, served_ehrs how many the rows
actually came from, and definition a SHA-256 digest of the predicate list —
the cohort’s identity, carrying none of its values.
Small-cell suppression. A result set narrow enough to name one person
re-identifies that person out of content you hold no individual entitlement to.
When fewer EHRs are served than cohort.small_cell_threshold (default 5), the
rows are withheld and suppressed is true. The response says so rather than
looking like an empty cohort: a caller who cannot tell the two apart will keep
widening the predicate until the boundary leaks. Set the threshold to 0 to
disable suppression.
The access record. Every execution — suppressed and failed ones included —
records one linkage-domain access event naming the cohort by its digest, the
purpose you declared, and how many EHRs were served. A crossing of this boundary
that nobody can reconstruct afterwards is what the access log exists to prevent.
The predicate values are never recorded: they are the cohort, and a trail
carrying them would be a second copy of it.
Limits. A predicate matching more than cohort.max_cohort_size parties
(default 100000) is refused 422 rather than truncated — a silently shortened
cohort is a wrong denominator with nothing on the wire to say so. More named
subjects than that ceiling are refused the same way. An unbound predicate
name, a malformed age band (the form is <min>-<max> whole years, both
inclusive), a predicate under an IHE identity source, or a request with
neither a predicate nor a subject is a 400. An empty
cohort runs no AQL and answers an empty result set.
Measured. The committed record docs/benchmarks/cohort/record.json
in the repository (produced by the ignored cohort_bench test, not by the
conformance instrument) measures the whole call, predicate to result set, over
a corpus of 100 000 parties, each the subject of one EHR holding one
composition, seeded through the service API, with the database in a local
container. Selecting one composition path per EHR:
| Cohort | p50 | p95 | Demographic statement | Clinical statement |
|---|---|---|---|---|
| 100 EHRs | 71 ms | 116 ms | 54 ms | 1 ms |
| 1 000 EHRs | 102 ms | 145 ms | 51 ms | 11 ms |
| 10 000 EHRs | 543 ms | 820 ms | 64 ms | 325 ms |
| 100 000 EHRs | 2.6 s | 2.9 s | 260 ms | 702 ms |
Measured over 100 000 parties, 100 000 EHRs and 100 000 compositions at commit 54495c274ebf on a consumer-laptop (8 cores, 16 GB, nvme); the two statement columns are EXPLAIN (ANALYZE) times.
The difference between the statement times and the wall clock is result
assembly and the access records. Both statements run on indexes (idx_dem_node_archetype on the demographic side, the current-version
and EHR indexes on the clinical side); no sequential scan appears in any of the
recorded plans. The record names the commit it was measured at; a re-run
replaces it rather than appending.
Queries rooted at a demographic class
The Demographic API reads a party by its identifier and has no search. To find
a person, an organisation or a role by what it holds, such as an identifier in
its details, FerroEHR serves a subset of the experimental AQL demographic
extension on the same POST /query/aql and GET /query/aql routes. No openEHR
specification governs it: QUERY 1.1 lets FROM name any Reference Model class
but gives semantics only to the clinical repository, so everything below is
FerroEHR’s own design, and the extension may change.
SELECT p/uid/value
FROM PERSON p
WHERE p/details[at0001]/items[at0002]/value/id = $identifier
ORDER BY p/uid/value
The grammar served. The query is ordinary AQL 1.1 syntax with these rules on
the FROM clause:
- The outermost class is
PERSON,ORGANISATIONorROLE, optionally under aVERSIONclass. It is the only demographic class at the root of the query. - Below it,
CONTAINSmay name the content classes a party holds:CLUSTER,ELEMENT, the item structures, and the party containersPARTY_IDENTITY,CONTACT,ADDRESSandCAPABILITY, with archetype and node predicates as usual, for exampleCONTAINS CLUSTER c[openEHR-DEMOGRAPHIC-CLUSTER.person_identifier.v2]. SELECT,WHEREandORDER BYtake paths inside the party: itsdetailstree, itsidentities,contactsandcapabilities,p/uid/value, and the whole party (SELECT p). Named parameters,LIMIT/OFFSET, the RESToffsetandfetch, and the functions and aggregates of the clinical envelope apply unchanged.
What a row is. The query reads the latest trunk version of each party. A
logically deleted party, and a party under a restriction of processing, answers
no row. p/uid/value is the version identifier the Demographic API returns.
What is refused. Each of these is a 400 Bad Request naming the construct, so you can tell a refusal from an empty result:
GROUP,AGENT,PARTY_RELATIONSHIP, and the abstractPARTYandACTOR;- a demographic class anywhere but the root, or a second one;
- a query that names
EHRor a clinical versioned object such asCOMPOSITIONanywhere but directly under the root (see the join below); - an
ehr_idscope (query parameter oropenehr-ehr-idheader), because a party is in no EHR.
Joining a party to its EHR. An EHR operand directly under the root
reaches the EHR the party is the subject of:
SELECT p/uid/value, c/name/value
FROM PERSON p CONTAINS EHR e CONTAINS COMPOSITION c[openEHR-EHR-COMPOSITION.encounter.v1]
WHERE p/details[at0001]/items[at0002]/value/id = $identifier
ORDER BY c/context/start_time/value DESC
A party is the subject of an EHR when either of these holds:
- the EHR’s current
EHR_STATUS.subjectis aPARTY_SELFwhoseexternal_refnames the party: itsid.valueis the party’s uid without::<system>::<version>, and itstypeis the root class or one of its abstract RM ancestors (ACTORforPERSONandORGANISATION,PARTYfor all three). This is the referencePOST /ehrstores, so an EHR created with that subject joins with no other call; - the linkage service’s party-to-EHR map holds the pair.
An EHR that both name answers once. The namespace of the reference is not
compared: BASE OBJECT_REF.namespace takes any name a deployment chooses
(local, demographic, or your own), and the id and the class already name
one party. A deployment that pseudonymises the subject stores a pseudonym in
id.value, and a pseudonym is not a party uid, so its EHRs join only through
the map.
Party classes may sit beside the EHR in an AND
(PERSON p CONTAINS (CLUSTER id[…] AND EHR e CONTAINS COMPOSITION c)). A row
is a party row together with a clinical row of an EHR that party is the subject
of, so a party with no EHR, and an EHR whose subject has no party, answer no
row. FerroEHR runs the query as four statements, each on one database
credential: the party half, the map, the EHRs whose subject names
those parties, and the clinical half scoped to the EHRs the two reads name. It
then joins, orders and pages the rows. The rules that follow:
- each column, each
WHEREcondition joined byAND, and eachORDER BYkey reads one side only, the party or the EHR; a condition comparing the two, or anORacross them, is a 400; - aggregates and
SELECT DISTINCTare a 400; - each half, and the joined rows, may hold at most
query.max_result_rowsrows; above that the query is a 400 asking you to narrow it, never a shortened answer; - the clinical half carries your per-EHR access decision and patient scope as any query does, and the party read and the crossing to the EHRs each leave an access record in their own domain.
Who may run it. The query runs under the authorisation of a Demographic API
read: the role check that admits a party read admits the query, and the per-EHR
access decision and the attribute-based patient scope, which govern EHR
content, do not apply to parties. The SMART aql-… scopes that govern every
query still apply. The query runs on the demographic database credential, and
its access record is filed under the demographic domain with the number of rows
served.
Version scope: LATEST_VERSION and ALL_VERSIONS
By default a query sees the latest version of each object. FerroEHR also
supports querying the entire version history, a capability many CDRs lack.
Wrap a source in VERSION and choose the scope:
SELECT v/commit_audit/time_committed, c/name/value
FROM EHR e
CONTAINS VERSION v[ALL_VERSIONS]
CONTAINS COMPOSITION c
LATEST_VERSION (the default) reads only current trunk versions;
ALL_VERSIONS reads across history (including branch versions, where a version
tree has any) so you can see how a record changed over time. A version
predicate on commit_audit/time_committed reads the trunk version current at
that instant. The VERSION variable also exposes commit metadata: the audit, the
committed time, and the version uid.
A VERSIONED_OBJECT operand between the EHR and the version names the
versioned object itself. Its variable answers uid/value, the object id every
version of it shares, and owner_id/id/value, the EHR that owns it; any other
path on it is refused. It sets no version scope of its own: the VERSION
below it does.
SELECT vo/uid/value, v/uid/value, c/name/value
FROM EHR e
CONTAINS VERSIONED_OBJECT vo
CONTAINS VERSION v[ALL_VERSIONS]
CONTAINS COMPOSITION c
Terminology in queries
Value filters can be backed by terminology in three ways:
TERMINOLOGY('expand', …)as (or inside) amatchesoperand expands a value set so a coded field matches any code in it, rather than listing codes by hand;TERMINOLOGY('validate'|'subsumes', …) = trueas a boolean condition evaluates a code-membership or subsumption test once per query;- a terminology URI operand (
matches { terminology://… }) expands the set the URI identifies.
These require a terminology source; if no terminology server is configured, the in-process openEHR bundle is used. See Terminology servers for wiring the FHIR terminology server.
Two rejections to expect here: only expand may stand as a matches operand
(the other operations have no value-list meaning), and only validate and
subsumes have boolean semantics, so TERMINOLOGY('lookup', …) = true is
refused rather than guessed at. A value set the configured server does not know
is a 400 naming it; a terminology server that fails mid-query is a 500,
kept distinct from a bad query on purpose.
Pagination and limits
Combine LIMIT/OFFSET in the AQL with the fetch/offset request parameters
to page through large result sets. Three bounds interact, and it is worth knowing
which one you hit:
| Bound | Where it comes from | Effect |
|---|---|---|
LIMIT/OFFSET, or fetch/offset | your query or request | Exactly the window you asked for. |
query.max_result_rows | server config, default 10000 | The largest page one execution serves: the page of a query nothing else bounds, and the maximum a LIMIT or fetch may ask for. 0 means unbounded. |
query.timeout_ms | server config, default 30000 | Per-query database execution budget. On by default; 0 disables it. |
Two bounds on the shape of the query text are fixed in the server. Brackets
and CONTAINS may nest at most 64 levels, and a WHERE expression may be at
most 512 levels deep, where each AND, OR, NOT, CONTAINS and bracket on
a path counts one level. A query beyond either returns 400 Bad Request
naming the bound, and a stored query beyond either is refused when you store
it. AND and OR chain one level per operator, so a long list of
alternatives over one path belongs in MATCHES {…}, which the bound does not
count:
WHERE c/name/value MATCHES {'Blood pressure', 'Pulse', 'Body weight'}
A query that exceeds the time budget returns 408 Request Timeout; narrow it
(add archetype constraints, an ehr_id scope, or a WHERE filter) rather than
retrying it unchanged. A LIMIT or fetch larger than the row ceiling returns
400 Bad Request naming the ceiling; the page is never silently shortened,
because a client paging with its own fetch as the stride would skip rows
without noticing. Page with offset and a fetch at or below the ceiling. A
result that stops at the ceiling without either bound set is the default page:
page explicitly to read past it.
Always order a query you intend to page
OFFSET skips rows of a result, and without ORDER BY a result has no defined
row order. Two requests for consecutive pages can then repeat a row or miss one
entirely, and nothing in the response says so. Give any query you page an
ORDER BY on something unique, such as c/uid/value.
Ordering also changes what paging costs, in the direction that helps you. An
ordered query sorts the whole matched set to answer any page, so the first page
already pays most of the work and a deep page costs little more than a shallow
one. An unordered query skips rows one at a time, so its cost grows with the
offset, from almost nothing on page one. Either way what dominates is how much
your FROM/CONTAINS matched, not how deep you paged.
One more reason to order a paged query, if the deployment runs attribute-based authorization: the authorization decision is made over every EHR and template the query would touch, not over the page it served, so that set is collected whatever the page size. An ordered query pays nothing extra for it, because it was producing the whole matched set anyway. An unordered page that looks cheap carries the whole collection on top of it. Narrow the query, and order it.
Tip
The more specific your
FROM/CONTAINS(name the archetype, scope byehr_id), the faster the query: those constraints map to indexed columns, while broad “everything that contains anything” queries do the most work. Repeated identical query text reuses a cached plan, so parameterising a recurring query beats rewriting its literals.
What is supported
FerroEHR implements the core AQL 1.1 envelope and rejects out-of-envelope constructs with an explicit, typed error rather than silently returning wrong results. Supported today includes:
SELECTof paths, literals, aliases,DISTINCT, and the aggregatesCOUNT(includingCOUNT(DISTINCT)),MIN,MAX,SUM,AVG.MINandMAXorder their operand by type (a quantity by its openEHR magnitude, a date/time chronologically, text lexically) so they work over non-numeric leaves;FROMoverEHR,VERSIONED_OBJECT(itsuidandowner_id),VERSION(LATEST_VERSION/ALL_VERSIONS), and the RM structure classes, with archetype and name predicates, and overPERSON,ORGANISATIONandROLEas the experimental demographic extension;CONTAINStrees withAND,OR, andNOT CONTAINS;WHEREcomparisons on typed leaves (with openEHR magnitude ordering for quantities),EXISTS,LIKE,MATCHESvalue lists, and range predicates;ORDER BYtyped leaves,LIMIT/OFFSET,TOP n, named query parameters, and theehr_id,offset, andfetchrequest parameters;- the single-row functions:
LENGTH,SUBSTRING,POSITION, the stringCONTAINS,CONCAT/CONCAT_WS,ABS,MOD,CEIL,FLOOR,ROUND, andCURRENT_DATE/CURRENT_TIME/CURRENT_DATE_TIME/NOW/CURRENT_TIMEZONE; - terminology-backed
TERMINOLOGY()operands and terminology-URImatchesoperands (see above).
What is refused, and why
Every refusal below is a typed error naming the construct, so you never get a silently incorrect answer:
- Demographic forms outside the served subset.
GROUP,AGENT,PARTY_RELATIONSHIP,PARTYandACTOR, a demographic class below the root, and a query mixing a demographic root withEHRor clinical content (see Queries rooted at a demographic class). TOP … BACKWARD.TOPis deprecated as of AQL 1.1.0 and the direction variant is not implemented; the error carries the specification’s own rewrite (ORDER BY <path> DESC LIMIT n).TOPandLIMITin one query are also refused; pick one.- Regex and
ORnode predicates.[{/…/}]is archetype-definition syntax, not AQL value matching, and a disjunctive node predicate is outside the accepted subset. - Branch version addressing. A version predicate may address trunk versions;
reading branch content is done by scoping
ALL_VERSIONS, not by naming a branch. - Reading a variable declared under
NOT CONTAINS.NOT CONTAINSstates that no such node exists, so a path through its variable has nothing to read. SELECT DISTINCTordered by an unselected expression. De-duplication and sorting by something the projection dropped have no defined meaning together; sort by a selected column.- Analysis failures, which are precise: an unknown RM
class or attribute, an unbound
$parameter, a duplicateFROMvariable name,LIMIT 0, a negativeOFFSET, wrong function arity,SUM/AVGover a non-numeric path, and anarchetype_node_idcriterion that is neither an archetype identifier nor a node code. Variable names are case-insensitive, as the specification requires.
The specification generation is an acceptance boundary
Which Reference Model classes and attributes a query may name depends on the
deployment’s spec_profile. On the
default development profile the query surface is the full RM 1.2.0 model. On
stable (RM 1.1.0), a FROM class or a path attribute that only a newer
generation defines is refused with 400 Bad Request, and the message names
both the offending class or attribute and the active profile. A server of that
generation would answer “unknown”, so returning rows instead would silently
overclaim the profile the deployment advertises.
Nothing else about querying changes with the profile: paths, predicates, and the result-set shape are identical.
Templates & validation
A template is what tells FerroEHR what clinical data to accept. Before you can commit a composition, you upload the Operational Template (OPT) it conforms to; from that template the server derives everything it needs to validate incoming data and to describe the data’s shape to client applications. This chapter covers uploading and retrieving templates in both ADL generations, the derived WebTemplate, the convenience FLAT and STRUCTURED composition formats, and how validation behaves on commit. If templates and archetypes are new to you, read the openEHR primer first.
- Uploading an OPT 1.4 template
- Uploading ADL 2 artefacts
- Listing templates
- Deleting a template
- Archetype sources (a FerroEHR extension)
- The WebTemplate
- Composition formats
- Validation on commit
- Next
Uploading an OPT 1.4 template
FerroEHR ingests templates in the OPT 1.4 XML format. Upload one with
Content-Type: application/xml:
curl -u ferroehr:ferroehr -i \
-H 'Content-Type: application/xml' \
--data-binary @vital_signs.opt \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4
The upload takes OPT XML and nothing else: a request declaring another payload
type (Content-Type: application/json, say) is refused with 415 Unsupported
Media Type before the template is parsed. text/xml works the same as
application/xml, and omitting the header altogether is fine; the endpoint has
only one body format.
A successful upload returns 201 Created with the id in ETag and
Location. Add Prefer: return=identifier when you only need the id back; the
response body is then the JSON identifier object:
{ "template_id": "vital_signs" }
(return=representation returns the stored OPT XML; the default is an empty
body.) Uploading a template whose id already exists returns 409 Conflict;
templates are immutable once loaded. Template ids are compared
case-insensitively (the stored casing is preserved), so uploading a
case-variant of an existing id (Vital_Signs against a stored vital_signs)
is also a 409 Conflict, not a second template.
What “invalid template” means, and which status you get
The upload runs two distinct gates, and they answer differently:
- A payload that is not well-formed XML is a 400 Bad Request: the server could not read it as XML at all.
- A well-formed document that is not a valid operational template is a 422
Unprocessable Entity. This covers both the structural check (foreign or
duplicated top-level elements, a decoded OPT with an empty
template_idorconcept) and the full archetype-model artefact-validity catalogue: reference-model conformance of every constrained type and attribute, occurrence/cardinality consistency, terminology code definedness and language consistency, archetype-identifier well-formedness, and constraint pattern validity (temporal and duration patterns, boolean satisfiability, assumed values inside their own constraints). The response body lists the offending archetype-model rule codes (VCARM,VATID,VCORM,Pattern_validity, …) in the problem’serrors, each with a human-readabledetail; a request withPrefer: return=representationreceives them (Problem types).
The 422 gate also validates the template’s own meta-data against the RM
resource-package invariants: the template’s language (and every description
detail’s) must be in the openEHR languages code set, is_controlled must
agree with the presence of a revision_history, and a description needs a
non-empty original_author, lifecycle_state, and at least one detail with a
non-empty purpose, with details keyed by distinct languages. Those refusals
carry the RM invariant name (for example
RESOURCE_DESCRIPTION.Lifecycle_state_valid) in errors. ADL 1.4
archetype source uploads enforce the same family; there, an empty
purpose/use/misuse string is reported as a named warning rather than
refused, because the empty string is how real-world 1.4 authoring spells
absence.
A refusal lists everything it found, not the first thing. errors
carries one entry per violation, so a template with an empty use and an empty
misuse reports both, and a code list with several duplicated codes names each
one. Fix the list, upload once. Two things are worth knowing about the shape of
that list:
- A constrained type the reference model does not have, or an attribute its parent type does not declare, makes every rule below it meaningless. Those violations say that the tree under them went unchecked, so a short list is never a claim that nothing else is wrong. Fix them and upload again.
- A template with more than 200 violations reports the first 200 and a final
TRUNCATEDentry saying so.
What an OPT 1.4 export must carry
One requirement of the OPT 1.4 XML format catches exported templates. Inside a
C_DV_ORDINAL, every list entry is a DV_ORDINAL whose symbol is a
DV_CODED_TEXT, and a DV_CODED_TEXT carries a <value> element beside its
defining_code. The element may be empty, <value/>: the rubric a user sees is
resolved from the template’s term_definitions, not from this text. CKM’s
exporter writes the empty element, so its templates load. Archetype Designer
omits it, and such a template is refused with 422, naming the <symbol>
element, its line, and the fix. Add <value/> to each ordinal symbol and upload
again.
Whether the schema should require the element on an ordinal symbol is a question for openEHR, tracked at https://github.com/FerroHEALTH/FerroEHR/issues/3401.
Uploading ADL 2 artefacts
ADL 2 artefacts (archetypes, templates and operational templates) are accepted
as text/plain source on …/definition/template/adl2 and validated by the full
ADL 2 engine: the source is parsed, then checked against the AOM2 validity
catalogue (phase-1 basic integrity, reference-model conformance, and, for a
specialised artefact whose parent is already loaded, specialisation
conformance).
The two failure modes are again distinguished by status:
- A source that does not parse is a 400 Bad Request, carrying the ADL syntax
(
S-prefixed) error codes. - A source that parses but fails validation is a 422 Unprocessable Entity,
whose
errorslist the AOM2 validity (V-prefixed) rule codes:VARD,VCORM,VACSDand friends.
A duplicate artefact id returns 409 Conflict.
A loaded ADL 2 template is retrieved in either of two representations, chosen by
Accept: the stored ADL 2 source (text/plain, the default) or the
operational template as JSON (application/json). Accept: application/xml
has no declared body here and is a 406. A partial template_id resolves to
the latest matching version, and a separate route addresses a version
explicitly:
# The ADL 2 source, verbatim
curl -u ferroehr:ferroehr -H 'Accept: text/plain' \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl2/openEHR-EHR-COMPOSITION.t_vitals.v1.0.0
# The operational template as JSON, latest version matching the partial id
curl -u ferroehr:ferroehr -H 'Accept: application/json' \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl2/openEHR-EHR-COMPOSITION.t_vitals.v1
# Resolved inside one SemVer version (exact, or a `{major}` / `{major}.{minor}` prefix)
curl -u ferroehr:ferroehr -H 'Accept: text/plain' \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl2/openEHR-EHR-COMPOSITION.t_vitals/1.0
Note
The Basic-authenticated examples on this page run against the quickstart, whose user holds
user/template-*.cruds. The template family is scope-governed on every deployment: a caller, Basic or bearer, whose scopes grant notemplate-access answers403. See SMART App Launch.
Listing templates
# List all templates
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4
# Filter and page the list
curl -u ferroehr:ferroehr \
'http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4?template_id=vital*&offset=0&fetch=20'
# Get the canonical OPT XML for one template
curl -u ferroehr:ferroehr -H 'Accept: application/xml' \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4/vital_signs
Both list endpoints (ADL 1.4 and ADL 2) accept the same three filters
(template_id, concept, and version, each a glob pattern with * wildcards)
plus offset (rows to skip, default 0) and fetch (maximum rows; absent or 0 =
all). The filters AND together. An offset past the end of the match set is an
empty list, not an error.
Note
When the
versionparameter is absent, the list collapses to the latest version of each template (the highest.vNaxis of itstemplate_id). Passversion=*to list every stored version, or a partial glob (version=*.v1*) to select specific ones.
Deleting a template
Deletes are deliberately not part of the openEHR definition API, and FerroEHR keeps them off the provisioning routes:
- an OPT 1.4 template is removed with
DELETE /ferroehr/rest/extensions/v1/admin/template/{template_id}, behind the admin gate (see Admin & messaging APIs); - an ADL 2 artefact is removed with
DELETE /ferroehr/rest/extensions/v1/definition/artefact/adl2/{artefact_id}.
Both refuse with 409 Conflict while any committed version still references the template, and the message names how many versions still hold the reference. A physical delete can never orphan committed clinical data. The count spans archived content too, so an archived composition’s template cannot be deleted out from under it. Delete the compositions first.
Archetype sources (a FerroEHR extension)
Beyond operational templates, FerroEHR can also hold the source archetypes
they were built from, so a deployment can keep its definition material in one
place. ITS-REST defines no archetype routes, so they sit under the extensions
root, /ferroehr/rest/extensions/v1, beside the openEHR API, and every route
below is relative to it:
| Route | What it does |
|---|---|
| `GET | POST /definition/archetype/adl1.4` |
| `GET | DELETE /definition/archetype/adl1.4/{archetype_id}` |
GET /definition/archetype/adl2, …/adl2/count | List the stored ADL 2 archetypes, and count them |
GET /definition/artefact/adl2, …/adl2/count | List and count all stored ADL 2 artefacts (archetypes, templates, OPTs) |
DELETE /definition/artefact/adl2/{artefact_id} | Remove one ADL 2 artefact |
Properties worth knowing before you build on these:
- They are our own design. The openEHR REST API provisions operational templates only: it declares no archetype resource, no count, and no delete. These routes realize the openEHR service model’s archetype operations, which the released wire never surfaced, so they are excluded from openEHR wire conformance and never gate a conformance profile tier.
- The archetype id is the client’s, never assigned. An ADL 1.4 upload reads
it out of the source’s own
archetypeheader, and a re-upload of the same id replaces it (there is no conflict branch: the service operation is replace-or-create). Success is 201 with the id in the body and aLocation. - Ids match case-insensitively, and a malformed id on a read is simply a 404; nothing is stored under it.
- Writes respect the read-only role. A principal carrying the configured read-only role is refused 403 before the store is touched. Uploads that do not parse or fail the phase-1 validity catalogue are 422.
The WebTemplate
The WebTemplate is a JSON description of a template that is far easier for application code to consume than raw OPT XML: it lists every field with its path, type, cardinality, allowed values, and labels, which is exactly what you need to render a form or map data. Request it with the WebTemplate media type:
curl -u ferroehr:ferroehr \
-H 'Accept: application/openehr.wt+json' \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4/vital_signs
The WebTemplate is part of openEHR’s own Simplified Formats
sub-specification of ITS-REST 1.1.0, and FerroEHR emits its metadata format
version 2.3 as that specification defines it, so tooling written against the
standard model works unchanged.
Accept: application/json on the same URL returns that identical WebTemplate
document (it is the only JSON representation of a template) but labelled
Content-Type: application/json, the type you asked for. Use
Accept: application/xml for the canonical OPT instead.
You can also fetch an example composition for a template (a skeleton
instance you can fill in) from
GET /definition/template/adl1.4/{template_id}/example. The same endpoint exists
for ADL 2 templates at GET /definition/template/adl2/{template_id}/example: the
stored operational template is turned into a WebTemplate and walked into an
example composition. Both serve any of canonical JSON/XML, FLAT, or STRUCTURED
(via Accept) and take the same two query parameters: type
(input/output) and detail_level (required/medium/complete).
The example carries every attribute and node the template makes mandatory, so it passes the server’s own validation. A template whose mandatory content only an archetype slot admits cannot be filled by any example.
A demographic template (one rooted at PERSON, ORGANISATION, GROUP,
AGENT or ROLE) gets an example party of that class instead, in canonical
JSON or XML. An example ROLE carries a placeholder performer, which the
reference model makes mandatory. The Simplified Formats map no party class,
so Accept: application/openehr.wt.flat+json or …structured+json on a party
example answers 406 Not Acceptable.
A template whose root is neither a COMPOSITION nor a party class (an
OBSERVATION or a SECTION, for example) has no example: the endpoint answers
400 Bad Request naming the root, because the example the specification
defines is an instance of a versioned-object class such as COMPOSITION,
which such a template does not describe.
Composition formats
When committing or retrieving a composition, the canonical openEHR JSON (or XML) is always available, but two flatter formats are offered for convenience, keyed to a template:
-
FLAT (simSDT):
application/openehr.wt.flat+json. The whole composition as a single flat map ofpath|attribute → value, which is compact and easy to produce from a form. For example:{ "vital_signs/blood_pressure/any_event:0/systolic|magnitude": 120, "vital_signs/blood_pressure/any_event:0/systolic|unit": "mm[Hg]", "vital_signs/blood_pressure/any_event:0/diastolic|magnitude": 80, "vital_signs/blood_pressure/any_event:0/diastolic|unit": "mm[Hg]", "vital_signs/language|code": "en", "vital_signs/language|terminology": "ISO_639-1" } -
STRUCTURED (structSDT):
application/openehr.wt.structured+json. The same data as a nested JSON tree that mirrors the template structure, rather than a flat map.
Send the matching Content-Type when committing, or the matching Accept when
retrieving, and the server converts between the flat/structured form and the
canonical composition. Both are defined by openEHR’s Simplified Formats
sub-specification of ITS-REST 1.1.0. That specification text is the only
authority the server follows here, and there is no vendor-compatibility mode. The
canonical JSON and XML remain the full-fidelity wire format. They work against
both ADL 1.4 and ADL 2 templates: a FLAT or STRUCTURED commit keyed to an
ADL 2-registered template resolves and is validated against that template’s
archetype constraints exactly as an ADL 1.4 commit is.
Note
The FLAT and STRUCTURED formats are always relative to a template: the paths are template paths, and the target template comes from the
openehr-template-idrequest header, without which a commit is a 400. Use them for form-driven capture; use canonical JSON/XML for full-fidelity exchange and archival.
Optional RM attributes (_-prefixed keys)
Beyond the template’s own fields, FLAT and STRUCTURED carry the optional
reference-model attributes as _-prefixed path segments, round-tripping in both
directions. Indexed families take an :n suffix; sub-fields ride the usual
|attribute pipes:
{
"vital_signs/blood_pressure/_uid": "9fcc1c70-…",
"vital_signs/blood_pressure/_link:0|type": "problem",
"vital_signs/blood_pressure/_link:0|target": "ehr://…",
"vital_signs/blood_pressure/any_event:0/systolic/_null_flavour|code": "253",
"vital_signs/blood_pressure/any_event:0/systolic/_normal_range/lower|magnitude": 90,
"vital_signs/blood_pressure/_other_participation:0|function": "witness",
"vital_signs/blood_pressure/_other_participation:0|name": "Dr. Marcus Johnson"
}
Which families apply depends on the node they hang off:
| Host | Families |
|---|---|
| Any locatable node | _uid, _link:n, _feeder_audit |
| Any entry (observation, evaluation, instruction, action, admin entry) | _work_flow_id, _guideline_id, _provider, _other_participation:n |
| An instruction | _expiry_time, _wf_definition |
| An action | _instruction_details, and _reason:n on its state transition |
| An element | _null_flavour, _null_reason |
| A data value | _mapping:n, _normal_range, _other_reference_ranges:n, _accuracy, _language, _encoding, _charset, _thumbnail |
| Any party reference (a provider, a participation, the subject) | _identifier:n |
| The composition’s event context | _health_care_facility, _participation:n |
An ACTION’s _instruction_details carries exactly three suffixes on the field
itself: the instruction’s path within its composition, that composition’s uid,
and the activity id:
{
"encounter/procedure/_instruction_details|path": "/content[openEHR-EHR-INSTRUCTION.request.v1]",
"encounter/procedure/_instruction_details|composition_uid": "4cdc3017-d8c5-4cd3-9900-f3bb7171d006",
"encounter/procedure/_instruction_details|activity_id": "activities[at0001]"
}
An interval event additionally carries |sample_count (the number of samples the
interval summarises) alongside its /width and /math_function fields.
An element that records why a value is missing carries _null_flavour (and
optionally _null_reason) instead of a value (the reference model makes the
two mutually exclusive), and both directions preserve it:
{
"vital_signs/blood_pressure/any_event:0/systolic/_null_flavour|code": "253",
"vital_signs/blood_pressure/any_event:0/systolic/_null_flavour|value": "unknown",
"vital_signs/blood_pressure/any_event:0/systolic/_null_flavour|terminology": "openehr",
"vital_signs/blood_pressure/any_event:0/systolic/_null_reason": "not asked"
}
Who the entry is about: subject
Every entry (observation, evaluation, instruction, action, admin entry) records a subject. It defaults to the owner of the EHR, and while it stays the default it does not appear on the wire at all. When an entry is about someone else (a relative, a donor, a fetus) spell it out with the party suffixes:
{
"family_history/family_history/subject|name": "Susan Doe",
"family_history/family_history/subject|id": "199",
"family_history/family_history/subject|id_scheme": "HOSPITAL-NS",
"family_history/family_history/subject|id_namespace": "HOSPITAL-NS",
"family_history/family_history/subject/relationship|code": "10",
"family_history/family_history/subject/relationship|value": "mother",
"family_history/family_history/subject/relationship|terminology": "openehr"
}
A /relationship sub-path makes the subject a related party; additional
identifiers ride the /_identifier:n family; |_type: "PARTY_SELF" marks a
subject that is the EHR owner yet still carries an external reference.
Context fields: ctx/ shortcut or full path
The composition’s event context is normally set through the ctx/ shortcuts
(ctx/time, ctx/setting, ctx/language, …). The equivalent full paths are
accepted on input too, and take precedence over the shortcut defaults:
…/context/start_time, …/context/setting, the _end_time and _location
forms, and an entry’s …/language and …/encoding. A bare
…/context/setting|code is resolved against the openEHR setting value set,
exactly as ctx/setting is. A full path also feeds the defaults its shortcut
feeds: …/context/start_time sets the default history origin of every
observation and the time of every event that gives none, exactly as ctx/time
does. When you give both spellings, the full path sets the stored field and the
ctx/ key sets the defaults. Output always uses the ctx/ form for these, so a
round trip through FLAT is stable. A path the Simplified Formats specification
does not define is rejected with an error, never silently dropped.
Embedding canonical JSON with |raw
When one node needs full fidelity inside an otherwise-FLAT commit, write the
node’s canonical JSON verbatim under the |raw suffix:
{
"vital_signs/blood_pressure/any_event:0/systolic|raw": {
"_type": "DV_QUANTITY", "magnitude": 120, "unit": "mm[Hg]"
}
}
The embedded object must carry _type (without it, the key is treated as a
normal leaf). Retrieval decomposes a value into regular FLAT keys wherever the
Simplified Formats define them, and returns |raw only for a value they cannot
express: a stored value whose type differs from the template’s, or a
DV_STATE.
DV_STATE has no FLAT form
The Simplified Formats RM mapping chapter has a table for each data value type
it maps, and none for DV_STATE (RM value: DV_CODED_TEXT, is_terminal: Boolean). No specification governs a FLAT or STRUCTURED spelling for it, and
FerroEHR invents none: a write that decomposes a DV_STATE node (|code,
|value, a bare value) is refused with an error that names the node, and
the node is sent and returned as |raw canonical JSON instead:
{
"my_template/my_observation:0/process_state|raw": {
"_type": "DV_STATE",
"value": {
"_type": "DV_CODED_TEXT", "value": "completed",
"defining_code": {
"_type": "CODE_PHRASE",
"terminology_id": {"_type": "TERMINOLOGY_ID", "value": "local"},
"code_string": "at0012"
}
},
"is_terminal": true
}
}
The FLAT and STRUCTURED examples leave a DV_STATE node out, and an element
whose value the template leaves open does not offer DV_STATE among its
alternatives. The gap is reported upstream in #3936.
Coded text and open value sets
A coded field whose template value set is open accepts free text under the
|other suffix. |other may not be combined with |code, |value,
|terminology, or |preferred_term on the same leaf, and is rejected when the
value set is closed.
Duplicate node names
When a template contains sibling nodes with the same name, the generated
WebTemplate/FLAT path ids are disambiguated with underscore suffixes counted from
1 (blood_pressure, blood_pressure_1, blood_pressure_2) as the
specification prescribes. There is no vendor-compatibility mode: the
specification’s numbering is the only numbering, and vendor-only DV_QUANTITY
suffixes such as |unit_system and |unit_display_name are not accepted. If
your tooling was written against another server’s path ids, map them on the
client side.
Validation on commit
Every composition is validated against its template at commit time. This is where the template earns its keep. The server checks:
- structure: required sections and fields are present, and cardinality and occurrence constraints are respected;
- leaf values: data types, units, value ranges, string patterns, decimal precision, and date/time constraints match the template;
- terminology: coded values are members of the value sets the template binds, using the bundled openEHR terminology or a configured external FHIR terminology server (see Terminology servers).
A second pass runs beside it and refuses the same way: the clinical side’s
data-minimisation rules read the subject reference, the party proxies and every
string leaf of the body, and a finding is a 422 naming the RM path and the
rule that matched, never the offending value. That pass is configured
independently of the template, and the shipped default already refuses a
national identifier anywhere in clinical content. See
Privacy & data minimisation.
Date and time patterns
A template can constrain which parts of a date, time or date-time value must be
present. The pattern takes the form yyyy-mm-ddTHH:MM:SS, and each part is
written one of three ways:
| In the pattern | Meaning |
|---|---|
a letter field (yyyy, mm, dd, HH, MM, SS) | the part is required |
?? | the part is optional |
XX | the part is not allowed |
So yyyy-mm-ddTXX:XX:XX accepts 2022-02-03 and refuses
2022-02-03T04:05:06, and HH:MM:?? accepts both 10:30 and 10:30:15.
A refusal names the parts in words and repeats the pattern with this key:
temporal value '2022-02-03T04:05:06' does not satisfy the pattern
'yyyy-mm-ddTXX:XX:XX': the template allows a date only; a time is not allowed
here. Pattern key: a letter field (yyyy, mm, dd, HH, MM, SS) is required, ?? is
optional, XX is not allowed.
If a composition is well-formed but breaks its template, the commit fails with
422 Unprocessable Entity, a validation-failed problem whose errors, sent
when the request carries Prefer: return=representation, hold one
{"path": …, "detail": …} per offending node, so a client can show the user
exactly what to fix. A syntactically malformed request instead gets 400 Bad Request.
The error shapes are described in
Content negotiation & errors.
A version committed as 553|incomplete| relaxes what must be present while
still checking everything that is; see the lifecycle notes in
Resource walkthroughs.
Next
- Using the API → Resource walkthroughs — the full composition create/update/delete lifecycle.
- Querying with AQL — reading the data back out by path.
Beyond the core
The core of FerroEHR is the openEHR platform: EHRs, compositions, contributions, templates, versioning, and AQL. Around that core the server carries capabilities for fitting into the systems around it: moving whole records between systems, storing the people those records refer to, validating codes against an external terminology server, telling downstream systems that something changed, and keeping large attachments out of the database.
This chapter set describes each one the way you meet it: what it does, whether you have to turn it on, and how to consume it.
Note
FHIR R4 resource mapping is not part of FerroEHR. It is FerroBRIDGE (BUSL-1.1), a separate server that maps between FHIR and openEHR with FHIRconnect mappings and reaches FerroEHR over the ITS-REST API. On Kubernetes the FerroEHR chart deploys it beside the CDR with
bridge.enabled=true(FerroBRIDGE). FerroEHR keeps two uses of FHIR: the access log rendered as FHIRAuditEventwith the ITI-81 retrieval (Audit), and the client for external FHIR terminology servers (Terminology servers).
What is on already, and what you switch on
Two different things are collected here, and they behave differently:
- Always mounted, part of the API surface. The demographic API and the
/messagegroup (EHR Extract export and import) are ordinary routes. They have no feature switch: they are served on every deployment and gated only by the same authentication and authorization as the clinical API (Security). The bundled openEHR terminology is likewise always present in-process, with no configuration and no external dependency. - Off until you configure them. Everything that reaches outside the server is opt-in: external terminology servers, change events, and multimedia offload to object storage. A bare server contacts none of them, and its clinical behaviour is that of a single-tenant, integration-free openEHR CDR until you enable one.
Important
Some of these carry PHI, and each chapter says which. The one that moves clinical content off this system is multimedia offload (the blob bytes land in your bucket). Change-event envelopes carry identifiers and metadata only. Treat enabling offload as a deliberate, auditable decision about where clinical data is allowed to go.
Every configuration key these chapters name lives in the configuration reference; the integration sections are on Integrations and Audit.
Build features, and what a slim build refuses
Three of these capabilities are also cargo features of the server build
(fhir, events, and multimedia) all on in the published binaries and
container images, and on in any default build. Their code lives in a separate
crate the platform pulls in only when the matching feature is on, so a
--no-default-features build contains none of it.
A slim build does not start up quietly missing a capability: it refuses at
boot when the configuration asks for one it was built without. The fhir
feature covers the two uses of FHIR the server keeps: the external FHIR
terminology providers, and the FHIR AuditEvent the audit store, the audit
feed and the ITI-81 retrieval carry. See
From source → Build features
for the exact list of settings a slim binary rejects.
Note
One gap worth knowing if you build slim:
terminology.api_enabledis a route switch, and in a slim build those routes are simply not compiled in, so the setting has no effect rather than failing loudly. The boot refusals cover the settings that would otherwise lose or fail to deliver data.
The capability set
- EHR Extract & messaging: export a whole EHR, clone it into another system with its distributed version identity intact, and import an extract into an existing record.
- Demographics: a versioned party store (persons, organisations, groups, agents, roles) with relationships, over a REST surface that mirrors the EHR APIs.
- Terminology servers: the bundled openEHR terminology for local codes, plus any number of external FHIR terminology servers for validating coded values against external value sets; FerroTERM ships with the quickstart as the terminology overlay and runs beside the sandbox CDR.
- Change events (AMQP): a transactional outbox that publishes a PHI-free, at-least-once event for every commit to an AMQP broker, so downstream systems can respond to changes instead of polling.
- S3 multimedia: threshold-based, content-addressed
offload of large
DV_MULTIMEDIAblobs to any S3-compatible object store, with integrity verification on the way back in.
Security, instance separation and the audit trail are covered in Security; running the server in production, including the health and observability surfaces these integrations feed, is covered in Operations.
EHR Extract & messaging
Moving a patient’s record between openEHR systems (migrating to another CDR, replicating an EHR to a downstream repository, or importing an externally produced document) is what openEHR’s EHR Extract and messaging services are for. FerroEHR implements whole-EHR export, import into a new or an existing EHR (including cross-system cloning that preserves version identity).
Note
The openEHR service model defines a Message component, but the released REST API publishes no extract or message endpoint at all. FerroEHR therefore serves these operations under a
/messagegroup of its own design, beside the openEHR API at/ferroehr/rest/extensions/v1/message; the routes are ours, not the standard’s, and they gate no openEHR conformance claim. Unlike the admin extensions, they are not admin-gated: they carry the same ordinary authentication as the clinical API. The four routes, with their bodies, parameters, and status codes, are in Admin & messaging APIs.
Exporting an EHR
An export produces an openEHR EXTRACT: a self-contained package of an EHR’s versioned objects. There are two ways to ask for one.
Whole-EHR export takes every versioned object in one EHR at its latest version and assembles them into a single extract. This is the simplest way to snapshot or hand off a complete record, and it needs nothing but the EHR’s identifier.
Export by specification takes an EXTRACT_SPEC: a manifest naming which
entities to include (each by EHR id or by subject id, optionally narrowed to
specific version containers) plus the extract type. It produces one extract
per manifest entity, in manifest order. Use it for selective or
policy-controlled export.
Tip
The manifest must name at least one entity:
EXTRACT_MANIFEST.entitiesis mandatory and non-empty in the Reference Model, so an empty manifest does not even decode: the request is refused as malformed rather than answered with an empty result. If you want “everything”, that is whole-EHR export, not an empty spec.
Not every selector the Reference Model allows is supported: search criteria and some commit-time intervals are refused explicitly rather than silently ignored, so an export never quietly returns a different set than you asked for.
Importing and cloning across systems
Import is the inverse, and it is where openEHR’s distributed version identity matters. When a record produced on one system is imported into another, the imported versions keep their original identity while being recorded as having arrived from elsewhere.
Cloning a whole EHR takes an extract and materializes it into a new EHR. There are exactly two outcomes, and you choose between them with one optional parameter:
- Supply a target EHR id and the clone lands under that identifier; the “same patient, other EHR service” case.
- Supply nothing and the source EHR id the extract carries is re-used, which makes it a true clone. The server does not invent a fresh identifier; if the extract names no source id and you named no target, the request is refused.
Either way, the response names the EHR that was created, so a caller that
supplied no id still learns what it got. An EHR that already exists under the
target id is a conflict, not a merge, and so is an imported EHR_STATUS naming
a subject some other EHR already holds.
Each original version in the extract is committed wrapped in an
IMPORTED_VERSION, so the record shows both the original authorship and the
fact of import: version identity is preserved, not regenerated.
Importing into an existing EHR merges an extract’s versions into an EHR that is already there, following the openEHR change-control copying rules. The extract’s content items become new versions of that EHR’s versioned objects.
Together these are the mechanism behind cross-system migration: export from the source, import into the destination, and the destination’s history faithfully reflects where each version came from.
An import takes the data-minimisation pass too. Extract import stores what
it receives, so it cannot rewrite a body; instead it refuses one whose content
carries a national identifier, a subject reference that is not a declared
pseudonym, or an identified party the deployment does not permit, with the same
422 and the same RM path the ordinary commit path reports. The rules are on
Privacy & data minimisation, and they
apply to the admin archive load for the same reason.
Protected national identifiers do not travel in an extract
With national-identifier protection on, a party’s versioned body carries a reference where each protected identifier stood, and the value lives in a sealed row under that deployment’s own root key. An extract carries the versions and never the sealed rows, so the identifier cannot cross from one FerroEHR to another in an extract:
- A party with a plaintext protected value is refused. An imported version
is a faithful copy that is never modified (RM common
master06§Copying), so the receiving deployment cannot seal it on the way in. - A party with another deployment’s sealed reference is refused. The row it
names is not held here, so it could never be read back, and the refusal
(
422) says so.
This is a decided refusal, not a gap waiting for a key exchange. Re-wrapping the sealed rows for the receiving deployment would hand one organisation’s key material, or a wrapped copy of every value, to another, and no openEHR specification defines such an exchange. Move a record this way instead:
- Export the EHR and import the extract without the party. The clinical side holds pseudonyms and never a national identifier, so the clinical content moves unchanged.
- Register the person in the receiving deployment through its own party API
(
POST {base}/demographic/person), with the identifier the receiving organisation holds for the person. The receiving deployment seals it under its own key. - Link the party to the EHR in the receiving deployment.
Between two deployments of one organisation that share the root key, the admin dump and load carries the sealed rows as ciphertext and restores them.
When ATNA auditing is enabled (it is on by default, see
Security) each completed
export and import emits a security-audit event under the ATNA Extract object
class, so records moving between systems are captured in the audit trail with
their direction.
Divergent copies and version branches
openEHR’s version tree branches when a version created on another system is
modified locally, and FerroEHR implements that: the local write forks a branch at
the imported version’s fork point, and the new version id carries a three-part
tree id (…::2.1.1) rather than a plain trunk number.
Branch versions are served at the same URLs as trunk ones and appear in the
revision history and in ALL_VERSIONS queries; the latest version of an object
is always the latest trunk version. See
Content negotiation & errors for how
version ids read on the wire. So importing a copy of a record that has diverged
on two systems is supported, and the divergence stays visible in the version tree
instead of being flattened away.
FerroEHR as a federation node
FerroFED is the FerroHEALTH federation gateway: a client sends one AQL query to it, and it dispatches the query to the openEHR CDRs it federates. FerroEHR is one of those CDRs, a node. This page is the node contract: what a FerroEHR node exposes, what it requires, and what it refuses. The gateway’s own behaviour is FerroFED’s and is documented there.
The ground is the openEHR Federation Tier with AQL specification, release 1.0.0, approved by the openEHR SEC on 2026-10-12 (https://github.com/syntaric/openehr-federation-spec). Its requirements are numbered N1 to N44. Most of them bind the gateway. The rows below are the ones a node answers.
FerroFED 0.0.13 runs FerroEHR 5.4.1 as its node in its end-to-end harness and its quickstart.
Endpoints
A node exposes the openEHR REST API (ITS-REST 1.1.0) under its own base URL
([server] base_path; N28). The gateway uses:
| Use | Endpoint |
|---|---|
| The dispatched query, scoped to the resolved EHR (N7, N33) | POST {base}/query/aql with ehr_id, or a WHERE e/ehr_id/value = … predicate (N29) |
| A follow-up read by version uid (N22) | GET {base}/ehr/{ehr_id}/composition/{uid} and the other resource reads |
| A routed write (N23) | the ITS-REST commit operations; ETag and Location come back for the gateway to pass through (N31) |
The node’s system_id and release | OPTIONS {base}/ and GET {base}/ (ITS-REST system information) |
| Moving a record between nodes | GET /ferroehr/rest/extensions/v1/message/export/{ehr_id} and POST /ferroehr/rest/extensions/v1/message/import?ehr_id=… (EHR Extract & messaging) |
The node answers an ITS-REST RESULT_SET (N17). It runs WHERE, ORDER BY,
LIMIT and OFFSET locally; merging pages across nodes is the gateway’s work
(N9, N39).
Identity
The node is invoked on its local ehr_id alone (N33, N34). It never needs
the patient’s identifier in the query: the gateway resolves the patient to
{node, local ehr_id} pairs outside AQL, through IHE PIXm or a record locator
(FerroPIX). The node’s own cross-reference is the IHE identity source asked
over PIXm, or the local linkage map (Demographics).
A node does not have to store a directly identifying identifier in
EHR_STATUS.subject (N5). With [privacy] subject_namespaces it keeps only a
pseudonym, and the identifier scanner refuses a national identifier where it
is configured (Privacy & data minimisation).
Every version keeps its identity across nodes (N21, N22, N36). An
OBJECT_VERSION_ID is object_id::creating_system_id::version_tree_id (RM
common master06 §Version Identification). An EHR exported from one node and
imported on another keeps every version uid and its creating_system_id byte
for byte; the receiving node records the import as its own act beside the
copied original (RM common master06 §The Copy Operation). The integration test
federation_node in app/ferroehr/tests/it/ moves an EHR between two nodes
and compares every version uid.
Admission (N42a)
A federation admits a node on four identifier conditions. For three of them, the operator configures a FerroEHR node as follows:
- A federation-wide unique
system_id. The default,ferroehr.local, is the same on every node that leaves it unset. Set[server] system_idto a name no other member uses before the node commits its first version: thesystem_idis stamped into every version uid and never changes (Server settings). - No adoption of a foreign
ehr_idon import.POST /message/importwithoutehr_idre-uses the source EHR’s id (RM common master06 §Copying, Case 1). A federation member imports with?ehr_id=set to an id it chose. - No reuse of an
ehr_id. A node never issues an id twice; creating an EHR under an id it holds is refused (409 Conflict, ITS-REST EHR APIPUT /ehr/{ehr_id}). ehr_idgeneration. FerroEHR mints everyehr_idas a UUIDv7 (RFC 9562 §5.7).
Authentication
There is no central FerroHEALTH authorization service. Each node validates the bearer tokens of the deployment’s own authorization server itself, as a SMART App Launch resource server (SMART App Launch), and records the caller in its access log (N24, N25). The gateway propagates the client’s identity; the node decides on it.
What the node refuses
Access decisions are the node’s (N26, N27). Whatever the gateway pre-filtered, the node applies its own controls before it releases data:
- role and attribute rules, and the per-EHR
EHR_ACCESSdecision; - the restriction of access by health professionals (EHDS Art. 8);
- a restriction of processing
(GDPR Art. 18): a restricted object leaves every query result and export,
and a point read of it answers
403; - a research objection (GDPR Art. 21(6)): the EHR leaves every population query and export.
A refusal is the node’s answer, which the gateway reports for that node. The run that sets a restriction at a node and shows a federated query never returns the restricted object belongs in FerroFED’s harness (FerroHEALTH/FerroFED#1389).
What this page does not claim
FerroEHR implements no gateway: no patient discovery (IHE XCPD), no
cross-node merge, no registry of nodes. Those are FerroFED’s and FerroPIX’s.
The node answers ehr_get_by_subject only for an identifier its own
cross-reference already holds.
Demographics
Alongside clinical records, a CDR often needs to store the people and organisations those records refer to: patients, clinicians, care teams, institutions. FerroEHR provides a versioned demographic store for the openEHR party types and the relationships between them, over a REST surface that mirrors the EHR APIs.
The demographic endpoints are always mounted (there is no feature switch) and are subject to the same authentication and authorization as the rest of the API. See Security.
Note
The party wire API is defined by the openEHR REST specification’s Demographic API, which carries DEVELOPMENT lifecycle status inside the released specification, and the conformance schedule places demographics in the Options profile tier rather than Core or Standard. Party relationships are the one part with no specification at all: those routes are a FerroEHR extension realizing the service model’s relationship interface, and they are excluded from conformance-profile claims.
- What is stored
- Where a party is stored
- Party endpoints
- Relationships
- What a submitted party must satisfy
- The specification generation a body is read against
What is stored
The store holds the five openEHR party types (PERSON, ORGANISATION,
GROUP, AGENT, and ROLE) and PARTY_RELATIONSHIP between them.
Every party is a fully versioned object with no owning EHR: updates create new
versions, history is retained, and you can read a party as of a point in time or
by a specific version, exactly as for compositions and EHR_STATUS. The
ETag / Location / Prefer / If-Match conventions are the same ones the EHR
group uses (see Using the API) and writes are
wrapped in contributions the same way clinical writes are.
Deletion is logical, as it is for clinical content: a delete commits a new version in the deleted state rather than erasing history. A deleted party then reads as absent, and deleting one twice is refused.
Where a party is stored
Parties do not live in the clinical schema. They live in a party schema of
their own, with its own archival partitions and its own database roles, and the
database refuses the mix in both directions: a version with no owning EHR cannot
enter the clinical schema, and one that has an owner cannot enter party. Point [storage.party] url at a role of its own
and the schema separation becomes a credential separation as well. See
Operations → Database roles and least
privilege for the roles
and the pseudonymisation boundary
for what the split is for.
The demographic side is where a person’s national identifier legitimately
lives, and [demographic.identifier_protection] decides how it is held there:
with it on, the value leaves the versioned body for an encrypted column and
the body keeps a reference, while a keyed digest beside the ciphertext still
answers “which party holds this identifier”. The wire shape of every route
below is unchanged either way. See
Privacy & data minimisation.
Party endpoints
All paths are relative to the API base path (/ferroehr/rest/openehr/v1), and
{kind} is one of agent, group, organisation, person, or role.
| Method | Path | Purpose |
|---|---|---|
POST | /demographic/{kind} | create a party |
GET | /demographic/{kind}/{uid_based_id} | read a party |
PUT | /demographic/{kind}/{uid_based_id} | update a party |
DELETE | /demographic/{kind}/{uid_based_id} | logically delete a party |
GET | /demographic/versioned_party/{versioned_object_uid} | the versioned container |
GET | /demographic/versioned_party/{versioned_object_uid}/revision_history | revision history |
GET | /demographic/versioned_party/{versioned_object_uid}/version | version at a time (query parameter) |
GET | /demographic/versioned_party/{versioned_object_uid}/version/{version_uid} | a specific version |
Party changes can also be committed and read as contributions
(POST /demographic/contribution,
GET /demographic/contribution/{contribution_uid}), and parties support item
tags (GET /demographic/tags, plus
/demographic/{kind}/{uid_based_id}/tags and
DELETE …/tags/{key} per party).
A demographic contribution follows the payload rules of the EHR one (see
Resources → CONTRIBUTION) and is
refused the same way. A contribution without an audit, an audit without
change_type or committer, a commit_audit without its own committer, and
a version without lifecycle_state are each a 400. A version whose object
is not a party or a PARTY_RELATIONSHIP, a lifecycle state outside the openEHR
version lifecycle state group, and content that fails validation are each a
422. The body is canonical JSON only: an XML or simplified-format
Content-Type is a 415.
Relationships
Party relationships are managed through a parallel set of routes (the FerroEHR
extension the note above describes) with the same versioned shape as parties.
ITS-REST defines no relationship routes, so they sit under the extensions root,
/ferroehr/rest/extensions/v1, beside the openEHR API, and every path below is
relative to it:
| Method | Path | Purpose |
|---|---|---|
POST | /demographic/party_relationship | create a relationship |
GET/PUT/DELETE | /demographic/party_relationship/{uid_based_id} | read / update / delete |
GET | /demographic/versioned_party_relationship/{versioned_object_uid} | the versioned container |
GET | /demographic/versioned_party_relationship/{versioned_object_uid}/revision_history | revision history |
GET | /demographic/versioned_party_relationship/{versioned_object_uid}/version[/{version_uid}] | version at a time / by id |
A PARTY_RELATIONSHIP is a versioned object in its own right, not a party, so
it has its own versioned-container routes rather than appearing under
/demographic/versioned_party.
What a submitted party must satisfy
A party body is validated against the Reference Model before anything is
committed: identities, contacts, and relationship references have to be
well-formed, a ROLE’s capabilities and performer have to be present where the
model requires them, and a party reference has to name a legal type and
namespace. A body that fails these is refused as unprocessable, with the
offending path named.
Two limits are worth knowing because they are deliberate rather than accidental:
- There is no demographic archetype or template store, so a party is checked against the Reference Model but not against an archetype. The service model’s “definitions valid” precondition therefore does not apply here.
PARTY.reverse_relationshipsis a derived attribute the server leaves unpopulated: it is the computed inverse ofrelationships, and the server re-derives rather than storing a client’s copy.
The specification generation a body is read against
The server runs one openEHR specification generation set at a time, chosen by the
spec_profile setting (development by default, stable for the released
generations; see the
configuration reference). For demographics
this shows up at the door, because the two generations disagree about one
attribute:
- Under
stable, a JSON party body may carryPARTY.reverse_relationships: the released generation defines it, so refusing a valid instance of the generation the server advertises would invent a prohibition. The value is accepted and then dropped, because the server derives that attribute itself. - Under
development, the attribute is not part of the model and a body carrying it is refused as an undeclared field, like any other unknown key.
XML bodies need no such split: undeclared elements are skipped in either profile.
This is an acceptance boundary, not a conversion: nothing is silently translated between generations. If you feed a mixed estate, pick the profile that matches what your clients actually send.
Terminology servers
openEHR records carry coded values: a diagnosis, a route of administration, a laboratory unit. Some codes come from openEHR’s own terminology; others must be checked against an external code system such as SNOMED CT or LOINC. FerroEHR serves the bundled openEHR terminology in-process, and asks one FHIR terminology server, FerroTERM, about every other code system.
- The bundled openEHR terminology
- The terminology server
- FerroTERM beside the CDR
- Running one locally (development and CI)
- On Kubernetes
- Terminology in AQL
The bundled openEHR terminology
The server ships the openEHR terminology bundle (Terminology 3.1.0) and uses it by default, with no configuration and no external dependency. It answers the questions the platform needs while validating and querying: which terminologies exist, whether a code belongs to one, what a term’s rubric is, whether one code subsumes another, and whether a code is a member of a value set.
Enumeration is always the bundle’s job. A lookup or a membership test goes to the bundle when the bundle knows that terminology, and only otherwise to the terminology server.
Exposing the lookups over REST
You can also expose these lookups over a small read-only REST surface. It is a
FerroEHR extension (no openEHR REST contract defines a terminology API) and it
is off by default; while disabled, every route answers 404 as if it were
not mounted. Turn it on with FERROEHR__TERMINOLOGY__API_ENABLED=true and it
serves:
| Method | Path | Purpose |
|---|---|---|
GET | /terminology | list terminologies |
GET | /terminology/{terminology_id} | describe one terminology |
GET | /terminology/{terminology_id}/term/{code} | look up a term |
GET | /terminology/{terminology_id}/subsumes?ref_code=&candidate= | subsumption test |
GET | /terminology/{terminology_id}/value_set/{value_set_id} | get a value set |
GET | /terminology/{terminology_id}/value_set/{value_set_id}/validate?candidate_code=&at_date= | test membership |
All paths are relative to the extensions root, /ferroehr/rest/extensions/v1,
where FerroEHR serves its own extensions beside the openEHR API. An
unknown terminology or value set is a 404; a missing required query parameter
is a 400.
The terminology server
Note
The CDR is only ever a client of the terminology server. FerroEHR does not implement one: you run a FHIR terminology server and point the CDR at it by URL. The one that ships with the product is FerroTERM, started by the quickstart’s terminology overlay; any other server speaking the FHIR terminology operations (Ontoserver, Snowstorm, HAPI FHIR) works the same way.
The terminology server is off by default, and while it is off nothing is
requested: validation uses the in-process bundle alone. The keys live under
[terminology.server]; the full table, including the timeout and OAuth2, is on
Integrations. The minimum
is the switch and the server’s FHIR R4B base URL:
[terminology.server]
enabled = true
url = "http://ferroterm:8080/r4b"
Enabling the server with no URL is a boot error, not a silent fall back to the bundle. So is an incomplete OAuth2 grant: a control you configured either works or the server refuses to start.
One server answers every terminology the bundle does not carry. openEHR puts the
resolver of an archetype constraint binding in a “terminology query server”
outside the CDR, and FerroTERM serves SNOMED CT, LOINC, ICD and any FHIR
CodeSystem or ValueSet from one process, so the CDR holds one client to one
server.
What it changes at commit time
The mechanism is openEHR’s own archetype constraint binding. Where a template
binds an archetype constraint code (an ac code) to a query against an
external terminology, the specification puts the resolver outside the CDR: the
archetype holds an identifier for a query, and the query itself is defined in the
terminology server.
With the terminology server enabled, committing a composition resolves those bindings for every bound coded value the instance actually carries:
- The code is in the value set → the commit proceeds.
- The code is not in the value set →
422, naming the path, the code, and the bound query. That is a real constraint violation, sofail_on_errordoes not change it. - The value set could not be resolved →
fail_on_errordecides, see below.
Only bound external queries leave the process. openEHR and the bundle’s own local terminologies are still answered in-process.
Warning
The composition’s
terminology_idtravels verbatim as the FHIRsystemparameter, and no openEHR specification defines a mapping betweenterminology_idvalues (SNOMED-CT) and FHIR system URIs (http://snomed.info/sct). If your archetypes and your terminology server disagree on that spelling, align them in the terminology-server configuration. The CDR does not rewrite the value.
What it changes when an ADL 2 archetype is uploaded
An ADL 2 archetype’s term_bindings are checked too (AOM2 validity rule
VETDF). A binding target is a URI in the IHTSDO model, …/id/<code>, and the
CDR takes it apart before asking: http://snomed.info/id/50121007 (or the
snomedct.info host the ADL 2 specification’s own examples use) is asked as
system=http://snomed.info/sct, code=50121007, per the SNOMED CT URI Standard;
http://loinc.org/id/LA6742-6 as system=http://loinc.org; any other …/id/…
URI as the URI before /id/. The outer binding key (SNOMED-CT, Snomed,
LOINC, however the author spelled it) is not sent. The server’s
answer then decides:
- the code exists → accepted;
- the code system is served and the code is not in it →
422, naming the rule, the binding and the code; - the code system is not served by the terminology server (or the server cannot say) → accepted with a warning in the log. AOM2 puts it this way: codes for inaccessible terminologies “should be flagged with a warning indicating that no verification was possible”, so a CDR wired at a server that carries only LOINC still stores archetypes with SNOMED CT bindings.
The same rule holds with no terminology server configured at all: nothing is asked and nothing is refused.
Which FHIR operation is used
Membership is tested with ValueSet/$validate-code, one direct yes/no with the
least payload; $expand serves a value set’s contents, $subsumes a
subsumption test and $lookup a term. Every call is a round trip to the
server, which keeps no answer the CDR would have to invalidate.
The CDR speaks to the server through
ferrobridge-term, FerroBRIDGE’s
FHIR terminology client, over the FHIR R4B operation definitions. Each
operation is a POST of a Parameters resource to the operation’s path under
the configured base URL, so a code never travels in a URL. The at_date of a
term lookup or a membership test is sent as the operation’s date input. Each
call is made once, without a retry, so request_timeout_ms bounds the whole
call on the commit path.
A response that does not fit the operation’s R4B definition is treated as an
upstream fault rather than partially read, and takes the same path as an
unreachable server: a $lookup answer without its name or display, an
$expand answer without an expansion, a $subsumes outcome outside its four
codes, or a body of another resource type. The server’s refusals are read
through the tx-issue-type coding of their OperationOutcome: not-found
means the server does not serve the code system or value set, invalid-code
that the code is not in it, and a 404 or 410 without either coding that the
thing asked about is unknown.
Authenticating to the server
A server behind an authorization server takes an OAuth2 client-credentials
grant under [terminology.server.oauth]; the token is cached and renewed shortly
before it expires. The client secret should come from a mounted file rather than
an inline value, client_secret_file. The keys are on
Integrations.
There is no option to disable certificate verification. Server-certificate and hostname verification are always on against the platform’s trust store.
When the terminology server cannot answer
fail_on_error decides what happens when a bound value set cannot be resolved at
all, whether the server is unreachable, answers an error, or does not know the
value set:
false(the default, fail-open): the composition is accepted and a warning is logged. The availability of an external service does not block clinical writes.true(fail-closed): the composition is rejected with a validation error naming the unresolved binding.
A code that is resolved and turns out not to be a member is a different matter: that is a constraint violation and is rejected under either setting.
Pick deliberately. Fail-closed means commits stop while your terminology server is down; fail-open means they are accepted unvalidated. There is no third option that gives you both.
FerroTERM beside the CDR
FerroTERM is the terminology server
FerroEHR ships with: the docker-compose.terminology.yml overlay of the
quickstart starts it beside the CDR and points [terminology.server] at
http://ferroterm:8080/r4b, the compose page
has the commands. The hosted sandbox at https://sandbox.ferroehr.eu runs the
same pair on one machine, so the coded-text binding you see resolving there is a
real round trip to a real terminology server.
What the sandbox shows, and how it is arranged:
- The server is not on the public surface. FerroTERM answers on the
sandbox’s private compose network alone; there is no route to it at
sandbox.ferroehr.eu, its own browser UI is off, and the CDR is its only caller. You exercise it through the CDR: commit a composition against thecnf.tpl.dv_coded_text_binding_scttemplate with a code inside its bound value set and it is accepted, with a code outside it and the answer is422; the/terminology/*routes look codes up; AQLTERMINOLOGY()expands a value set into aMATCHESoperand. - Fail-open, the shipped default: a binding the server cannot resolve is accepted. The sandbox declares this posture and the register entry that records why neither posture is spec-mandated (AMB-172).
- The content is the shaped seed: two code systems and two value sets under
the reserved
example.testdomain, with no licensed terminology in them.
SNOMED CT on a public sandbox
Serving SNOMED CT to the public is a licensed activity, and the arrangement above is what the SNOMED CT Affiliate Licence Agreement (April 2023, snomed.org/get-snomed) asks for. Clause 2.2.4 permits systems “made available to the general public for accessing and/or retrieving any part of the International Release and/or data encoded using the foregoing”, provided users “are not able to extract any substantial portion of SNOMED CT” and no fee is charged for access. Clause 2.7 requires “reasonable measures to ensure that the International Release (and any part of it) cannot be accessed or downloaded from the Licensee’s systems except by authorised users”. A raw FHIR terminology endpoint open to anonymous callers would let anyone walk a code system or expand large value sets; per-code operations through the CDR, behind its rate limit, do not.
The sandbox serves the SNOMED CT International Edition, release 20260901
(http://snomed.info/sct/900000000000207008/version/20260901), loaded by the
operator under their Affiliate Licence from an index built off the machine. The
International Edition rather than a national one: it needs the Affiliate Licence
alone (a Member’s national release also needs an agreement with that Member), its
English displays fit an international audience, and it is the lighter of the two.
Every surface showing that content carries the notice the licence prescribes:
This material includes SNOMED Clinical Terms® (SNOMED CT®) which is used by permission of the International Health Terminology Standards Development Organisation (IHTSDO). All rights reserved. SNOMED CT®, was originally created by The College of American Pathologists. “SNOMED” and “SNOMED CT” are registered trademarks of the IHTSDO.
No SNOMED CT content is in this repository, in any image, or in CI; the index exists only on the sandbox machine. This is a description of the arrangement, not legal advice: the licence text is the authority and the Member’s conditions apply in each territory.
LOINC on the sandbox
The sandbox also serves LOINC version 2.83 (http://loinc.org), from an
index built the same way. The LOINC licence
(loinc.org/license) grants use and distribution
“for any commercial or non-commercial purpose” without fees and names “online
terminology services” among the permitted products, on three conditions this
deployment meets: the notice below is available where the service’s terms are
stated, every LOINC code is shown with one of its LOINC display names (the FHIR
operations return the long common name), and the version is stated. Where a
LOINC record carries a third-party copyright notice of its own, that notice
travels with the record.
This material contains content from LOINC (http://loinc.org). LOINC is copyright © Regenstrief Institute, Inc. and the Logical Observation Identifiers Names and Codes (LOINC) Committee and is available at no cost under the license at http://loinc.org/license. LOINC® is a registered United States trademark of Regenstrief Institute, Inc.
Neither index is in the repository, an image or CI; both exist only on the sandbox machine, beside the licence-free shaped seed the compose profile ships.
Running one locally (development and CI)
The quickest local terminology server is the quickstart’s
terminology overlay:
FerroTERM beside the CDR, the shaped seed served, the CDR wired, one command
and no checkout. The conformance lane runs the same server from a checkout of
the repository, with the shaped seed mounted read-only out of
docker/terminology/seed/:
docker compose -p ferroehr-cnf --project-directory . --profile terminology \
-f docker/sut-ferroehr.yml -f docker/sut-terminology.yml up -d --wait ferroehr
The profile starts FerroTERM (host port 8090 by default,
FERROEHR_TERMINOLOGY_PORT), which reports healthy only once the mounted code
systems and value sets are served, and the CDR waits on that. The overlay file
is what points the CDR at it, by switching on the [terminology.server] the
development configuration already carries in the disabled state.
None of this touches the downloadable quickstart Compose file on its own, which uses the in-process openEHR terminology only; the terminology overlay is the opt-in that adds FerroTERM to it.
Warning
The seeded content is synthetic and lives under the reserved
example.testdomain: one hierarchical code system shaped like SNOMED CT and one shaped like LOINC, each with an enumerated value set. It carries no licensed terminology content. Point the CDR at a real server, and for SNOMED CT hold the appropriate licence, for anything beyond experimentation.
Tip
Serving your own content is the same shape: FerroTERM loads every FHIR
CodeSystemandValueSetin the directoryFERROTERM_CODESYSTEMSnames, and serves them read-only. A value set it does not hold is an unresolved binding, which yourfail_on_errorsetting then decides the fate of.
On Kubernetes
terminology.enabled=true runs FerroTERM in the cluster and wires the CDR at it
(FerroTERM on Kubernetes).
A server outside the cluster goes through the config passthrough, which
renders the keys verbatim into the server’s configuration file
(Any server setting is reachable):
# values.yaml
config:
terminology:
api_enabled: true
server:
enabled: true
url: https://tx.example.org/fhir
fail_on_error: true # fail-closed: an unresolvable binding rejects the commit
secrets:
# only when the server sits behind an authorization server; the grant itself
# goes under config.terminology.server.oauth
terminologyClientSecret: ""
Before you enable it: a reachable terminology server, a decision on
fail_on_error, and (if the chart’s default-deny egress policy is on) an
egress rule that admits the server, or every call fails as a timeout.
To turn it off, set config.terminology.server.enabled: false; validation
falls back to the in-process bundle and no external call is made.
Terminology in AQL
Query authors can constrain a match to a value set with the AQL TERMINOLOGY()
function: TERMINOLOGY('expand', …) resolves a value set and merges its codes
into a matches list at query-analysis time, so the planner sees an ordinary
value list. A terminology://… operand in a matches list is expanded the same
way. See Querying with AQL for the query surface.
Operations with no defined comparison semantics in AQL are typed rejections rather than silent wrong answers.
Change events (AMQP)
When something is committed to the CDR, downstream systems often need to know: an analytics pipeline, a care-coordination service, a cache invalidator. Rather than have them poll, FerroEHR can publish a small event for every commit to an AMQP 0.9.1 broker (RabbitMQ). The events are built so you can fan them out broadly without leaking clinical data: they carry identifiers and metadata, never the record content.
- Delivery guarantees
- The event envelope
- Routing keys and subscriptions
- Enabling it
- What a broker outage looks like
- Consuming events
Delivery guarantees
The publisher is built on a transactional outbox, which is what gives it properties you can design a consumer against:
- At-least-once delivery. Every commit writes its event row to an outbox table in the same database transaction as the change itself, so no commit without its event, no event without its commit. A background task drains the outbox and marks a row published only after the broker confirms. A crash or a retry may deliver a message more than once, so consumers deduplicate.
- Ordered draining. Rows are read in global sequence order, and the drainer stops the batch at the first publish failure rather than skipping ahead, so an earlier event for an EHR is not overtaken by a later one from the same drainer. Messages are published persistently to a durable exchange.
- PHI-free envelopes. The message body carries ids, version numbers, and metadata. To read the actual clinical content a consumer calls back through the authenticated REST API.
- Commits never wait on the broker. If the broker is down, events accumulate
in the outbox and drain when it returns. Published rows are pruned after a
retention window, and never past the cursor of an active reader registered in
event_outbox_reader: the prune’s floor is the lowest active reader cursor, read in the same statement.
flowchart LR
commit["commit<br/>(composition / status / folder / party)"]
tx[("same DB transaction")]
node["clinical data"]
outbox["event_outbox row<br/>(published_at = NULL)"]
drain["outbox drainer<br/>(background task)"]
broker["AMQP topic exchange<br/>ferroehr.events"]
consumer["your consumer<br/>(bound queue)"]
commit --> tx
tx --> node
tx --> outbox
drain -->|"poll pending, publish, await confirm"| broker
outbox -.->|"drained in seq order"| drain
broker --> consumer
consumer -.->|"fetch bodies via authenticated API"| commit
Note
Several server replicas can drain the same outbox safely (each row is claimed exclusively and the others skip it) but that also means two replicas may have different rows in flight at once. Order your consumer on the
seqfield rather than on arrival order, and the guarantee holds however many replicas you run.
The event envelope
Each published message is JSON. One contribution can touch several versioned objects, and the publisher emits one message per version, each under its own routing key. Every message carries the shared envelope:
| Field | Meaning |
|---|---|
contribution_id | the contribution this change belongs to |
ehr_id | the EHR; null for a demographic contribution, which has no EHR scope |
committed_at | the commit instant |
versions[] | one entry per changed versioned object |
seq | the delivery sequence number (monotonic) |
version_index | which entry in versions this message is for |
Each versions[] entry carries:
| Field | Meaning |
|---|---|
vo_id | the versioned object’s identifier |
kind | the full RM type name: COMPOSITION, EHR_STATUS, EHR_ACCESS, FOLDER, or one of the demographic kinds (PERSON, ORGANISATION, GROUP, AGENT, ROLE, PARTY_RELATIONSHIP) |
sys_version | the version ordinal |
version_tree_id | the version-tree id, so a branch version is distinguishable from a trunk one |
change_type | the numeric openEHR audit change-type code: 249 creation, 250 amendment, 251 modification, 523 deleted, 666 attestation, and the other members of that code group |
template_id | the composition’s operational template, or null |
The code, not its English rubric, is what travels: rubrics are display text and change, the code is what the audit stores.
Tip
Deduplicate on the pair
(contribution_id, version_index)and process inseqorder. That handles at-least-once redelivery and keeps ordering at the consumer regardless of how the server side is scaled.
Routing keys and subscriptions
Messages are published to a topic exchange (default name ferroehr.events),
with a three-field routing key:
<kind>.<change_type>.<template_id>
For example, COMPOSITION.249.openEHR-EHR-COMPOSITION_encounter_v1. AMQP topic
keys use . as the word separator, so a template id containing dots is
sanitised (every character outside [A-Za-z0-9_-] collapses to _) and the key
always has exactly three fields. When there is no template, the last field is -.
Bind a queue with the usual AMQP topic wildcards to select what you care about:
COMPOSITION.*.* for all composition changes, *.523.* for all deletions, #
for everything.
The server can also manage subscriptions for you. With the event-subscription
admin API enabled (FERROEHR__EVENTS__ADMIN_API), the CRUD routes under
/ferroehr/rest/extensions/v1/admin/event_subscription let you store subscription rows, and each enabled
row makes the server declare and bind a durable queue named <exchange>.<name>
(for the default exchange, ferroehr.events.<name>). Its binding key is built
from the row’s kind / change_type / template_id predicates, with a wildcard
for any predicate left unset. Topology is (re)declared when the broker
connection is established or the enabled set changes, not on every poll, and
re-declaring is idempotent, so a broker replaced underneath the server gets its
queues back.
Enabling it
Publishing is off by default. The keys live under [events]; the full table with
every default is on
Integrations. The essentials:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__EVENTS__ENABLED | false | master switch |
FERROEHR__EVENTS__URL | a local development broker | broker connection URL (credentials are redacted from every rendering) |
FERROEHR__EVENTS__URL_FILE | unset | read the broker URL from a mounted file instead |
FERROEHR__EVENTS__EXCHANGE | ferroehr.events | topic exchange name, and the queue-name prefix |
FERROEHR__EVENTS__TLS | false | upgrade an amqp:// URL to amqps:// |
FERROEHR__EVENTS__ADMIN_API | false | mount the /ferroehr/rest/extensions/v1/admin/event_subscription routes |
Batch size, poll interval, publish retries, and the retention window for published rows are tunable too, and their defaults are sensible for a normal deployment. The retention window is a floor, not a schedule: a published row that an active cursor reader has not reached survives the window until that reader passes it, and a reader the configuration switches off is marked inactive at boot so it holds nothing.
Warning
The broker URL carries credentials, so keep it in a secret (
url_filereads it from a mounted file) not in a plain environment file. For anything beyond a local broker use TLS (FERROEHR__EVENTS__TLS=true, or anamqps://URL). Underdeployment_profile = "production"the server refuses to start with the outbox on a plainamqp://broker unlessplaintext_brokeris accepted by name.
Note
Eventing is also a cargo feature (
events), on in the published images and any default build. A slim--no-default-featuresbuild contains none of the transport’s code and refuses to boot withevents.enabled = truerather than starting quietly without a publisher; see From source → Build features.
What a broker outage looks like
A broker the server cannot reach is a degraded, not a failed, deployment. The
events health indicator reports degraded with “event broker unavailable; outbox
buffering”, and because it is not a required indicator, readiness still passes:
the CDR keeps accepting clinical writes and the outbox keeps growing. Watch the
indicator on the health surface rather than the broker alone: see
Operations.
On Kubernetes
The chart renders its config tree verbatim into the server’s configuration
file, so every key above is reachable as config.events.*
(Any server setting is reachable).
The broker URL carries credentials, so it goes through secrets.eventsUrl,
which the chart mounts as a file and passes by path:
# values.yaml
config:
events:
enabled: true
exchange: ferroehr.events
tls: true
secrets:
eventsUrl: "amqps://user:pass@broker.example:5671/%2f"
Before you enable it: a reachable broker, a broker certificate the pod trusts
if you set tls: true, and (if the chart’s default-deny egress policy is on) an
egress rule that admits the broker. To turn it off, set
config.events.enabled: false and upgrade; the outbox stops draining and nothing
else changes.
Consuming events
A consumer binds a queue to the exchange and reads. In shell form with the RabbitMQ tooling:
# bind a queue to every composition creation, then consume
rabbitmqadmin declare queue name=my-consumer durable=true
rabbitmqadmin declare binding source=ferroehr.events destination=my-consumer \
routing_key='COMPOSITION.249.*'
The server declares the exchange itself (durable, topic) the first time it publishes. If you bind before the server has ever published, declare the exchange yourself with the same name and type, or the binding has nothing to attach to.
Each delivery is a JSON envelope as described above. Your consumer records the
(contribution_id, version_index) pairs it has seen, and for anything whose
content it needs, it calls the CDR’s REST API (for example
GET /ehr/{ehr_id}/composition/{vo_id}) with its own credentials. The event
tells it what changed; the authenticated API is where it reads the data.
S3 multimedia
Clinical records sometimes carry large binary attachments (scanned documents,
images, waveforms) as DV_MULTIMEDIA values. Keeping big blobs inline in the
database bloats storage and slows queries. FerroEHR can offload large multimedia
blobs to any S3-compatible object store, keeping a small content-addressed
reference in the composition, and re-materialize them on demand when a record is
read back.
How offload works
Offload is a commit-path transformation applied to DV_MULTIMEDIA nodes anywhere
in the tree, including a node’s nested thumbnail, which is itself a multimedia
value:
- A node qualifies only when it is purely inline (it has
dataand nouri) and its decoded byte length is strictly greater than the configured threshold. A value at or below the threshold stays inline; a value that already references external media is stored verbatim, never touched. - The raw decoded bytes are written to the object store under a key that is the SHA-256 hash of those bytes, in lowercase hex. Because the key is the content hash, identical blobs deduplicate automatically and an upload whose key already exists is a no-op.
- The node is rewritten in place: inline
datais removed and replaced with auriof the forms3://<bucket>/<hash>, plus anintegrity_check(the same SHA-256 digest), anintegrity_check_algorithmcode phrase (SHA-256), andsizeset to the decoded byte length.
Uploads happen before anything is persisted, so a failed upload aborts the commit and a record is never half-stored, so the version count is unchanged.
Note
What lives where afterwards: the object store holds the blob bytes; the composition in PostgreSQL holds a compact, spec-legal
DV_MULTIMEDIAthat points at the blob by content hash. Everything stays canonical openEHR JSON: thes3://reference and the integrity fields are standard Reference Model attributes, not FerroEHR inventions. The digest appears twice in two encodings, because the model asks for it that way:integrity_checkis a byte array, so canonical JSON renders it base64, while theurispells the same digest as lowercase hex.
Reading blobs back
By default a read returns the stored form, the compact reference. To get the
bytes back inline, ask for expansion on the read with ?expand_multimedia=true.
It is available on the reads that can return externalized content: composition
and versioned-composition reads, EHR_STATUS and its versioned reads, and
directory (folder) reads.
The server fetches each of its own externalized blobs (only URIs of the exact
form s3://<configured-bucket>/<hash> count as its own, so a foreign https://
or other-bucket reference is left alone) verifies the SHA-256 of the fetched
bytes against the key, and only then re-inlines the data. A hash mismatch is a
hard error, so a corrupted or tampered blob is never quietly served.
An expanded value keeps its uri and integrity fields alongside the restored
data: it is both inline and external, which is spec-legal and means a
subsequent commit of that same body re-offloads cleanly.
If the object store is unreachable, both halves of the path fail loudly rather
than losing content: a commit that needs to offload is refused 500 and nothing
is written, and an expanded read of an already-offloaded record is refused 500. A
read without expand_multimedia still answers 200; it never touches the
store.
Turning it back off
enabled governs new offloads, not access to old ones. Switching it back to
false:
- New commits keep large
DV_MULTIMEDIAinline, byte-identical, with no dependency on the object store. Nothing else about the request or the record changes. - Records that were already offloaded keep their
s3://reference and stay fully readable:?expand_multimedia=truestill fetches, verifies and re-inlines them, as long as anendpointis still configured. Content this server externalized does not become unreachable because a switch was flipped.
Warning
Removing the
endpointas well is the decision that matters. With no store reachable at all, an expansion request against an already-offloaded record fails, and does not quietly answer200with the compact reference. The bytes are still in your bucket and still reachable with an S3 client; the API refuses rather than pretending the request was honoured. So decide what happens to the blobs already in the bucket before you remove the endpoint, not before you flipenabled.
One slim-build caveat: a binary built without the multimedia feature has no
object-store code at all, so it serves the stored compact reference and does not
refuse the expansion. Do not read externalized records with a slim binary.
Enabling it
Offload is off by default. The keys live under [multimedia]; the full table with
every default and its meaning is on
Integrations. The essentials:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__MULTIMEDIA__ENABLED | false | master switch |
FERROEHR__MULTIMEDIA__THRESHOLD_BYTES | 262144 (256 KiB) | offload blobs larger than this; smaller stay inline |
FERROEHR__MULTIMEDIA__ENDPOINT | unset | S3 endpoint URL; unset uses default AWS endpoint resolution |
FERROEHR__MULTIMEDIA__BUCKET | openehr-multimedia | target bucket |
FERROEHR__MULTIMEDIA__REGION | us-east-1 | S3 region, required even for non-AWS endpoints |
FERROEHR__MULTIMEDIA__ACCESS_KEY_ID | unset | access key id |
FERROEHR__MULTIMEDIA__SECRET_ACCESS_KEY | unset | secret key (or …__SECRET_ACCESS_KEY_FILE for a mounted secret) |
FERROEHR__MULTIMEDIA__ALLOW_HTTP | false | permit plain-HTTP endpoints, development only |
With both the access key and the secret unset, the client runs unsigned (anonymous), the mode a local development SeaweedFS gateway accepts with no credentials. Set both to make signed requests against a real store.
An enabled integration is refused at boot when its endpoint is set but
blank, relative, or carrying a scheme other than http or https. That case is
easy to reach by accident (an unset Compose variable expanding to nothing, an
empty Helm value, a bare host:port) so it is refused where an operator can
still act on it, rather than at the first multimedia commit.
Warning
The bucket must already exist. The server never creates it, and a missing bucket is not distinguishable on the wire: S3 answers a
PUTinto a bucket that does not exist with403 AccessDenied, not404 NoSuchBucket. So every multimedia commit fails500and the log saysAccess Denied, which reads like a credentials problem and is not one. Create the bucket before you enable the integration; the SeaweedFS recipe below shows the one-liner.
Warning
Offloaded blobs are PHI. In production the bucket must be private, encrypted, and reached over HTTPS (
FERROEHR__MULTIMEDIA__ALLOW_HTTP=false). Prefer instance or workload identity over static keys where your platform supports it. See Operations for the deployment-side security posture.
Note
Externalization is also a cargo feature (
multimedia), on in the published images and any default build. A slim--no-default-featuresbuild contains none of the object-store code and refuses to boot withmultimedia.enabled = truerather than silently storing every blob inline; see From source → Build features.
On Kubernetes
Every key above is reachable as config.multimedia.*
(Any server setting is reachable);
the S3 credentials go through secrets.*, which the chart mounts as files:
# values.yaml
config:
multimedia:
enabled: true
endpoint: https://s3.example.com
bucket: openehr-multimedia
threshold_bytes: 262144
secrets:
multimediaAccessKeyId: "AKIA…"
multimediaSecretAccessKey: "…"
Before you enable it: the bucket must exist (nothing in the chart creates it)
and, if the chart’s default-deny egress policy is on, an egress rule must admit
the endpoint. To turn it off, set config.multimedia.enabled: false and leave
the endpoint in place so already-externalized content stays readable (see
Turning it back off).
Quick setup with SeaweedFS
Any S3-compatible store works: AWS S3, MinIO, SeaweedFS. SeaweedFS is a light option for development and testing: its S3 gateway needs no credentials.
Step 1, create the bucket. The gateway starts with no buckets at all, and
nothing in it creates one. Against an unauthenticated development gateway a bare
PUT on the bucket path is enough:
curl -X PUT http://127.0.0.1:8333/openehr-multimedia
curl -s http://127.0.0.1:8333/ # the bucket now appears in ListAllMyBuckets
The Compose stack does this for you: the s3
profile brings up the gateway plus a seaweedfs-init service that performs
exactly that PUT once the gateway is healthy, reading the same bucket variable
the server does so the two cannot disagree. This step is only for a gateway you
run yourself.
Step 2, point the server at the gateway and allow plain HTTP for local use:
export FERROEHR__MULTIMEDIA__ENABLED=true
export FERROEHR__MULTIMEDIA__ENDPOINT=http://127.0.0.1:8333
export FERROEHR__MULTIMEDIA__BUCKET=openehr-multimedia
export FERROEHR__MULTIMEDIA__ALLOW_HTTP=true
The same exports drive the Compose stack, which passes the whole
FERROEHR__MULTIMEDIA__* set through from your shell; only the endpoint changes,
to the in-network hostname http://seaweedfs:8333.
Step 3, check what the server actually took. /management/env reports the
effective configuration, which is the quickest way to catch a variable that never
arrived:
curl -s -u ferroehr:ferroehr http://localhost:8080/management/env | jq .multimedia
The secret access key is masked there. access_key_id is not masked, and
deliberately so: it is an identifier rather than a credential, and seeing it is
how you confirm which key the server is actually using.
With the switch on and the bucket present, large DV_MULTIMEDIA values committed
through the ordinary composition APIs (see
Using the API) are offloaded automatically. Nothing
about the request or the stored record changes except the size of what lives in
the database.
Note
Base64 inflates a blob by about a third on the wire, and the whole request body is still subject to the request-body limit (
rest.body_limit_byteson the clinical surface,server.body_limit_byteseverywhere); a composition that exceeds it is refused413before offload is ever considered. See[server].
Security
A clinical data repository holds PHI, so its access controls and audit trail are part of the product. This chapter covers the four security surfaces you configure when you deploy FerroEHR: authentication (who is calling), authorization (what they may do), the pseudonymisation boundary (which role reads which domain), and the ATNA audit trail (recording what happened). Each is independently configurable, and each is described here in terms of the environment variables you actually set.
This chapter tells you how to configure each control. Its companions tell you the rest: the Threat model states what remains true after each control has done its job: the trust boundaries, the residual risk at each, and what this software explicitly does not defend against; and Verifying releases covers the artifacts themselves: how to establish that the binary, image, or chart you downloaded came from this project’s own build. Read the threat model before you decide a control is sufficient for your deployment.
- Authentication
- Authorization
- Response security headers
- Request limits and rate limiting
- Operational surfaces: what is reachable, and by whom
- Secrets: mount files, never bake values
- Verifying what you pulled
- The pseudonymisation boundary
- One instance, one organisation
- Version signing
- ATNA audit trail
Configuration follows the same pattern throughout: the server reads defaults,
then the single ferroehr.toml file, then environment variables, with __
separating nested keys. The security configuration groups live in
distinct sections of ferroehr.toml: [auth] (authentication), [authz]
(authorization), [privacy] (the pseudonymisation boundary), and [audit]
(the ATNA audit trail). Any key can be overridden with the matching
FERROEHR_* environment variable shown below.
Authentication
Authentication is on by default (FERROEHR__AUTH__ENABLED=true). Setting
it to false lets all requests through unauthenticated, a development-only
mode.
There is no single “mode” switch. The server offers two mechanisms and enables
each by the presence of its configuration. Both are checked by
ferrohealth-smart, the resource-server crate every FerroHEALTH product
shares; FerroEHR checks tokens and never issues them.
- HTTP Basic is active when
[[auth.basic.users]]lists a user. Each user has a user-id, an Argon2id password hash (a PHC string beginning$argon2id$) or apassword_hash_filepointing at one, and the SMART on openEHRscopesit holds, and its RBACroles(a user with none holds no role). Because it is a list of users, the Basic store is normally supplied through the TOML configuration file rather than environment variables. - OAuth 2.0 bearer tokens are active when
[auth]names anissuer. The server reads the issuer’s metadata and key set at boot and validates every token’s signature (asymmetric algorithms only), issuer, audience, lifetime and type under RFC 9068.
Both mechanisms are validated at startup, and a configuration the server cannot honour refuses to boot rather than degrading at the first request:
| Configuration | Boot outcome |
|---|---|
auth.enabled = true with no mechanism | error: a 401 challenge must name a scheme the server implements (RFC 9110 §11.6.1) |
an issuer with no audience | error: a server with no declared audience cannot reject another server’s token (RFC 9068 §4, RFC 8725 §3.9) |
an issuer not https (plain http is accepted on a loopback host only), carrying a query or fragment, or a user name or password | error: RFC 8414 §2, §6.2; OpenID Connect Core 1.0 §1.2 |
| an issuer whose metadata or key set cannot be read | error: the start is refused rather than the first request |
clock_skew_seconds above 300 | error: leeway may be “no more than a few minutes” (RFC 7519 §4.1.4) |
algorithms naming an HS* algorithm, or empty | error: the server accepts asymmetric algorithms only (RFC 8725 §3.1, §3.2) |
a jwks_file holding a symmetric key or a private-key member | error: a verifier holds public keys only |
a product_scope that is neither a full URI nor starts with __ | error: SMART App Launch 2.2.0, Scopes and Launch Context, §Extensibility |
a password_hash below m=19456,t=2,p=1 argon2id | error: the OWASP Argon2id floor |
[authz.assurance] minimum with an empty levels map, or with no mapped value at or above it | error: every patient-data request would be refused |
an [authz.assurance] levels value that is blank or carries whitespace, a quote or a backslash | error: the step-up challenge names the values in one quoted acr_values parameter (RFC 9470 §3) |
[authz.professional] with a blank claim or acting_for_claim | error: nothing to read the person from |
Successfully verified Basic credentials are cached for
FERROEHR__AUTH__BASIC__CACHE_TTL_SECONDS (default 0, which checks every
request) so a busy client pays the deliberately expensive Argon2 verification
once per TTL instead of on every request. The cache names each entry by a keyed
hash of the credential, never the password, and an entry exists only after a
successful verification; the TTL bounds how long a removed user can still
authenticate, exactly like a session lifetime.
The bearer settings:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__AUTH__ISSUER | unset (required for bearer tokens) | expected iss, and where the metadata is read: RFC 8414 §3 first, OpenID Connect Discovery 1.0 §4 after a 404 |
FERROEHR__AUTH__AUDIENCE | unset (required with an issuer) | accepted aud values |
FERROEHR__AUTH__ALGORITHMS | RS256,RS384,PS256,PS384,ES256,ES384 | accepted signing algorithms, asymmetric only |
FERROEHR__AUTH__TOKEN_PROFILE | rfc9068 | rfc9068 requires typ at+jwt and the client_id, iat and jti claims; jwt admits a plain JWT for an issuer that mints no RFC 9068 tokens |
FERROEHR__AUTH__CLOCK_SKEW_SECONDS | 60 | leeway on exp, nbf and iat; capped at 300 |
FERROEHR__AUTH__JWKS_FILE | unset | a static JWK Set of public keys, read instead of the issuer’s metadata |
FERROEHR__AUTH__JWKS_MAX_AGE_SECONDS | 600 | a key set older than this is read again |
FERROEHR__AUTH__JWKS_REFRESH_INTERVAL_SECONDS | 60 | an unknown kid reads the key set again at most once per interval |
FERROEHR__AUTH__TIMEOUT_MS | 10000 | one call to the authorization server |
FERROEHR__AUTH__REALM | ferroehr | the realm every challenge names |
FERROEHR__AUTH__PRODUCT_SCOPE | __ferroehr.platform | the scope that admits the ADMIN and DEMOGRAPHIC APIs |
There is no separate key-set URL to set: the server reads the jwks_uri from
the issuer’s metadata unless you supply a JWKS_FILE. A rotated key is picked
up on the first token that names it, bounded by the refresh interval, and a
withdrawn key stops verifying within the max age.
Every route also enforces its SMART on openEHR requirement: a valid token or
Basic user whose scopes do not grant the operation is refused with 403. See
SMART App Launch.
Tip
Keycloak example. Point the issuer at your realm and let discovery do the rest:
export FERROEHR__AUTH__ISSUER=https://keycloak.example/realms/ferroehr export FERROEHR__AUTH__AUDIENCE=ferroehr-api export FERROEHR__AUTH__TOKEN_PROFILE=jwtKeycloak’s access tokens carry
typ: JWT, outside the RFC 9068 profile the server expects by default, hencetoken_profile = "jwt". The client’s default client scopes put the SMART on openEHR scopes the routes need into each token’sscopeclaim.The same pattern works for Active Directory or any standards-compliant identity provider; walkthroughs for Entra ID and AD FS (and the answer for plain-LDAP directories) are in Enterprise identity providers. User accounts, roles, and lifecycle are administered in the IdP; the CDR has no user API.
An unauthenticated request to a protected route is refused with 401; an
authenticated request that lacks the required role or scope is refused with
403. Two outcomes are neither: a malformed Authorization header, or two
of them, is a 400 (the server never read a credential), and an unreadable
key set is a 503 (no token can be validated, so the server cannot decide; it
is not a statement about the caller’s credential). The per-status table for
client authors is in Using the API.
The 401 body deliberately says nothing about why, and neither does the
challenge: every token that does not hold gets the one invalid_token answer
(RFC 9068 §4), with no error_description. Rendering the rejection would tell
an unauthenticated caller whether a token was expired or forged, which is
exactly the distinction an attacker probes for; the reason stays in the debug
log, where the operator can read it and the caller cannot. A 403 names no
scope either, in its challenge or its body. A request that carried no
credential at all, or one for a scheme the server does not admit, gets no error
code: it has not made a mistake yet (RFC 6750 §3.1).
Assurance level and the natural person on patient data
Two optional blocks under [authz] judge how a professional
authenticated and who stands behind a token. Both are off by default; with
neither configured nothing below changes the wire.
[authz.assurance] reads the authentication assurance level from a
token claim (claim, default acr) and maps each value your identity provider
emits to an eIDAS level (levels: low, substantial or high). With
minimum set, a patient-data request whose token maps below it, carries a value
the map does not name, or carries no such claim is refused with 401 and the
OAuth 2.0 step-up challenge of RFC 9470 §3:
WWW-Authenticate: Bearer realm="ferroehr", error="insufficient_user_authentication",
error_description="patient data requires authentication at assurance level substantial or higher",
acr_values="http://eidas.europa.eu/LoA/high http://eidas.europa.eu/LoA/substantial"
acr_values lists every mapped value at or above the minimum, so a client can
send the user back to the identity provider with that request. EHDS Annex II 3.1
asks an EHR system used by health professionals for reliable identification and
authentication of them; Implementing Regulation (EU) 2026/2099 Art. 6(3) sets
substantial for cross-border exchange, and high from 26 March 2032.
[authz.professional] names the claim that identifies the natural
person (claim, default sub). A token is a client token when it lacks that
claim, or, under the default client_tokens = "sub_is_client", when its sub
equals its client_id or azp (RFC 9068 §2.2 has sub name the client when
no resource owner is involved). With the block present, a client token is
refused on patient data with 403, unless acting_for_claim names a claim the
token carries the professional’s identifier in (a dotted path reaches into a
JSON object, such as act.sub). The access record then names that
professional; see Audit trail.
What counts as patient data: every API route except the definition family
(/definition/...: templates, archetypes, stored-query texts), terminology,
the aggregate counts under /admin/report, and the admin routes that manage
configuration, event subscriptions, the retention policy, templates and
stored queries. The list of exceptions is closed, so a route
added later is judged patient data until it is listed. The health family,
/rest/status, discovery and /management are outside the API and never
judged.
Basic credentials carry no assurance level and no issuer-asserted person.
With minimum set a Basic request for patient data gets the same 401 step-up
challenge, and with [authz.professional] set it gets the 403; definition
routes stay reachable with Basic.
The assurance level is judged before RBAC, so a caller below the level learns
it must step up rather than that it lacks a role. A client token acting for a
professional is held to the same level as any other token: the acr it
carries is the one judged, so a client-credentials token needs your
authorization server to put the professional’s assurance level on it.
Two limits worth planning around
Plain-HTTP issuers are accepted on a loopback host only. The server reads the issuer’s metadata and keys over TLS, because over plain HTTP an attacker on the network could serve their own signing keys (RFC 8414 §6.2); there is no switch that admits a plain-HTTP issuer elsewhere.
Revocation latency equals the access-token lifetime. Tokens are validated
offline against the issuer’s published keys; the CDR calls no introspection
endpoint (RFC 7662 defines that mechanism but does not require a resource server
to use it), so a token revoked at the identity provider stays acceptable here
until its exp passes. That is deliberate: introspecting per request would put
the identity provider’s availability in the request path, and caching the results
only shortens the lag. The control is therefore the token lifetime, which your
authorization server owns: keep access-token lifetimes short (minutes, not
hours) if prompt revocation matters, and use refresh tokens for session length.
clock_skew_leeway_seconds adds at most its own value on top of exp.
Authorization
Authorization has three composable layers. The per-EHR EHR_ACCESS gate is
the openEHR-specified base and is always on; the coarse role layer is active
when authentication is enabled; the fine-grained attribute layer is opt-in.
A request must clear every active layer. Deployments serving SMART apps can
enable a fourth, token-scope layer on top; see
SMART App Launch.
Per-EHR access control (EHR_ACCESS)
Every EHR carries a versioned EHR_ACCESS object, the openEHR
access-decision authority for that record. A new EHR has no settings, and what
that admits is a server-wide choice:
authz.rbac.ehr_access_default | An EHR with no settings |
|---|---|
open (default) | reachable by any caller the coarse layers already admitted |
restricted | reachable only by authz.rbac.admin_role |
open is the default because it is what every existing deployment runs, and
changing it changes who can read existing records. restricted is object-level
default-deny, and it is the setting to reach for if your threat model
includes a caller enumerating record ids: a server-created ehr_id is a
time-ordered UUIDv7, so it is not even unpredictable, and the OWASP
Insecure Direct Object Reference Prevention
cheat sheet is explicit that an unpredictable identifier is not itself an access
control.
Note
Under
restricted, the admin role still reaches a setting-less EHR. That is deliberate: a plain deny would make such a record unreachable by everyone (including the operator who would author the settings that fix it) which is an outage rather than a control. Bind callers to patients with the ABAC layer below; this key decides only the default disposition.
Committing settings with the ferroehr.access_control.v1 scheme
switches that EHR to explicit policy, and those settings always win over the
server default, in both directions:
{
"_type": "EHR_ACCESS",
"name": { "_type": "DV_TEXT", "value": "access" },
"archetype_node_id": "openEHR-EHR-EHR_ACCESS.generic.v1",
"settings": {
"_type": "FERROEHR_ACCESS_CONTROL_V1",
"gate_keeper": "user:alice",
"default_access": "restricted",
"access_list": [
{ "principal": "user:bob", "access": "full" },
{ "principal": "role:nurse", "access": "restricted_below", "max_level": 2 }
],
"privacy": {
"default_level": 0,
"composition_overrides": [
{ "uid": "8849182c-82ad-4088-a07f-48ead4180515", "level": 3 }
]
}
}
}
- Access list: with
default_access: "restricted", only listed principals may touch the EHR:user:<login or OIDC subject>orrole:<role>(matched against the caller’s roles). Everyone else gets403. - Privacy levels: integer sensitivity levels with meanings you define
for your jurisdiction. A composition’s level is its override entry or the
default, and a caller may read it only when its level is strictly below
their ceiling.
fullaccess has no ceiling;restricted_belowuses the entry’smax_level; a caller with no access-list entry getsdefault_level + 1, so the default level stays readable and only raised levels are withheld. This gate applies to Composition read routes. - Gate-keeper: once set, only that principal may commit a new
EHR_ACCESSversion (via a CONTRIBUTION; there is no dedicatedEHR_ACCESSendpoint in the openEHR REST API). Changes are versioned and audited like all record content.
The scheme is a FerroEHR extension: openEHR mandates the EHR_ACCESS
object and its change control but publishes no concrete access-control
scheme. The per-EHR decision also holds for AQL, ad-hoc and stored queries
alike: an EHR the caller may not read contributes no rows, whether the query
names it in its WHERE clause, in an EHR predicate, through the ehr_id
parameter, or not at all, as in a population query. Query results are not
filtered by privacy level.
Restriction of access by health professionals (EHDS Art. 8)
Regulation (EU) 2025/327 Art. 8 gives a person the right to restrict the access of health professionals and healthcare providers to all or parts of their data, and has the fact of the restriction stay invisible to healthcare providers. Art. 11(5) lets the restricted data be reached where necessary to protect the person’s vital interests, logged so the person can see it. No openEHR specification governs this; it is our own design.
- Recording it. The controller records a restriction on the person’s
instruction with
POST /ferroehr/rest/extensions/v1/admin/access-restriction, for a whole EHR or for one versioned object, and lifts it withPOST /ferroehr/rest/extensions/v1/admin/access-restriction/lift(the routes). A whole-EHR restriction covers data recorded after it too. - What a professional sees. Restricted data reads as absent: a restricted composition or EHR answers 404 exactly as an unknown one does, a query leaves it out at every scope (and a query scoped to a restricted EHR answers as for an unknown EHR), an export skips it, a CONTRIBUTION lists none of its versions, the event stream withholds it, and a write to it is refused as a write to an unknown target. No status, header or count says that a restriction exists.
- What it does not change. Nothing is deleted, and storage and the controller’s own processing go on: the admin routes (dump, archive, erase, the registers) see everything. It is not the GDPR Art. 18 restriction of processing, which answers 403 and stops processing itself.
- The emergency override. A caller holding the role
authz.rbac.emergency_access_rolenames that declares one of[audit] emergency_purpose_codesreads the restricted data, and its access record carries the emergency mark. Either alone lifts nothing. Unset, nobody may override.
[authz.rbac]
emergency_access_role = "EMERGENCY"
[audit]
purpose_codes = ["TREAT", "ETREAT"]
emergency_purpose_codes = ["ETREAT"]
The grain is the versioned object, the whole EHR, or both. A finer grain (one entry inside a composition) is not built: an object is the unit the version reads, the queries and the exports already decide on, and splitting a composition would change what the stored, signed version holds.
RBAC (role-based, coarse)
Every openEHR operation is classified as Public, Clinical, or
Admin, and a role model gates each class. Roles are plain,
case-insensitive strings; the defaults are USER and ADMIN.
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__AUTHZ__RBAC__ENABLED | true | the coarse role gate (active only when auth is enabled) |
FERROEHR__AUTHZ__RBAC__ADMIN_ROLE | ADMIN | role required for admin operations |
FERROEHR__AUTHZ__RBAC__READONLY_ROLE | READONLY | role marking a principal read-only: refused on every write |
FERROEHR__AUTH__ROLE_CLAIMS | ["roles","groups","entitlements"] | JWT claim paths mined for roles; a Keycloak issuer adds realm_access.roles |
FERROEHR__AUTHZ__RBAC__SUBJECT_AUDIT_ROLE | unset | role that reads the access log for ONE subject at a time (GET /fhir/r4/AuditEvent with patient required). Unset, that log stays admin-only. See Audit trail |
Roles come from the JWT claims listed in ROLE_CLAIMS, or from a Basic user’s
configured roles. The defaults are the carriers
RFC 9068 §2.2.3.1 names
for conveying authorization state (roles, groups, entitlements, of which
roles and entitlements are SCIM
attributes), the default every FerroHEALTH product shares. A claim path may be
dotted, so an issuer that nests them differently, such as Keycloak’s
realm_access.roles, is configuration rather than a code change; a claim
carrying a single string and one carrying an array are both accepted.
A Clinical operation needs at least one role of any name; an Admin operation
needs the admin role. A Basic user holds the roles its roles list names
and nothing else, so a user with none is refused every Clinical operation;
roles = ["USER"] satisfies the Clinical class and no Admin operation. A
bearer token whose SMART on openEHR resource scopes are all system/ scopes,
such as system/template-*.r, is a backend client and needs no role for a
Clinical operation: its scopes decide what it reaches. A user/ or patient/
token belongs to a person and still needs a role. The former user_role key is refused at boot.
Disabling RBAC restores authentication-only behaviour.
Important
The management surface is not configured here.
/management/*is governed entirely by[management.endpoints], one level per endpoint, and nothing under[authz.rbac]changes it. There is no global default beside it: an endpoint you do not name isoffand is not mounted at all.Each level means:
off, not mounted, answers404;private, any authenticated principal;admin_only, authenticated and holdingauthz.rbac.admin_role(the one place RBAC is consulted);public, no check at all, including no authentication.The consequence worth internalising:
prometheus = "public"is reachable by an anonymous caller whatever your RBAC settings say, because apublicendpoint is mounted outside the authentication layer. Lock the surface down by raising the levels in[management], and read the effective set back from/management/env(or from the boot log line that names every mounted endpoint and its level) rather than assuming.
Important
The OAuth2
scopeclaim does not grant roles. A scope grants a client delegated authority (RFC 6749 §3.3); it asserts nothing about the subject’s roles. Reading it as one also made the at-least-one-role check pass for every OIDC token, sinceopenidalone satisfied it. If your callers rely on a scope naming a role, move that role into one of the role claims above. Scopes remain on the principal and still drive SMART scope enforcement and ABAC policy.
Note
The downloadable Compose quickstart deliberately runs with RBAC off and a single user, so every surface works out of the box (see Docker Compose). Turn the role gate on with
[authz.rbac] enabled = true(orFERROEHR__AUTHZ__RBAC__ENABLED=true) and give each principal an explicitroleslist.
A principal carrying the readonly_role (default READONLY) is refused on
every write operation (creating an EHR, committing a composition, uploading a
template, and any update/delete) even when it also holds granting roles such
as ADMIN (a restriction always overrides a grant). Reads and AQL queries stay
permitted, so a READONLY account is an authenticated, view-only principal. The
repository’s from-source development stack ships one such account
(ferroehr-readonly, password ferroehr) alongside ferroehr and
ferroehr-admin, with RBAC at its default enabled = true, so the separation
can be tried out; the downloadable quickstart file has neither (one user, no
role gate).
ABAC (attribute-based, fine-grained)
For attribute-level decisions (“may this user touch this patient’s data, under this organisation, for this template?”) enable ABAC. A policy decision point is consulted per clinical operation with resolved attributes. An enabled ABAC block that cannot be built (a missing or invalid policy directory, an unreachable-by-construction PDP client) aborts server startup: a configuration that promises fine-grained authorization never silently runs without it.
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__AUTHZ__ABAC__ENABLED | false | master ABAC switch |
FERROEHR__AUTHZ__ABAC__ENGINE | cedar | cedar (embedded) or remote (external PDP) |
FERROEHR__AUTHZ__ABAC__ORGANIZATION_CLAIM | organization_id | JWT claim for the organisation attribute |
FERROEHR__AUTHZ__ABAC__PATIENT_CLAIM | patient_id | JWT claim for the patient attribute (enables the subject gate) |
FERROEHR__AUTHZ__ABAC__CHECK_DIRECTORY | false | also submit DIRECTORY (FOLDER) operations to the policy engine |
FERROEHR__AUTHZ__ABAC__CEDAR__POLICY_DIR | — (required for cedar) | directory of *.cedar policy files |
FERROEHR__AUTHZ__ABAC__CEDAR__RELOAD_SECS | off | optional policy hot-reload interval |
FERROEHR__AUTHZ__ABAC__REMOTE__SERVER | — (required for remote) | PDP base URL (must end with /) |
FERROEHR__AUTHZ__ABAC__REMOTE__CONNECT_TIMEOUT_MS | 2000 | PDP connect timeout |
FERROEHR__AUTHZ__ABAC__REMOTE__REQUEST_TIMEOUT_MS | 5000 | PDP request timeout |
Two engines sit behind one interface. Cedar is the embedded default:
policies live in *.cedar files, are schema-validated at boot against a
shipped schema built from the resource-kind and access-mode sets themselves (an
invalid policy set stops the server rather than silently denying), and need no
external service. The remote PDP option consults an external policy server
over HTTP for deployments that already run one; it additionally requires an
[authz.abac.policy.<kind>] binding for every resource kind it will be asked
about, and a missing one is a boot error rather than a first-request surprise.
A policy sees the caller, not just the request: the authenticated subject, its roles (as the role layer above resolved them), its scopes, the resolved organization and patient, the resource’s patient, template, category and origin, and the operation id. So a rule can be written about one caller, a role, a scope, or a single operation; the shipped example policy shows a role-keyed break-glass permit and a scope-keyed write restriction.
Category and origin (EHDS Annex II 3.4 asks for access rights that take
the origins and categories of the data into account). On a composition read,
write or delete, a contribution and a query, resource.category holds each
EHDS category of the data, spelt as the access record spells it: the
<system>|<code> token of a priority or national category,
[audit.categories] classifying
the template, then the root archetype; none; or unclassified.
resource.origin holds each origin the data came from, as the version recorded
it at commit: a FEEDER_AUDIT originating-system id, the registered origin of
the committing client, or this server’s system_id
(keying on an origin
declares the register). A write is decided on what its body names: its
template’s category and the origin the commit will stamp. A FLAT or STRUCTURED
write is converted only after the check, so it carries the category its
template header implies and no origin attribute. A remote PDP receives both as
the category and origin parameters of [authz.abac.policy.<kind>].
// Laboratory reports fed by the lab are readable by the lab's own staff only.
forbid (principal, action == Action::"composition.read", resource)
when {
resource has category && resource has origin &&
resource.category == "http://hl7.eu/fhir/health-data-api/CodeSystem/eehrxf-document-priority-category-cs|Laboratory-Reports" &&
resource.origin == "lab.example" &&
!(principal has organization && principal.organization == "lab")
};
A request whose patient, template, category or origin resolves to several values is evaluated
over the full cartesian product of them, and every combination must permit;
the first deny short-circuits. A request that resolves to no combination at all
permits vacuously, because there is nothing to decide about.
Warning
Authorization is fail-closed in two distinct senses, and the difference shows up in the status code.
Nothing permits by omission: a gate reached without an authenticated caller refuses, an unconfigured resource kind on the remote PDP denies (and the missing rule is a boot error), and Cedar is deny-by-default with
forbidoverridingpermit. A denied decision is a403.And a stage that cannot decide is never read as a decision. An unreachable policy engine, a policy server answering
5xx, or a policy that errors during evaluation is a500: never a silent permit, and never a403, which would claim a decision was made. (Cedar skips a policy that errors and reports it in its diagnostics; ignoring those would let an erroringforbidquietly stop forbidding.) A4xxfrom a remote PDP is a decision, so it denies.When a patient claim is configured, a local subject gate also rejects access to another patient’s EHR before any policy call.
Response security headers
Three surfaces, three honest answers: the set differs because what they serve differs.
The REST API carries, on every response including the transport-layer ones
(413 from the body limit, 408 from the timeout, 500 from the panic handler):
| Header | Value | Why |
|---|---|---|
Cache-Control | no-store | responses carry patient data; the OWASP cheat sheet names no-store for exactly that. It does not affect openEHR’s ETag/If-Match concurrency control, which is a precondition mechanism, not a caching one |
X-Content-Type-Options | nosniff | stops a proxy or browser re-guessing application/json as something executable |
Referrer-Policy | strict-origin-when-cross-origin | request paths carry ehr_id and version identifiers; this keeps them out of cross-origin Referer headers |
Cross-Origin-Resource-Policy | same-site | refuses cross-site embedding of API responses |
X-Frame-Options | DENY | for the HTML this origin can serve (Swagger UI) |
Content-Security-Policy | default-src 'none'; frame-ancestors 'none' | the defensive minimum, applied wherever a response does not set its own; Swagger UI needs a real policy and brings one. The cheat sheet is explicit that CSP “might be meaningless in the response of a REST API that returns content that is not going to be rendered”, so this is not a policy pretending to govern scripts: it says nothing loads and nothing frames |
X-XSS-Protection and X-Powered-By are absent because the cheat sheet says to
remove them, and no Server header is sent at all.
Strict-Transport-Security is deliberately not sent by the API server. It is a
property of the TLS edge, and RFC 6797 §7.2
requires a browser to ignore it over plain HTTP, which is how this server is
commonly reached behind a terminating proxy. Set it at the proxy or ingress that
owns TLS; sending it from here would be inert at best and misleading at worst.
The viewer carries the FerroHEALTH console set instead, because it serves
HTML: a CSP that admits scripts and styles from its own origin alone
(script-src 'self'; style-src 'self', no nonce, no inline script or
style), Referrer-Policy: no-referrer and Cross-Origin-Opener-Policy: same-origin (the viewer chapter
lists them). Its Cache-Control has one scoped exception to no-store:
FerroUI’s stylesheet, theme and scripts under /ferrohealth-ui/<version>/
are served no-cache, so the browser keeps them and revalidates them, and
they hold nothing clinical (the viewer reaches the CDR from its own server,
never from the browser). Every document carries no-store, because
documents carry patient data. An asset response that is not a served body
(a 404, a redirect) is never kept either.
The published documentation site cannot carry response headers at all: it is
static files on GitHub Pages, so its policy travels as a <meta http-equiv>
element, which is weaker by specification (frame-ancestors, report-uri and
sandbox are ignored in meta form). Anyone re-hosting these docs behind a real
web server should send the headers properly instead.
Request limits and rate limiting
Four different protections, four different statuses, and an operator should be able to tell them apart from the status alone.
Connection bounds: [server] header_read_timeout_ms. The limit that applies
before a request exists, because a client that opens a socket and trickles
headers, or sends nothing at all, reaches none of the others: a client that has
not sent its request head in time, counted from accept, is disconnected, on the
plain and the TLS listener alike. On a kept-alive connection the bound counts
from the first byte of each later request head, and [server] idle_timeout_ms
(120 s by default) closes a connection that waits that long for its next request.
Keep it longer than the idle timeout of your clients (90 s for a Go client) and
of any load balancer in front, so they close an idle connection first.
Body size: [server] body_limit_bytes and [rest] body_limit_bytes. Two
tiers: the routes that accept bulk by design (template upload and
/message/import) take the listener’s limit, and the clinical surface the
narrower [rest] one when it is set. Over-limit is 413. The listener’s
default is the family’s 1 MiB; a deployment that uploads operational
templates raises it (the chart and the Compose files set 64 MiB), and one whose
compositions embed large DV_MULTIMEDIA data raises the clinical tier
deliberately.
Request rate: [server.caller_rate], unset by default and set by the chart
and the Compose quickstart. It sits inside authentication, keyed on the
authenticated subject, because a hospital behind one NAT is a single address and
address-keying a clinical API would throttle a whole site for one busy client.
Refusal is 429 with Retry-After.
Concurrency: [server] max_concurrent_requests, the admission bound.
Refusal is 503 with Retry-After; the health routes are never refused.
So: 503 means the server is full right now, 429 means you are asking too
fast, 413 means your payload is too big. Full key tables are on the
configuration page.
If you benchmark this server, turn the rate limiter off first, or you will
measure the limiter. Our own measurement lanes compose an overlay that disables
it, and both instruments refuse to write a record if the server answered any
429: a performance number that is really a configuration key is worse than no
number.
Operational surfaces: what is reachable, and by whom
Everything reachable without a credential is listed here, with the reason it
is public. Every other route answers 401 to a request without one, and a
test drives every mounted route to keep this list closed. A public answer says
only what a client or probe needs; the detail behind it is on the
authenticated /management surface.
| Surface | Default | Why it is public, or how it is gated |
|---|---|---|
/health, /health/liveness | always on, unauthenticated | Orchestrator probes carry no credential. Liveness answers {"state":"up"} with no I/O behind it; /health answers the readiness status with the state alone. |
/health/readiness | always on, unauthenticated | An orchestrator probe: 200 while ready, 503 while the process boots or drains or an indicator is down. The body is the state, the phase and each indicator’s name and state, nothing more. The detail is /management/health. |
{rest root}/status | always on, unauthenticated | A documented client contract: status, server version, ITS-REST version, timestamp and the deployment profile. The licence, the deployment gaps and the support period are /management/status. |
{rest root}/.well-known/smart-configuration | mounted only with SMART on | SMART App Launch service discovery, read before the client holds any token (ITS-REST SMART App Launch, Service Discovery). |
OPTIONS {base path} | always on, unauthenticated | The ITS-REST System API’s options manifest, which its released OpenAPI document declares without security. |
{rest root}/swagger-ui, {rest root}/api-docs/* | private (rest.swagger_ui) | Public only if you set public; admin_only and off are the other levels. |
/management/* | not mounted at all (management.enabled = false) | With the master switch off, every route is 404. |
/management/{info,metrics,prometheus,env,loggers,flamegraph,health,status} | each off individually | Even with the master switch on, each endpoint stays unmounted until you name a level for it. There is no global fallback: silence means off, so a surface this privileged opens one endpoint at a time, by name. |
management.port | unset (shares the API listener) | Set it to serve ops introspection from its own listener on its own port. It binds all interfaces and always stays plain HTTP even with [server.tls] on, so treat it as an internal surface and keep it off any publicly routed port; the interface half of the separation is your network’s, not this key’s. Under deployment_profile = "production" setting it opens the plaintext_listener gap, so the server refuses to start unless the gap is accepted by name. |
env and flamegraph deserve particular caution: env renders the
effective configuration (redacted, but still configuration), and flamegraph
starts a CPU profiler on request, which is both a disclosure and a
denial-of-service lever. Both are off until you name a level, and the profiler
additionally caps the window and sampling frequency, refusing an out-of-range
request rather than clamping it silently.
Secrets: mount files, never bake values
Every secret this server reads has a *_file sibling, and the loader reads and
trims the file at startup. Exactly one of a pair may be set; both is a boot
error naming the pair:
| Secret | File sibling |
|---|---|
| the database DSN, per storage domain, and the credential that prepares the schema | db.url_file, storage.<domain>.url_file, db.migrate_url_file |
| a Basic user’s Argon2 hash | auth.basic.users[].password_hash_file |
| the PGP signing-key passphrase | signing.key_passphrase_file |
| a terminology OAuth2 client secret | terminology.server.oauth.client_secret_file |
| the object-store secret key | multimedia.secret_access_key_file |
| the AMQP URL of the change events | events.url_file |
| the national-identifier root key | demographic.identifier_protection.key_file |
(The TLS certificate_file / client_ca_file settings and a static key
set’s auth.jwks_file are paths by nature and have no inline form at all,
which is the same property arrived at from the other direction.)
That is deliberately the shape Docker Secrets and Kubernetes Secrets deliver: a file mounted into the container. So the recommended posture needs no extra machinery.
services:
ferroehr:
environment:
# Point the key at the mount path; the VALUE never appears anywhere.
FERROEHR__SIGNING__KEY_PASSPHRASE_FILE: /run/secrets/signing_passphrase
secrets:
- signing_passphrase
secrets:
signing_passphrase:
file: ./secrets/signing_passphrase.txt # or `external: true` in swarm
Why files rather than environment variables: an environment variable is readable
from /proc/<pid>/environ by anything in the container’s namespace, appears in
docker inspect output, is inherited by every child process, and is routinely
captured whole by crash reporters and process listings. A mounted file is none of
those, and it can be rotated without recreating the container.
Two properties worth knowing because they are not obvious:
- Redaction is a property of the type, not a list. Secret-bearing fields are a
Secret/SecretUrlnewtype whoseDebugand serialization render***, so a new secret key cannot be forgotten by a per-endpoint redactor:/management/envandferroehr config checkshow***because the type does, not because something remembered to hide it. A Basic password hash in[auth]is asecrecysecret of the shared crate’s section, rendered***the same way. - A Kubernetes
Secretis base64, not encryption. The cheat sheet is blunt about this: Secrets are stored unencrypted in etcd by default. Enable encryption at rest or use an external manager; the chart mounts whatever you give it and cannot make an unencrypted store safe.
Warning
The downloadable quickstart carries an inline Argon2 hash for its throwaway
ferroehruser, because a self-contained demo file cannot reference a secret you do not have. That is the one place a credential appears in our own artifacts, and it is a development credential by construction; replace it before any real use.
Verifying what you pulled
Release binaries, container images and the Helm chart carry signed build provenance, SBOMs and checksums, so you can establish that the bytes you are about to run came from this repository’s own pipeline and see what went into them. The commands, the SBOM formats, the SLSA levels claimed per artifact, and the published VEX justifications for scanner findings are all in Verifying releases. Enforcing image provenance at admission time inside a Kubernetes cluster is covered separately, in Images: build, provenance, scanning.
The pseudonymisation boundary
The pseudonymisation boundary separates a record from the person it is about, and it is enforced by PostgreSQL grants rather than by the server’s own routing: code that reaches for the wrong schema is a bug that can be fixed, while a database role able to read two domains defeats the separation however correct the code is.
GDPR Art. 4(5) defines pseudonymisation as processing where attributing data to a person requires additional information “kept separately and subject to technical and organisational measures”, and Art. 32(1)(a) names it a security measure for health data. EDPB Guidelines 01/2025 require that separation to hold against internal actors, operators with database access included.
Three domains hold the three parts, each behind its own role:
flowchart LR
server["FerroEHR server"]
server -->|ferroehr_clinical| ehr[("clinical<br/>versions and nodes,<br/>keyed by an opaque subject pseudonym")]
server -->|ferroehr_party| demo[("party<br/>parties, and national identifiers<br/>sealed under a per-domain key")]
server -->|ferroehr_linkage| link[("linkage<br/>which party is the subject<br/>of which EHR")]
server -->|audit writer| audit[("audit<br/>ATNA record repository")]
ehr -. barred .- demo
ehr -. barred .- link
demo -. barred .- link
Read one domain and you hold a clinical record whose subject is an opaque
identifier, or a set of people with no records attached, or a table of two
identifier columns naming neither. Only the three together re-identify
anything, and no credential the server uses holds more than one of them. The
migrations create the roles, grant each its own schema, and revoke every
other domain explicitly, in both directions; each role is NOINHERIT and a
member of no other, so a grant cannot arrive through a membership.
The boundary is checked at startup, not assumed: the server reads the catalogue for every table, view, sequence and function each role can reach in a domain it does not own, and refuses to serve if it finds one, naming the role and the object. A misconfigured grant is a failed boot, not a silent weakening.
Two limits are worth stating plainly. The clinical schema holds a pseudonym
only when [privacy] subject_namespaces declares the namespaces a subject
reference may draw from — left unset, whatever a client sends is what gets
stored, and no schema split saves you from a national identifier written into
the clinical side; see
Privacy and identifiers. And the split is a
schema split by default: giving the demographic domain its own
[storage.party] url is what turns it into a credential split, which is the
deployment step described in
Operations. The linkage
domain takes its own credential from [storage.linkage] url the same way.
Resolving across the boundary
One question needs all three domains at once: which record belongs to this person? The server answers it in exactly one place — the linkage service — and answers it in the application, over two connections, never in the database.
An external identity (a national identifier, say) is matched against the
sealed party.national_identifier map by keyed digest, which returns a
party without decrypting anything. That party is then looked up in
linkage.subject_ehr on a second connection, with a second search path and,
when [storage.linkage] url is set, a second database role. No statement
performs the join, because no credential could: the linkage role holds no
grant in demographic and the demographic role holds none in linkage, both
revocations are explicit and in both directions, and the boot check refuses to
serve a database where either has been given one. The crossing exists as an
application step that can be audited, rather than as a query anyone holding
one password could write.
Every resolution — and every write that opens, merges or splits a mapping —
records an access event in the linkage domain, naming who asked, the purpose
of use they declared, and whether anything matched. A miss is recorded like a
hit: it says someone asked whether this deployment holds a record for that
person. The event deliberately does not name the EHR that came back. The
audit trail lives in its own schema, outside the linkage role, so a record
pairing a party with its EHR would be a second copy of the map the split
exists to hold apart.
Merges and splits are period-closing writes. A mapping that stops being true
gets an end date rather than a deletion, and the linkage role holds no
DELETE privilege to delete it with. “Which party was the subject of this EHR
when that composition was written” therefore still has an answer after two
person records have been merged. The database enforces one mapping in force per
party with a temporal primary key, so the rule holds against any code path, not
only the intended one.
Erasure is the one exception, and the role still cannot perform it: physically
deleting an EHR calls a SECURITY DEFINER function that removes the rows
naming that one EHR id, in force or historical. A row that survived would go
on asserting whose record an erased EHR was.
Looking an EHR up by subject (GET /ehr?subject_id=…&subject_namespace=…)
does not go through any of this. That operation matches the EHR’s own
EHR_STATUS.subject.external_ref, which is the opaque pseudonym the privacy
layer already constrains, so it resolves a pseudonym rather than an identity
and consults no map. It records a linkage-domain access event all the same,
because who resolved a subject to a record is worth knowing wherever it
happened.
The lookup puts the subject identifier in the URL, because ITS-REST passes it
as a query parameter. FerroEHR keeps it out of everything it writes: the
request log and every trace span record the path and the matched route without
the query string, and the 404 for an unknown subject names only the
namespace. A reverse proxy or load balancer in front of FerroEHR sees the whole
URL, so configure it not to log query strings on this route, or not at all.
FerroPIX as the EHR index provider (IHE PIXm, planned in
#3380) resolves a
patient identifier to an EHR without putting it in a FerroEHR URL.
Declaring the posture
The separations above are each a configuration key, and a deployment that has
made none of them is indistinguishable from one that has made all of them
until something goes wrong. The top-level deployment_profile key
(configuration) makes the
posture explicit: production refuses to start while a separation is missing
and not accepted by name, and reads the cluster each pool reached from
pg_control_system() rather than trusting the DSN text; sandbox, the
default, names every missing separation on the banner, in the boot log and on
GET /management/status, reports the profile on GET /rest/status, and must
not hold real patient data. The database keeps
its own line of defence for the subject pseudonym too: once
privacy.subject_namespaces is declared, a trigger on ehr refuses a subject
reference that is not a UUID, whichever code path or session writes it.
One instance, one organisation
FerroEHR is single-tenant, and that is where openEHR puts the boundary. BASE
architecture_overview/master06-design_of_the_ehr.adoc §The EHR System defines
a system as “a distinct logical repository corresponding to an organisational
entity that is legally responsible for the management and governance of the
healthcare data contained within”, and says it is “distinct from any underlying
virtualisation infrastructure or cloud computing facility, which may house
multiple logical EHR systems in a multi-tenant fashion”. Multi-tenancy belongs
to the layer that hosts several systems, not inside one of them.
So several organisations are served by several instances: one instance, one
database, one set of domain roles each. That is a stronger boundary than a row
predicate — a defect in a query cannot cross it, because there is nothing to
cross — and it is what the §System Identity rule needs, since system_id
“becomes embedded in the version identifiers of committed – and possibly
signed – content” and “cannot easily be changed afterwards”. One system_id
shared across organisations mints version identifiers that cannot tell the
responsible parties apart.
There is therefore no tenant column on any relation, no row policy, no session
variable to set and no [tenancy] configuration. What separates the clinical
record from the identity of its subject is the pseudonymisation boundary above,
which is a different question and stays.
Version signing
Every version the server commits can carry a VERSION.signature, computed
inside the write transaction over the canonical form of the version itself.
Signing is on by default in digest mode, and read-time verification of
the server’s own signatures defaults to strict, so a served version that no
longer matches its stored signature is a 500 rather than a silently served
record.
The chapter Version signing covers the mechanism in two
pages: Digest signing (what is signed, when, what the
stored value proves, and how to reproduce it yourself) and
PGP signing (key configuration and rotation, client-supplied
signatures, and the signature an import wrapper carries). The [signing] keys
are in the configuration reference.
ATNA audit trail
Separately from openEHR’s own provenance, FerroEHR keeps an IHE ATNA
security audit trail of API access: on by default, persisted in the
local Audit Record Repository (the dedicated audit PostgreSQL schema),
rendered in both official formats (FHIR R4 AuditEvent per IHE BALP, and
the DICOM PS3.15 audit message for the classic syslog feed), retrievable via
the RESTful-ATNA ITI-81 FHIR search, and optionally forwarded to an
external ARR over syslog and/or the ITI-20 FHIR feed. Node authentication
(ITI-19) is available as native mutual TLS on the listener.
Stored records are tamper-evident: each is linked into a SHA-256 hash
chain maintained by the database, the table refuses every rewrite path except
the forwarding stamp, and SELECT * FROM audit.verify_audit_chain() names any
record that was modified or removed. That is detection, not prevention: the
controls that make it hard to forge wholesale are the least-privilege database
role and the off-box sinks.
The full chapter (record content, sinks, tamper evidence, the ITI-81 search,
fail-mode semantics, and mTLS) is Audit trail (IHE ATNA);
every [audit] key is in the
configuration reference.
Note
The ATNA trail is orthogonal to openEHR’s own
CONTRIBUTIONandAUDIT_DETAILS, which the server always writes in the same transaction as every change. openEHR audit records what a version says about its own authorship; ATNA records security surveillance of API access. Both coexist. Identified data never enters telemetry (metrics, traces, logs), so the audit trail is the single place where access to identified data is recorded; see Operations.
Verifying releases
Before you run a FerroEHR binary, image, or chart in a clinical environment, you should be able to answer two questions about the bytes you downloaded: did they arrive intact, and were they built by this project’s own release pipeline. This page is the operator’s procedure for both: one command per artifact kind, plus the deliberately-failing runs that prove your verification is actually checking something.
Every release publishes signed build provenance, a dependency SBOM, and a plain checksum alongside each artifact. Provenance is signed through Sigstore, so you can verify it yourself, and the signer identity is one you can pin to a single hardened workflow.
The repository moved to the FerroHEALTH organization on 2026-10-01, and
v4.3.2 is the first release built there. Releases up to and including v4.3.1
were built and signed while it was rubentalstra/FerroEHR, so their
attestations name that repository: verify them with -R rubentalstra/FerroEHR
and --signer-workflow rubentalstra/FerroEHR/…. Their images and charts were
published under ghcr.io/rubentalstra. The copies of v4.3.1 under
ghcr.io/ferrohealth carry the same digests, so gh attestation verify accepts
them with the old repository name. Their cosign signatures exist only beside
the originals, so run cosign verify against the ghcr.io/rubentalstra path.
- What a release publishes
- A release binary
- Prove your verification can fail
- A container image
- The Helm chart
- The SBOMs, one per published artefact
- What SLSA level each artifact reaches
- The
openehr-*crates - Findings a scanner will report
- If verification fails
What a release publishes
Substitute the release tag you downloaded for <tag> (for example
v5.6.0-rc3) and the architecture for <arch> (x86_64 or aarch64) throughout
this page. Linux is the only published target.
| Asset | What it is |
|---|---|
ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz | the stripped server binary |
…tar.gz.sha256sum | a plain checksum of that tarball |
…tar.gz.sigstore.json | the Sigstore bundle for its build-provenance attestation |
…tar.gz.sbom.sigstore.json | the Sigstore bundle for its SBOM attestation |
…tar.gz.intoto.jsonl | the same provenance as one DSSE-wrapped in-toto statement per line |
ferroehr-<tag>-<arch>-unknown-linux-gnu.cdx.json | the CycloneDX dependency SBOM for that binary |
ferroehr-<tag>.spdx.json | the SPDX SBOM of the source tree at the release commit |
ferroehr-<tag>-vulnerability-record.json | every finding of a full scan of each image and binary, each with the OpenVEX statement that judges it (the record) |
ferroehr-<tag>-trivy-reports.tar.gz | the raw Trivy reports the record was joined from |
<crate>-<crate-version>.cdx.json | the CycloneDX SBOM of each published openehr-* crate |
<crate>-<crate-version>.crate.sbom.sigstore.json | the Sigstore bundle binding that SBOM to the .crate archive crates.io serves |
docker-compose.yml + the Keycloak and observability overlays | the quickstart stack, so a downloader never has to clone the repository |
Container images and the Helm chart are published to GHCR by their own lanes and carry their own attestations; see images and the chart below.
Note
The release lane refuses to publish a release whose asset set is incomplete. It creates the release as a draft, attaches every asset, checks the full expected set is present, and only then publishes, because a published release is immutable on this repository, so a missing asset could never be added afterwards. The remedy for a bad cut is a new patch version, never a retag.
A release binary
The floor: the checksum. Every tarball ships a .sha256sum beside it, and
this is the only verification available with neither gh nor cosign installed
, which is a realistic constraint in a locked-down clinical environment:
sha256sum -c ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sha256sum
The file records the bare filename, so this works in whatever directory you put
the two files in. On macOS, shasum -a 256 -c is the same check.
Warning
Be clear about what a checksum buys: it detects a corrupt or truncated download, not a substituted release, because anyone who could replace the tarball could replace the checksum beside it. Only the Sigstore bundle answers “who built this”. The checksum is a floor, not a substitute.
The real check: the provenance attestation.
gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
-R FerroHEALTH/FerroEHR
Without reaching GitHub. Each release also carries its Sigstore bundles as assets, so verification needs nothing but the artifact and the bundle, useful on an air-gapped host, and the only form in which the signature travels with the download:
gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
--bundle ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sigstore.json \
--repo FerroHEALTH/FerroEHR
The *.sbom.sigstore.json asset beside it is the same thing for the SBOM
attestation, so “which dependency graph was this binary built from” is
verifiable offline too. Verifying an SBOM attestation needs one extra flag:
gh attestation verify enforces the SLSA provenance predicate by default, so
a non-provenance attestation must name its predicate type explicitly:
gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
-R FerroHEALTH/FerroEHR \
--predicate-type https://cyclonedx.org/bom
Fully offline, with no call to GitHub at all: fetch the trusted key material once on a connected machine and carry it across with the bundle:
gh attestation trusted-root > trusted_root.jsonl # on a connected host
gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
--bundle ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sigstore.json \
--custom-trusted-root trusted_root.jsonl \
--repo FerroHEALTH/FerroEHR
Require the hardened signer. Without a signer constraint you are trusting that some workflow in this repository signed the artifact. Pin the one that actually did:
gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
-R FerroHEALTH/FerroEHR \
--signer-workflow FerroHEALTH/FerroEHR/.github/workflows/release-build.yml
That workflow is the reusable release-build lane described under SLSA levels below.
The binary also describes itself. From v4.0.1 on, the shipped binary
embeds its own compressed dependency list in a .dep-v0 linker section
(built with cargo auditable;
the section is allocated, so it survives the release lane’s strip). A
scanner that reads binaries — syft, trivy, grype, osv-scanner — recovers the
crate graph from the artifact itself, even when the release page’s SBOM never
travelled with it:
syft ferroehr -o cyclonedx-json # the extracted binary, not the tarball
From v4.3.5 on, the binaries inside the ferroehr and ferroehr-viewer
images are built with cargo auditable as well, so a scan of either image
lists the Rust dependencies of the binary it carries, and the
exploitability record judges them per image.
Images of earlier releases carry no such list: a scan of them sees the
operating-system layer only, and the server’s Rust dependencies are visible
through the tarball scan and its SBOM.
# <version> is v4.3.5 or later, without the v
trivy image --list-all-pkgs --format json ghcr.io/ferrohealth/ferroehr-viewer:<version> \
| jq -r '.Results[] | select(.Type == "rustbinary") | .Packages[] | "\(.Name) \(.Version)"'
Tip
gh attestation verifyreports success by exiting zero and printing nothing in currentghversions. Check the exit status in scripts rather than grepping for a success message.
Prove your verification can fail
A check that cannot fail proves nothing, so do both of these once, by hand, before you trust a passing run:
# 1. Tamper with the artifact — verification must refuse it.
printf 'x' >> ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz
gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
--bundle ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sigstore.json \
--repo FerroHEALTH/FerroEHR
# → Error: verifying with issuer "sigstore.dev"; exit status 1
# 2. Name a repository that did not build it — also non-zero.
gh attestation verify … --repo someone-else/something
Re-download the tarball afterwards. Only once you have seen both refusals does a passing run mean something.
Cross-check the digest. The same digest is produced independently in three places, so they are worth comparing against each other rather than trusting any one of them:
# the published checksum file
cat ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sha256sum
# the digest the provenance statement was signed over
jq -r '.payload' ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.intoto.jsonl \
| base64 -d | jq -r '.subject[].digest.sha256'
# the bytes on your disk
sha256sum ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz
A container image
The three images (ferroehr, ferroehr-viewer, ferroehr-postgres) each
carry a Sigstore-signed SLSA provenance attestation, plus the SPDX SBOM and
provenance the builder writes onto the image index itself.
gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:5.6.0-rc3 \
-R FerroHEALTH/FerroEHR
Important
Image tags carry no
vprefix. A release publishes4.0.0,3.20andlatest(plus asha-…tag per commit), while the release assets are named after thev4.0.1git tag. Usingv4.0.1as an image reference will simply not resolve.
The development tags (ghcr.io/ferrohealth/ferroehr:main and its two
siblings) are signed the same way, so you can rehearse the command against them
before a release.
Add --signer-workflow FerroHEALTH/FerroEHR/.github/workflows/build-image.yml
to require the hardened image-build lane specifically rather than any
workflow in this repository.
Verify by digest where it matters. A tag is mutable, so anything that GATES on verification (admission control, a deploy script) should resolve the tag once and verify the digest it resolved — otherwise the bytes verified and the bytes pulled can differ:
digest=$(docker buildx imagetools inspect ghcr.io/ferrohealth/ferroehr:5.6.0-rc3 \
| awk '/^Digest:/{print $2}')
gh attestation verify "oci://ghcr.io/ferrohealth/ferroehr@${digest}" \
-R FerroHEALTH/FerroEHR
The attestation is also pushed to the registry itself, so a host with
registry access but no GitHub API path can verify from the registry alone
with --bundle-from-oci. And the SBOM the builder wrote onto the image index
is readable without any verifier at all:
docker buildx imagetools inspect "ghcr.io/ferrohealth/ferroehr@${digest}" \
--format '{{ json .SBOM }}'
Enforcing this at admission time in a Kubernetes cluster is a separate job with its own machinery; see Images: build, provenance, scanning.
The Helm chart
The chart carries a keyless cosign signature in addition to its provenance attestation. The two are not redundant: the attestation says what the chart was built from, the signature says who signed the artifact you pulled, and the signature is what Helm-ecosystem tooling looks for.
cosign verify ghcr.io/ferrohealth/charts/ferroehr:<chart-version> \
--certificate-identity-regexp '^https://github\.com/FerroHEALTH/FerroEHR/\.github/workflows/build-chart\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Both flags are the point: without an identity and an issuer, cosign verify
accepts a signature from anyone in the transparency log.
gh attestation verify oci://ghcr.io/ferrohealth/charts/ferroehr:<chart-version> \
-R FerroHEALTH/FerroEHR \
--signer-workflow FerroHEALTH/FerroEHR/.github/workflows/build-chart.yml
Note
The chart version is not the product version. The chart runs its own SemVer line, so a chart version and the
appVersionit deploys move independently. Read the chart’sappVersionto learn which server release it defaults to.
The chart ships no PGP .prov file, so helm install --verify does not
apply. That is deliberate: a .prov needs a long-lived private key in CI, and
keyless Sigstore signing gives a consumer a stronger identity to pin without
one.
The publishing lane refuses to overwrite a chart version that already exists,
reads both the signature and the attestation back from the registry before
reporting success, and checks that the appVersion image accepts the chart’s
rendered defaults. It also attests the chart’s SBOM, which names every
image the chart deploys by digest
(the SBOMs).
The SBOMs, one per published artefact
Every artefact this project publishes carries a software bill of materials, and each one answers a different question about a different download:
| Artefact | SBOM | Where | Format | Answers |
|---|---|---|---|---|
| the server binary | ferroehr-<tag>-<arch>-unknown-linux-gnu.cdx.json | a release asset per architecture, attested against the tarball (…tar.gz.sbom.sigstore.json) | CycloneDX 1.5 | what is inside the binary I am about to run? Every cargo component with a pkg:cargo/… purl and licence, most with checksums, and the dependency edges, so “is this crate direct or four levels down” is answerable. This is what a vulnerability scanner consumes. |
| each container image | the image SBOM | written onto each image index by the builder | SPDX | what is in the image’s OS layer? Which is what matters for ferroehr-postgres, built on the upstream postgres image. |
each openehr-* crate | <crate>-<crate-version>.cdx.json | a release asset per crate, attested against the .crate archive crates.io serves (<crate>-<crate-version>.crate.sbom.sigstore.json) | CycloneDX 1.5 | what does this library pull into my build? The crate’s normal and build dependencies; dev-dependencies are left out, as cargo package strips them. |
| the Helm chart | the chart SBOM | an attestation on the chart’s OCI digest in GitHub’s attestation store | CycloneDX 1.5 | which images does this chart deploy? Each image the chart can deploy, named by repository, tag and the digest the tag resolved to when the chart was published, with its platforms. |
| the source tree | ferroehr-<tag>.spdx.json | a release asset per release | SPDX 2.3 | what am I redistributing, and under what terms? The attribution and licence-obligation view of the source tree at the release commit, the document a legal or procurement reviewer of a multi-licence redistribution asks for. |
The crate SBOMs ride the server release that ships them, so <crate-version>
is the crate’s own version (the crates run their own SemVer line). A crate
version that did not change in a release is described again, against the
archive crates.io already serves. Verify a crate SBOM against the archive you
downloaded:
curl -sSLO https://static.crates.io/crates/openehr-rm/openehr-rm-<crate-version>.crate
gh attestation verify openehr-rm-<crate-version>.crate \
-R FerroHEALTH/FerroEHR \
--predicate-type https://cyclonedx.org/bom \
--signer-workflow FerroHEALTH/FerroEHR/.github/workflows/release.yml
Read the chart SBOM, and verify it in the same step:
gh attestation verify oci://ghcr.io/ferrohealth/charts/ferroehr:<chart-version> \
-R FerroHEALTH/FerroEHR \
--predicate-type https://cyclonedx.org/bom \
--signer-workflow FerroHEALTH/FerroEHR/.github/workflows/build-chart.yml \
--format json --jq '.[].verificationResult.statement.predicate'
CycloneDX 1.5 is the highest version cargo-cyclonedx emits (it accepts 1.3,
1.4 or 1.5 and defaults to 1.3, so the release lane sets it explicitly); it
carries everything 1.6 consumers read. The release lane refuses an SBOM that
is not the format it claims or that names another artefact: the repository
SPDX document must name itself ferroehr and list at least one package, a
crate SBOM must describe that crate at that version, and every image in the
chart SBOM must resolve to a sha256 digest before the chart is pushed.
What SLSA level each artifact reaches
The levels are the SLSA v1.2 Build track (the current spec; v1.0 is marked Retired, and the Build-track requirements are unchanged across those versions — GitHub’s own documentation still phrases the same construction against “SLSA v1.0”). They differ per artifact, so a table is the honest form:
| Artifact | Level | Why |
|---|---|---|
| release binaries + their SBOM | Build L3 | built and attested inside a reusable workflow (release-build.yml), so the signing material is out of reach of any caller-defined step |
| container images | Build L3 | built, pushed and attested inside a reusable workflow (build-image.yml); the calling jobs pass names and label text, never steps |
| the Helm chart | Build L3 | packaged, pushed, signed and attested inside a reusable workflow (build-chart.yml); the caller carries only triggers |
Images and the chart reach L3 from the first publish after v4.0.1; earlier artifacts were attested at Build L2 (in the job that built them), and their attestations verify with the repository-scoped command, not the pinned signer.
Build L3’s distinguishing requirement is that secret material used for
authenticating the provenance “MUST NOT be accessible to the environment
running the user-defined build steps”. Every step of a GitHub Actions job shares one runner VM, so
attesting inside the building job cannot satisfy it. The release lane therefore
builds and signs inside a reusable workflow: it runs on its own VM, and a caller
passes declared inputs; it cannot add steps. The calling job has no steps at
all, which is what makes the property hard to lose by accident, and it is why
--signer-workflow is worth passing.
What is still not claimed, in either lane: the isolation is GitHub’s rather than this project’s, and nothing here asserts a reproducible or hermetic build; those are separate SLSA tracks this project does not address. Provenance proves where an artifact was built, not that the source was good. Naming the boundary is worth more than rounding a level up.
The openehr-* crates
The nine specification crates publish to crates.io through Trusted
Publishing: the workflow authenticates with a short-lived OIDC token, and no
long-lived crates.io token exists anywhere. Be precise about what that does
and does not give you: it is authentication, not provenance. crates.io
records and displays nothing about how a crate was built, and the Rust RFC
that introduced Trusted Publishing lists provenance verification as
explicitly out of scope — there is no registry-side attestation to verify,
from this project or any other. cargo checks the crate’s checksum against
the registry index, and that is the whole registry-side story. The crates’
source of truth is this repository: the publishing lane, the tags, and the
public history.
What this repository does attest is each crate’s SBOM, against the very
.crate archive crates.io serves; the crates.io leg of the release reads that
attestation back from crates.io’s own copy after publishing. See
the SBOMs for the command.
Findings a scanner will report
Run a scanner over a FerroEHR artifact and it will report findings. Every one
this project has assessed and accepted is published as an
OpenVEX document under
security/vex/,
carrying a controlled-vocabulary justification and an impact statement you can
check, rather than an ignore entry that records only the verdict. Point your
tooling at them (trivy --vex, and most SCA platforms take an OpenVEX feed).
| Document | Covers |
|---|---|
rust-advisories.openvex.json | the Rust dependency advisories: the ones the advisory gate accepts, plus one that only a Cargo.lock-reading scanner reports |
ferroehr-os.openvex.json | the Debian packages the ferroehr and ferroehr-viewer images inherit from their distroless base (glibc, the GCC runtime libraries, zlib) |
postgres-os.openvex.json | the Debian packages of the ferroehr-postgres image, one statement per finding |
A statement with status affected names a finding that applies to you, with
an action statement saying what to do until the fix ships. trivy --vex
leaves those findings in its output on purpose; only not_affected and
fixed statements remove one.
The ferroehr-postgres image is the upstream postgres:18.6 image with
Debian’s security updates applied at build time, minus two things: GnuPG and
the libraries only it uses (upstream needs it only to import a package signing
key while building the image), and upstream’s gosu, a Go binary, which a
short shell wrapper around util-linux setpriv replaces under the same name.
Every finding that remains is a Debian package with no fix in Debian yet. Six
are affected: three libxml2 findings and two glibc character-converter
findings, which any database login reaches through PostgreSQL’s XML functions,
and the resolver finding CVE-2026-8674. FerroEHR sends no XML to the database,
so the action statements come down to giving database logins only to the
FerroEHR roles and your administrators, and keeping the resolver configuration
the container receives under your control.
The Rust document is generated from deny.toml (the gate that actually
decides whether a build passes) joined with the published reasoning, and a CI
job fails if the two disagree in either direction. So an advisory cannot be
accepted without a justification reaching you, and a justification cannot claim
something the gate does not do.
The ferroehr-os.openvex.json statements argue from what the one program in
each image loads and imports: libgcc_s, libm, libc and the loader, no
RPATH or RUNPATH, no dlopen, no C++, and a named list of glibc functions it
never calls. Each statement carries those facts as data in a ferroehr:linkage
block, and the release pipeline reads them back off every ferroehr and
ferroehr-viewer binary it builds, on amd64 and arm64, with readelf before
the image is assembled. A binary that needs another library or imports a listed
function stops the release, so the statement you read describes the binary in
the image you pull. You can repeat the check on a binary you extracted from an
image: bash scripts/checks/vex-linkage.sh <path>/ferroehr (it needs GNU
readelf and jq).
One asymmetry worth knowing, because it produces findings that are real reports
of nothing. Cargo.lock records the union of every dependency any feature
combination could pull, so a scanner reading the lock file alone reports
crates this project’s feature set never compiles. cargo deny resolves features
and does not. Where the two disagree in that direction the feature-resolving
tool is the more precise instrument, and the VEX document carries that argument
for the specific crate it applies to, so you do not have to take our word for it
in prose.
The published images are additionally re-scanned on a weekly schedule at the tag an operator actually pulls. A fixable high-or-critical finding files or updates one tracker issue, and the issue is the alert: the scheduled run itself stays green, because a red scheduled run nobody looks at is not a control.
The exploitability record
The scan that gates an image blocks only high and critical findings that have
a fix. Every release therefore also scans each image (ferroehr,
ferroehr-viewer, ferroehr-postgres, both platforms, at the digest the
release pushed) and each server binary (x86_64 and aarch64) with no
severity floor and unfixed findings included, and publishes the result as
two release assets:
ferroehr-<tag>-trivy-reports.tar.gz: the raw Trivy JSON reports, one per image and platform and one per binary.ferroehr-<tag>-vulnerability-record.json: every finding from those reports, joined with the OpenVEX statement undersecurity/vex/that judges it. Each entry names the product (pkg:oci/<image>orpkg:cargo/ferroehr-server), the vulnerability id, the package and its installed version, the fixed version where one exists, the severity, and the statement: its status (not_affected,affectedorfixed), its justification, impact statement or action statement, and the document it comes from.
The release is refused while any finding has no such statement: the record is
checked before the images are tagged, so a refused release leaves its images
untagged and its GitHub release a draft. The rule for a finding with a fix is
to fix it by rebuilding, not to judge it. A statement counts only when it is a judgement:
under_investigation does not, and neither does not_affected without a
justification or impact statement, or affected without an action statement.
Read what a release shipped and why:
gh release download <tag> -R FerroHEALTH/FerroEHR -p 'ferroehr-*-vulnerability-record.json'
jq -r '.records[] | [.product, .id, .severity, .statement.status, .statement.justification] | @tsv' \
ferroehr-<tag>-vulnerability-record.json | sort -u
If verification fails
A failing verification is a security report, not a support question. Do not run the artifact, and follow SECURITY.md: report privately, never as a public issue. Note that only the newest release receives fixes; there is no maintenance branch to backport to.
Threat model
This chapter says what FerroEHR defends against, how, and (more usefully)
what remains true after the control has done its job. It exists because the
parts of this system that actually protect patient data are the parts no
external specification governs: openEHR’s REST specification makes
authentication a SHOULD and mandates no scheme, and no openEHR specification
governs authorization, so only the 401-versus-403 split is
specification-grounded. Everything finer is this project’s own design, and a
design nobody has written down asks every deployer to reconstruct it privately.
- What this is, and what it is not
- Assets
- Actors
- Trust boundaries
- What each database credential can reach
- Re-identification
- What is explicitly NOT defended against
- Reporting a finding
What this is, and what it is not
This is an attack-surface and trust-boundary analysis with named residual risk. Boundaries are enumerated, the control at each is stated, and the risk that survives the control is stated beside it.
It is not a certification, an audit report, or an assurance case in the formal sense, and it does not make FerroEHR compliant with anything. It is also not a substitute for your own analysis: your deployment introduces boundaries this document cannot see: your identity provider’s token policy, your network, your operators, your backups. The value here is that you do not have to reverse-engineer our half of it from configuration prose.
Read it alongside Security (how each control is configured), Audit trail (what is recorded), Verifying releases (establishing what you are running), Operations (the deployment surface) and Cluster hardening (what the platform owes).
Where this document and the code disagree, the code is right and this document is a defect. Report it.
Assets
What an attacker wants, in the order the loss hurts:
| Asset | Where it lives | Why it matters |
|---|---|---|
| Clinical payload: compositions, EHR status, folders | the clinical schema’s node and version tables, archival partitions included | this is PHI; disclosure is the primary harm and it is not undoable |
| Demographic parties and their identifiers | the party schema, archival partitions included, with protected national identifiers sealed in national_identifier | who the people are, held apart from what is recorded about them |
| The EHR id / subject cross-reference | linkage.subject_ehr | the additional information that re-attributes a pseudonymised record to a person; on its own it names neither |
| The audit trail | the audit schema, plus any configured forwarding sink | it is the evidence that everything else happened; an attacker who can edit it can make an access disappear |
| Version history and its integrity | version, vo_head, contribution, attestations | an openEHR record’s value is that it is append-only and attributable; a silently rewritten prior version is worse than a deleted one |
| Signing keys | the configured signing key material for commit attestation | forging an attestation forges provenance of clinical content |
| Bearer credentials and password hashes | tokens in flight; Argon2id PHC hashes at rest | a stolen token is an authenticated clinical caller until it expires |
| Instance separation | one instance, one database, one set of domain roles per organisation | two organisations sharing one instance is a breach of both at once |
| Availability | the whole stack | a CDR that is down is a clinical system that is down; denial of service is a patient-safety issue here, not an inconvenience |
Actors
| Actor | Reaches | Assumed capability |
|---|---|---|
| Unauthenticated caller | the listening port | can send arbitrary bytes, replay anything observed, and probe every route |
| Authenticated clinical caller | the openEHR API within their grants | holds a valid token; may be a legitimate user acting outside their remit, or a compromised client |
| Admin / management caller | the admin, management and messaging surfaces | can delete physically, archive, dump and load; the most dangerous authorized actor |
| Database | everything stored | trusted for confidentiality, but assumed to be a place where a mistake is permanent |
| Terminology server (FHIR R4B) | called out to at validation and commit time | operator-configured; semi-trusted: it answers our questions and can lie |
| Object store (S3) | called out to for multimedia | operator-configured; semi-trusted, and its responses are parsed |
| Message broker (AMQP) | receives change events | operator-configured; a sink for data, so a confidentiality boundary |
| Identity provider | issues the tokens everything else believes | fully trusted by construction; if it is compromised, no control below it holds |
| Cluster / host operator | the process, its memory, its filesystem, its secrets | fully trusted; outside the software’s reach |
Trust boundaries
flowchart LR
subgraph Untrusted
U[Unauthenticated caller]
C[Clinical client]
A[Admin client]
end
subgraph Deployment["Deployment boundary (operator-controlled)"]
subgraph Server["FerroEHR process"]
B1["B1 network + protocol"]
B2["B2 authentication"]
B3["B3 authorization<br/>EHR_ACCESS - RBAC - ABAC - SMART"]
B4["B4 content validation"]
B5["B5 instance separation"]
end
DB[("B6 PostgreSQL")]
AUD[("B7 audit store")]
end
subgraph External["Semi-trusted peers"]
IDP[Identity provider]
TS[Terminology server]
S3[Object store]
MQ[Message broker]
end
U --> B1
C --> B1
A --> B1
B1 --> B2
B2 --> B3
B3 --> B4
B4 --> B5
B5 --> DB
B5 --> AUD
B2 -.verifies tokens from.-> IDP
B4 -.asks.-> TS
B4 -.stores blobs.-> S3
B5 -.publishes.-> MQ
Each boundary below names the control that holds and the risk that remains.
B1 — Network and protocol
Control. TLS terminates either at the server (rustls, TLS 1.3 by default)
or in front of it. Connection-level bounds apply before a request even exists (an
HTTP/1 header-read timeout, an HTTP/2 stream cap with keep-alive PINGs); request
bodies are size-limited, requests are timed out, concurrency is capped by an
admission limit, and a two-tier tower_governor rate limit is on by
default. Panics are caught and become a clean 500 rather than a dropped
connection, and release builds run with overflow checks on so an arithmetic
mistake is a loud panic rather than a silently wrong number. Response security
headers are set. Every parser that reads attacker-controlled bytes (canonical
JSON, canonical XML, AQL, ADL, OPT 1.4 templates, the simplified formats, and
the identifier types) has a libFuzzer harness, built on the pull-request path
and fuzzed on a nightly campaign, and a crash is fixed in the crate, never in
the harness.
Residual risk.
- Fuzzing finds crashes; it does not prove their absence. The harnesses cover the parsers we identified as reachable. A parser reached by a path we did not enumerate is not covered by anything.
- Rate limiting is per-process. A multi-replica deployment behind a load balancer divides the effective limit by the replica count; it is not a distributed limiter. The shipped defaults are also deliberately generous: they sit above this implementation’s own measured whole-server ceiling, so the limiter refuses abuse rather than shaping normal load.
- Algorithmic complexity in query execution is not bounded by the limiter. A syntactically small AQL query can be an expensive one. Statement timeouts at the database are the backstop, and they are the operator’s to set.
- TLS termination in front of the server is the operator’s; nothing in the software can tell whether the hop behind the proxy is encrypted.
B2 — Authentication
Control. Two mechanisms: HTTP Basic against Argon2id PHC hashes with a
verified-credential cache, and OAuth 2.0 bearer tokens validated against the
issuer’s key set by ferrohealth-smart. Configuration is validated at boot,
not on the first request: a mandatory audience list, an https issuer whose
metadata and key set are read at start, asymmetric algorithms only (with none
and every HS* algorithm refused), and the OWASP Argon2id parameter floor. A
malformed Authorization header is a 400, not a 401: the server never read
a credential. An unreachable issuer is a 503, never a silent pass. A 401 body
names no reason, so it is not an oracle for expired-versus-forged.
Residual risk.
- A valid token is a valid caller. FerroEHR verifies signature, audience, issuer and expiry; it does not and cannot know whether the human behind the token is who the token says. Token theft is defended against by your identity provider’s lifetimes and binding, not by us.
- There is no token binding, no proof-of-possession and no replay window. A bearer token replayed from another network location is accepted.
- The identity provider is unconditionally trusted. A compromised issuer, or an attacker who can mint tokens with the right audience, defeats every authorization stage below, all of which read their inputs from claims.
- Basic authentication has no session, no lockout and no second factor. It is intended for machine callers and small deployments; the Argon2id cost is the only brake on offline cracking if a hash leaks.
B3 — Authorization
Control. Five stages, each of which can only narrow what the previous one allowed, which is what makes an optional stage safe to leave off:
- Authentication produces a principal or a typed refusal.
EHR_ACCESSis always on and unconditional, from the openEHR Reference Model’s own gateway clause.- RBAC checks the coarse operation class (public, clinical, or admin)
against roles taken from the RFC 9068 claim carriers. An OAuth2
scope is deliberately not treated as a role. The read-only restriction
overrides any grant. (The
/management/*surface is not in this classification: it is governed endpoint-by-endpoint by its own configuration, and an endpoint you do not name is not mounted at all.) - ABAC (off by default) evaluates subject and resource attributes through embedded Cedar or an external policy decision point, fanning multi-valued attributes out as a cartesian product with all-must-permit and short-circuit deny.
- SMART scopes (off by default) are AND-composed onto the ABAC decision.
Deny-by-default throughout, and fail-closed in both senses: no stage permits
by omission (an unconfigured resource kind on the external PDP denies, and the
missing rule is a boot error), and a stage that cannot decide is never read as
a decision: an unreachable issuer is 503, a policy server answering 5xx or a
Cedar policy that errors during evaluation is a fail-closed 500. Silence is
never consent, and a broken control never looks like a policy outcome.
Residual risk.
- Coarse by default. With ABAC off (the default) authorization is
role-level and
EHR_ACCESS-level. Any authenticated clinical caller can reach any EHR thatEHR_ACCESSdoes not restrict. If your model is “a clinician may read only their own patients”, that is ABAC, and you must turn it on and write the policy. - Policy is only as good as the attributes. Every decision is made from
claims the identity provider asserted. A wrong
organizationclaim produces a correct decision on a false premise. - The external PDP is a network dependency in the request path. Fail-closed means an outage is an outage: correct, and an availability risk you should size.
- Authorization is enforced at the API. Anything with a direct database connection is past it entirely; see B6.
- A legitimate caller acting outside their remit is not prevented, only recorded. That is what the audit trail is for, and detection is not prevention.
B4 — Content validation and outbound calls
Control. Committed content is validated against the operational template
(the WebTemplate walker), the Reference Model’s own invariants (machine-derived
from the specification’s meta-model rather than hand-written) and terminology
bindings. Canonical JSON is read by a strict reader that refuses undeclared
and duplicate keys. Outbound terminology, object-store and broker calls go only
to operator-configured endpoints: terminology goes to the one configured FHIR
terminology server ([terminology.server], FerroTERM in every shipped
deployment), authenticated with an OAuth 2.0 client-credentials token when it
sits behind an authorization server, and verified against the platform trust
store. The one outbound call that is on by default is
the usage report to report.ferropulse.eu, which
[usage_report] enabled = false switches off; it sends a fixed set of fields
and reads nothing from the answer beyond its status code.
Residual risk.
- The terminology server is trusted to answer honestly. A compromised or misconfigured one can permit a code that should have been refused. It is a correctness boundary, and a clinical-safety one.
- A response parsed from a semi-trusted peer is still parsed. A terminology answer, an object-store response and a broker message are all parsed by the server, so a compromised endpoint reaches a parser rather than only a validator. The object-store path once carried an XML parser version with denial-of-service advisories the client-facing canonical-XML codec did not share; both are now on the patched version, and the only pre-fix copy left in the build reaches it through the flamegraph renderer, which writes SVG and parses nothing. That argument is published as a machine-readable VEX document rather than left in a comment. Read the current documents for the current position: they name the versions, and they are regenerated from the gate.
- Multimedia blobs are stored, not inspected. FerroEHR is not an antivirus and does not pretend to be; a malicious blob is delivered faithfully to whatever opens it.
- Validation does not make content true. A well-formed composition asserting a clinically wrong fact is stored, correctly.
B5 — Instance separation
Control. One instance serves one organisation: one database, one set of
domain roles. There is no tenant column, no row policy and no session setting
to get wrong, because there is nothing inside the instance to separate. That is
also where openEHR puts the boundary — BASE architecture_overview
master06-design_of_the_ehr.adoc §The EHR System calls a system “a distinct
logical repository corresponding to an organisational entity that is legally
responsible” for the data and “distinct from any underlying virtualisation
infrastructure or cloud computing facility, which may house multiple logical
EHR systems in a multi-tenant fashion”.
Residual risk.
- Separation is now a deployment property, so a deployment mistake is the whole risk. Two organisations pointed at one instance share everything; nothing in the software will catch it, because there is no longer a tenant for it to compare against. The compensating control is that the mistake is visible in the deployment, not buried in a session variable.
- Instance separation does not separate resources by itself. Two instances on one node still compete for CPU, connections and disk unless the platform bounds them.
system_idmust differ per instance. It “becomes embedded in the version identifiers of committed – and possibly signed – content” and “cannot easily be changed afterwards” (§System Identity), so two instances sharing one value mint version identifiers that cannot tell the responsible parties apart.
B6 — The database
Control. The application connects with least-privilege roles, one per
pseudonymisation domain, and the schema is prepared by a separate credential
that is not any of them
(what each credential reaches). FORCE ROW LEVEL SECURITY
means even a table owner is subject to the policies. Version history is
temporal (PRIMARY KEY … WITHOUT OVERLAPS) rather than overwritten, so a prior
version is a row that exists, not one that was replaced. Every write emits a
contribution and an audit record in the same transaction.
Residual risk.
- Anyone with a database connection is past every control in B1–B5. That is the single largest residual risk in this system, and it is inherent: FerroEHR enforces authorization at the API. Protect the credentials and the network path accordingly.
- Encryption at rest is the operator’s. FerroEHR does not encrypt clinical payload before it reaches PostgreSQL; the payload is queryable JSONB, which is what makes AQL possible and is incompatible with application-level encryption of the same fields.
- A superuser can rewrite history. The temporal design makes accidental
overwrite structurally hard; it does not defend against someone with
UPDATEon the tables. The audit chain (B7) is the detection layer. - Backups carry PHI with none of these controls. A restored dump is a complete copy of the schemas it covers. Which schemas that is, and what two dumps are worth together, is Backup artefacts below.
B7 — The audit trail
Control. Every access is recorded in both official renderings (DICOM PS3.15
and FHIR R4 AuditEvent/BALP) into a local Audit Record Repository, on by
default, with optional syslog and ATX:FHIR-Feed forwarding. Refusals (401,
403, and the 400 a malformed credential header earns) are always recorded,
and an unattributable denial is recorded as unattributed rather than under a
fabricated subject. Records are hash-chained, so
SELECT * FROM audit.verify_audit_chain() names any record that was altered
or removed, and the server runs that verification itself on a schedule
(tamper evidence). ITI-81 is the retrieval side; ITI-19 mutual TLS is available for
node authentication.
Residual risk.
- Tamper evidence, not tamper proof. The chain proves alteration happened; it does not prevent it. An attacker with write access to the audit schema can rewrite the chain from the point of compromise forward, which is precisely why forwarding to an off-box sink matters, and why the sink should not be writable by the same identity.
- A local repository shares the blast radius of the thing it audits. If the database is lost, so is the local audit trail.
- The trail records what the server saw. A read performed directly against the database appears nowhere in it.
- Audit records are themselves sensitive. They name patients, subjects and actions; ITI-81 retrieval is a PHI-disclosure surface and is authorized like any other.
B8 — The supply chain
Control. Releases are built by a reusable workflow that satisfies SLSA v1.0
Build L3, signed through Sigstore with a signer identity a consumer can pin,
carrying a CycloneDX dependency SBOM, a SPDX repository SBOM, in-toto
provenance and a plain sha256sum; the verification procedure is
Verifying releases. The release is created as a draft
and only published once its full asset set is present, because a published
release is immutable. Container base images are digest-pinned, and the published
images are re-scanned weekly at the tag an operator actually pulls, with a
fixable high-or-critical finding both failing the run and filing an issue. Every
action is pinned to a commit SHA, workflows are audited by zizmor on every pull
request, and accepted advisories carry published VEX justifications. Commits and
tags are signed and the tags are protected by a ruleset.
Residual risk.
- Provenance proves where an artifact was built, not that the source was good. Build L3 says these bytes came from this workflow at this commit. It says nothing about what that commit contained.
- The build is not hermetic and not reproducible. Those are separate SLSA tracks this project does not address. A dependency resolved at build time is trusted to be what the lock file names.
- Review capacity is one person. No second human is structurally required between an idea and a released binary; the mitigation is machine enforcement and it is honestly recorded in GOVERNANCE.md.
- Fixes ship in the newest release only. Only the newest release is supported, and a fix arrives as a newer release; there is no maintenance branch to backport to. Cadasto B.V. commits to keep publishing security fixes for at least five years after each release (CRA Art 13(8) and (10)). See SECURITY.md.
What each database credential can reach
Every control from B1 to B5 is enforced at the API, so a database credential
is past all of them. What one credential holds is therefore the boundary that
survives a leaked connection string, and it is a property of the grants rather
than of the server’s routing. The migrations under
app/ferroehr/migrations/ apply those grants, revoke each domain from the
roles that do not own it, and make every domain role NOINHERIT and a member
of no other, so a privilege cannot arrive through a membership.
| Credential | Reaches | Cannot reach |
|---|---|---|
ferroehr_clinical | clinical, archival partitions included, with SELECT, INSERT, UPDATE and DELETE; the ext.posture stamp; USAGE on ext; in audit, record an event, stamp it forwarded, run the retention reaper and verify the chain | party and linkage, revoked explicitly and in both directions |
ferroehr_clinical_reader | SELECT on clinical; USAGE on ext; read the audit repository and verify the chain | the same two schemas |
ferroehr_party | party, archival partitions included, with SELECT, INSERT, UPDATE and DELETE, the sealed national_identifier rows included; EXECUTE on party.resolve_national_identifier | clinical and linkage, revoked explicitly and in both directions |
ferroehr_party_reader | SELECT on party. On national_identifier the table-level grant is revoked and re-granted column by column, so it reads id, party_id, scheme, created_at and retired_at and never lookup_digest, nonce or ciphertext | clinical and linkage |
ferroehr_linkage | linkage.subject_ehr with SELECT, INSERT and UPDATE; EXECUTE on linkage.erase_ehr; USAGE on ext | clinical, party, and party.resolve_national_identifier by its own revoke. It holds no DELETE anywhere, so the only rows it can remove are the ones the definer function deletes for an erased EHR, one EHR id at a time |
The schema-preparation credential ([db] migrate_url, normally a member of ferroehr_migrator) | every schema: it issues the DDL of all five migration sets and reads all five _sqlx_migrations tables, and it owns the objects it created | nothing. The server opens it for that one boot step and closes it again, so no pool is held on it and no request is served through it |
A schema’s _sqlx_migrations bookkeeping, the record of which migrations ran
that the boot check compares, is read-only to every runtime role, the writer
roles included; the server refuses to start while a runtime role can write any
bookkeeping table, in every database a domain pool reaches.
The audit schema is granted to the clinical pair (ferroehr_clinical records an
event, stamps it forwarded, runs the retention reaper and verifies the chain;
ferroehr_clinical_reader reads it).
The local Audit Record Repository is written on the clinical pool, so a login
role that is a member of ferroehr_clinical alone writes its own access log. The
audit trail is not a pseudonymisation domain: the demographic and linkage
roles hold no privilege there, and the trail carries record identifiers, never
a subject’s data.
The five domain roles are checked at every boot. The server reads the
catalogue for every table, partitioned table, view, materialized view, foreign
table, sequence and function each of them can reach in a domain it does not
own, and refuses to serve when it finds one, naming the role, the object kind
and the object. A role that does not exist is skipped rather than failed,
because role provisioning is a deployment step and the migrations create the
roles only where the migrator holds CREATEROLE. ferroehr db verify runs
the same check from outside the deployment.
Residual risk.
- The grants are only a boundary once the credentials are separated. With
every
[storage.<domain>] urlunset, all four pools authenticate as[db] urland the split is a schema split. Theshared_credentialgap ofdeployment_profileis what names that. - A login role can be a member of several domain roles. The compose stacks do exactly that, deliberately, and say so. The boot self-check measures the group roles, so it reports a boundary that a membership then crosses.
- A superuser holds everything, and so does the role that owns the objects. Neither is a runtime credential, and neither is constrained by any of the above.
Backup artefacts
A dump leaves the database with none of the grants attached, so the chart takes
one per domain rather than one per cluster: clinical with ext and audit;
party; linkage on its own. Each job authenticates as its own read-only
role, because every domain role is revoked from the other domains and a dump
taken through a runtime credential would be silently partial. Each writes to
its own claim, and the chart refuses to render when two domains name the same
one.
Residual risk.
- Each dump is plaintext of its own domain. The clinical one holds every
composition. The demographic one holds every party and the sealed identifier
columns with them, which stay sealed only while
[demographic.identifier_protection] keylives somewhere the dump does not. - Two dumps together are worth more than twice one.
demographicbesidelinkagesays which person holds which record id; add the clinical dump and the record is re-identified. Who may read each claim is the control, and it is yours. - A cluster-wide artefact spans all three. A physical base backup and an
instance-wide point-in-time recovery are bridges no grant closes. That is
the
shared_clustergap, and running the three domains on one cluster accepts it by name. - Nothing prunes old dumps, and no network policy of ours selects the dump pods.
Re-identification
Pseudonymisation is worth what the paths back are worth. These are the ones this system has.
An identifier written into clinical content
Control. A data-minimisation pass reads the canonical JSON of every
clinical commit body before it is stored. EHR_STATUS.subject.external_ref
must name a declared pseudonym namespace and carry a UUID once
[privacy] subject_namespaces is set. A PARTY_IDENTIFIED or PARTY_RELATED
carrying identifiers, and a PARTY_RELATED carrying a name, are refused
unless the deployment opts in. An identifier scanner reads every string leaf
against the active rules: eight ship, one per issuing register that publishes a
check digit (ch-ahvn13, ch-epd-pid, de-kvnr, fi-hetu, gb-nhs-number,
nl-bsn, no-fodselsnummer, se-personnummer), all active by default in strict mode, and a deployment
adds regular expressions for the kinds no build can ship a rule for. The two
write paths that replay content verbatim, EHR-Extract import and the admin
archive load, are scanned too: they store what they receive, so a body
carrying an identifier can only be refused. The database keeps its own line.
Once the namespaces are declared the server stamps ehr.posture, and a
trigger on ehr refuses a subject_id that is not a UUID, whichever session
writes it.
Residual risk.
- A checksum rule matches a number, not a person. A name, a street address, a date of birth and a local record number with no check digit pass every shipped rule.
subject_namespacesis empty by default, and with it empty the subject rule and the database trigger are both out of force. Theopen_subject_namespacegap ofdeployment_profileis what names that.- A finding names the RM path and the rule, never the value. That is deliberate, and it means a refusal tells an operator where to look rather than what was there.
Content that identifies without carrying an identifier
Control. None in the query engine. Clinical content is keyed by an opaque subject pseudonym, and AQL returns the rows it was asked for.
Residual risk. A rare diagnosis with an admission date and a place of
treatment identifies a person with no identifier field involved. Small-cell
suppression applies to the cohort query
only: a cohort result serving fewer distinct EHRs than
cohort.small_cell_threshold has its rows withheld and is marked suppressed.
An ordinary AQL execution takes no threshold at all, so nothing counts how
small a population a plain POST /query/aql returned. For that surface, who
may run AQL, for what purpose, and over which EHRs are the controls, and they
are configuration rather than arithmetic.
The map that rejoins the two domains
Control. linkage.subject_ehr holds a party id, an EHR id, the subject
identifier the clinical side carries, the association metadata and a
validity period. No name, no address and no plaintext attribute, because a
row here is already the additional information that re-attributes a record.
Its role is barred from both domains it joins and both of them from it. It
holds anything only when a FerroEHR run on its own opts in to it with
[ehr_index] provider = "local"; by default the cross-reference lives in an
IHE patient identity source that FerroEHR asks over PIXm (ITI-83), and the map
stays empty. FerroEHR matches, links and merges no patients in either mode.
Each EHR Index operation writes a linkage-domain access event naming the
actor, the declared purpose of use and whether anything matched, and that
event deliberately does not name the subject identifier. Erasure is the one
deletion, and the role still cannot delete: linkage.erase_ehr is a
SECURITY DEFINER function that removes the rows naming one erased EHR, which
the admin delete calls once the clinical delete has committed.
Residual risk.
- The identity source holds the re-identifying link. Its controls, who may resolve a pseudonym back to a person, are the source’s, and FerroEHR never asks for that resolution.
- Under the local map, nothing populates it. What it holds is what an
I_EHR_INDEXcaller wrote, so a deployment that never writes it has no map, and one that writes it from a batch job has whatever that job decided. - A miss is as informative as a hit. Asking whether this deployment holds a record for a person is itself a disclosure, which is why the miss is recorded too.
The sealed identifier and its lookup digest
Control. A protected national identifier leaves the versioned body and
lives in party.national_identifier under AES-256-GCM with a fresh
96-bit nonce per record, with the scheme code bound in as
associated data so a ciphertext moved between either fails to open. Beside it
is an HMAC-SHA-256 digest under a separate subkey, which is what lets equality
lookup work without the plaintext ever reaching the database. Both subkeys are
derived per domain from one configured root key. The resolve
function is SECURITY DEFINER, PUBLIC is revoked from it, and only
ferroehr_party may execute it. Every resolution records an access
event naming the scheme and whether it matched, never the value. The scheme
registry is a closed set: an identifier whose scheme nobody registered cannot
be stored as a protected one at all.
Each party version seals rows of its own. When a later version or the logical
deletion supersedes a version, its rows get a retired_at stamp and stay, so
an older version still expands to the value it committed. Resolution matches
live rows only, and a value is live for at most one party at a time.
Three paths that move a party version keep the value sealed:
- EHR-Extract import: a received version is stored verbatim, so the server
cannot seal it on the way in. It refuses with a
422a version that carries a protected scheme’s value in plaintext, and one that references a sealed row this instance does not hold, such as a reference from another instance. - Admin dump and load: the archive carries the party’s sealed rows, live and retired, as base64 ciphertext, nonce and digest, never the value. They open again only under the same root key.
- Physical erasure of a party: every sealed row of the party, live and retired, is deleted in the same transaction as its versions.
Residual risk.
- The root key is the whole control. Held beside a dump, the ciphertext opens and the digest can be recomputed over a nine-digit space in seconds. It belongs in a secret store, not next to the backup.
- Protection is off by default, and an identifier whose type the deployment did not list is left in the versioned body exactly as written.
- Rotating the key is a re-encryption, not an edit, because every record is sealed under a subkey derived from it.
The subject pseudonym
Control. mint_subject_pseudonym derives the value from the party id
under the linkage key domain, in the first declared pseudonym
namespace. No caller input enters it, so the same party yields the same
pseudonym and a national identifier cannot become a subject
reference through that path.
Residual risk. A stable per-subject identifier across the whole clinical store is what makes it an EHR, and it is also a linkage key. Anyone holding the clinical domain can gather every record of one subject, without knowing who the subject is. Across instances the derivation differs with the root key, so the same person in two instances is two pseudonyms.
The audit trail as a re-identification surface
Control. Access records live in audit, outside all three domains, and
retrieval is the admin-gated ITI-81 route. A linkage event names the party
and the purpose, not the EHR that was returned, so the trail is not a second
copy of the map.
Residual risk. The records name subjects, actors, organisations, roles and
what they read, which is sensitive in its own right and is a disclosure
surface like any other. Retention makes it durable on purpose: where one of
the deployment’s active identifier rules names a jurisdiction with a
registered retention floor, a shorter audit.store.retention_days is a boot
error rather than a silent trim. The Netherlands is the one registered floor
today, at 1830 days.
What is explicitly NOT defended against
Silence is never coverage, so these are stated rather than left to inference.
- A compromised identity provider. Everything downstream reads claims it asserted.
- A compromised host, container runtime, or cluster operator. Process memory holds decrypted PHI and key material by necessity.
- Anyone with direct database credentials.
- A malicious maintainer, or a compromise of the maintainer’s account or signing key. The bus factor is one; see MAINTAINERS.md.
- Physical access, side channels, and speculative-execution attacks.
- Traffic analysis. Request sizes, timings and error-code patterns can leak the existence of records; nothing pads or delays.
- A legitimate user’s authorized misuse, beyond recording it.
- Denial of service by a resource-holding authorized caller: an expensive query, a large commit, a slow client. The rate limiter is on but coarse and per-process; database statement timeouts and container limits are the operator’s.
- Data remanence. Physical deletion removes rows; it does not scrub filesystem blocks, WAL segments, replicas or backups.
- Clinical correctness. FerroEHR validates structure, invariants and terminology bindings. It does not know whether a recorded fact is true, and no control here is a substitute for clinical governance.
Reporting a finding
If you believe a control is weaker than stated here, or a residual risk is missing, that is a valid security report, including when the defect is in this document rather than in the code. Follow SECURITY.md: report privately, never as a public issue.
Data protection impact assessment
GDPR Art. 35 makes a data protection impact assessment mandatory before large-scale processing of health data. That assessment belongs to the controller, and it is about a deployment: its purposes, its users, its network, its organisation. What this page supplies is the half a supplier can supply, which is the technical description the assessment builds on.
FerroEHR is software. It is not a controller, not a processor and not a certified organisation, so nothing here says a deployment is compliant with anything. It says what the software does with personal data, which controls it ships, and what those controls do not reach.
- How to use this page
- 1. The processing this software performs
- 2. Data categories, by schema
- 3. Roles and the credentials that serve them
- 4. The pseudonymisation domain
- 5. Retention
- 6. Risk register
- 7. Controls, by issue and legal source
- 8. What this software cannot do for your assessment
- Next
How to use this page
Copy the sections you need into your own assessment and replace what is yours. Sections 1 to 5 describe the software and are the same for every deployment. The risk register in section 6 lists the risks the product’s own design creates and the control it ships against each; your assessment adds the risks your deployment creates. Section 7 maps shipped controls to the legal source each was built for.
Read it alongside the threat model, which states the residual risk at each boundary, and the shared responsibility page, which says which duties the software cannot carry.
1. The processing this software performs
A FerroEHR deployment stores and serves an openEHR clinical data repository. Seven activities carry personal data, and they are described one by one, with the Art. 30 fields, in Records of processing:
- Storage of clinical content as versioned objects.
- Versioning and change control: every write commits a contribution and an audit entry in the same transaction, and no version is overwritten.
- Query over stored content, in AQL.
- Identity and linkage: parties, protected national identifiers, and the map from a party to the EHR whose subject it is.
- Access logging: one record per access, in DICOM PS3.15 and FHIR
AuditEventrenderings. - Change-event publication: a PHI-free envelope per commit, drained to a
broker when
[events]is enabled.
Everything else the server does (templates, terminology lookup, conformance reporting) operates on definitions rather than on people.
2. Data categories, by schema
The database is split so that no single runtime credential holds two of the three parts. Each schema is a separate category of data with its own role.
| Schema | Personal data it holds | GDPR category |
|---|---|---|
clinical | Clinical content: compositions, EHR status, folders, contributions, attestations, item tags. The subject appears as EHR_STATUS.subject.external_ref, which is an opaque pseudonym once [privacy] subject_namespaces is declared | Art. 9 special-category health data |
party | Parties: persons, organisations, groups, agents, roles and their relationships, with names, addresses and contacts as the operational template defines them. Protected national identifiers live in national_identifier, sealed under a per-domain key with a keyed digest beside them | Art. 4(1) personal data; a national identifier is Art. 87 national identification number |
linkage | subject_ehr, the EHR id / subject cross-reference: a party id, an EHR id, the opaque subject identifier the clinical side carries, the Service Model’s association metadata and a validity period. No attribute of any kind | The additional information of Art. 4(5): identifying only when joined to one of the other two |
audit | One record per access: who, from which organisation, under which roles, what they read or wrote, the declared purpose, the outcome and the time | Art. 4(1) personal data about both the subject and the accessing person |
ext | No personal data. Helper functions, the runtime roles and the deployment posture | — |
3. Roles and the credentials that serve them
Five runtime database roles, each holding one domain and revoked from the others in both directions, plus a migration credential used for one boot step. The full grant-by-grant table, and what a backup artefact per schema is worth to a holder, is the threat model.
At the API, an access decision runs through authentication, the openEHR
EHR_ACCESS gateway, RBAC, optional ABAC and optional SMART scopes, each stage
only able to narrow the previous one. That layering is
Security.
4. The pseudonymisation domain
flowchart LR
subject(["The person"])
subgraph dom ["The pseudonymisation domain"]
clin[("clinical<br/>clinical record,<br/>keyed by an opaque pseudonym")]
demo[("party<br/>the person, and sealed<br/>national identifiers")]
link[("linkage<br/>which party and which subject<br/>identifier name which EHR")]
end
aud[("audit<br/>who reached what, when")]
subject -.-> demo
clin -. barred .- demo
clin -. barred .- link
demo -. barred .- link
link -->|"application-level crossing,<br/>two pools, recorded"| aud
Read one domain and you hold a clinical record whose subject is an opaque identifier, or a set of people with no records attached, or two identifier columns naming neither. Only the three together re-identify anything.
One call crosses the boundary. resolve_ehr_for_identity asks the demographic
pool for the party holding a sealed identifier, by keyed digest and without
decrypting, then asks the linkage pool for that party’s EHR. The two hops are
two connections in the application, so no statement performs the join and no
credential could issue one. Every crossing writes an access record naming the
actor, the declared purpose and whether anything matched.
5. Retention
FerroEHR expires almost nothing on its own, which is deliberate: an openEHR record is indelible by design, and deciding when a record stops being needed is the controller’s judgement rather than a default.
| Data | What the software does | Key |
|---|---|---|
| Clinical content and its versions | Kept until an administrator deletes it. Archiving moves a record to the cold tier without expiring it | the admin API |
| Demographic parties | The same | the admin API |
| Linkage mappings | Kept while the EHR exists: a merge, a split or an index correction closes the period and opens a successor, and the linkage role holds no DELETE. Erasing the EHR erases them, through a SECURITY DEFINER function the role may execute but whose reach is one EHR id, so no additional information outlives the record it was additional to (Art. 17(1)) | the admin API |
| Access records | Kept forever by default; reaped hourly when a retention is set | [audit.store] retention_days |
| Change-event envelopes | Published rows pruned after seven days by default | [events] retention_days |
The access log carries a jurisdictional floor. Where one of the active
identifier-scan rules names a jurisdiction with a registered retention floor, a
shorter audit.store.retention_days is a boot error naming the floor rather
than a silent trim. The Netherlands is the one registered floor today: five
years from the moment the entry is written, under the
Besluit vaststelling bewaartermijn logging,
expressed as 1830 days because five calendar years never exceed that. Adding a
jurisdiction is a code change, not a configuration one, so a deployment
elsewhere sets its own floor by hand and records why.
6. Risk register
Each row is a risk the product’s own design creates or leaves. “Shipped control” is what the software does about it today; “what remains” is what your assessment has to size.
| # | Risk | Shipped control | What remains |
|---|---|---|---|
| R1 | A clinical record and the identity of its subject are held together, so one credential re-identifies everyone | Three schemas, five NOINHERIT roles, explicit revokes in both directions, and a boot self-check that refuses to serve when a role can read across | With every [storage.<domain>] url unset, all four pools use one credential and the split is a schema split. A superuser and a cluster-wide restore span everything |
| R2 | A national identifier is written into clinical content | The identifier scanner over every string leaf of every clinical write, strict by default, with all eight shipped rules active; the subject rule; the refusal of identifying party proxies; and a database trigger on ehr once the namespaces are declared | A rule matches a checksummed number, not a name, an address or a local record number. subject_namespaces is empty by default, which leaves the subject rule and the trigger out of force |
| R3 | A national identifier is readable in the party store, in a backup or on a replica | AES-256-GCM per record with the scheme bound in, a keyed HMAC-SHA-256 digest for lookup, per-domain subkeys, and a SECURITY DEFINER resolve function only the party role may execute | Protection is off by default. An identifier whose scheme is not configured stays in the versioned body. The root key held beside a dump defeats all of it |
| R4 | Someone re-identifies a record and nobody can tell afterwards | Every resolve, link, merge and split writes a linkage-domain access record naming the actor, purpose and outcome, including a miss | Recording is not prevention. An actor entitled to the crossing re-identifies by design |
| R5 | Content identifies a person without carrying an identifier | Small-cell suppression on the cohort query: a result serving fewer distinct EHRs than cohort.small_cell_threshold (default 5) withholds its rows and says so | A rare diagnosis with a date and a place identifies without any identifier field. The threshold covers the cohort query alone; an ordinary AQL execution takes none |
| R6 | One organisation reads another’s rows | One instance per organisation: its own database, its own domain roles, and no relation, policy or session setting that two organisations could share. openEHR puts multi-tenancy at the layer hosting several systems rather than inside one (BASE architecture_overview master06-design_of_the_ehr.adoc §The EHR System) | The separation is now a deployment property, so two organisations pointed at one instance share everything and nothing in the software will catch it |
| R7 | Access records are altered or lost | Records are hash-chained and audit.verify_audit_chain() names any that changed; the runtime role can insert and stamp delivery and nothing more; syslog and FHIR-feed forwarding put copies off the box | The chain is evidence, not prevention. A local repository shares the blast radius of the database it audits, and a read made straight against the database appears nowhere |
| R8 | A backup carries what the grants withheld | One dump per domain, each under its own read-only role and its own claim, and the chart refuses to render when two domains name the same claim | Each dump is plaintext of its own domain. Two of them together rebuild the join. Nothing prunes old dumps |
| R9 | A deployment has made none of the separations and looks like one that has | deployment_profile = "production" refuses to start while a separation is open and not accepted by name; sandbox names every open one on the banner, in the log and on GET /management/status | The default is sandbox, so an operator who sets nothing gets the loud version rather than the enforced one |
| R10 | Anyone with a database connection is past every API control | Least-privilege roles revoked from each other’s domains, and a version table that is appended to rather than overwritten | This is inherent. Authorization is enforced at the API, so protecting the credentials and the network path is yours |
7. Controls, by issue and legal source
Each control below is a closed tracker issue, so the claim is checkable against the pull request that delivered it. The complete list, generated from the tracker and re-checked by a CI job, is the control matrix; these are the ones a pseudonymisation assessment usually asks for.
A cell carries a mapping only where the delivering issue declares it, which is what the generated matrix reads, or where the source code itself cites the standard. An empty cell means no such declaration exists, never that the control is irrelevant to that source.
| Control | Issue | GDPR | EDPB 01/2025 | EHDS | NEN |
|---|---|---|---|---|---|
| Demographic parties in their own schema under a non-overlapping runtime role | #3153 | Art. 4(5), Art. 32(1)(a) | pseudonymisation domain | — | — |
| The clinical side refuses identifying data; the subject reference is constrained to a pseudonym namespace | #3154 | Art. 4(5), Art. 25(2) | — | — | — |
| National identifiers sealed, looked up by keyed digest, resolved under audit | #3155 | Art. 32(1)(a) | — | — | NEN 7510-2 cryptographic controls |
| Per-domain access logging for reads and queries | #3156 | — | — | Art. 9 | NEN 7513 event content |
| Separate encryption keys and per-schema backup handling | #3157 | Art. 4(5), Art. 32(1)(c) | — | — | — |
| The linkage map as its own schema and role | #3158 | Art. 4(5) | pseudonymisation domain | — | — |
The declared deployment profile: production refuses what it cannot prove | #3226 | Art. 32(1) | — | — | — |
| The server mints the subject pseudonym; no caller value becomes one | #3232 | Art. 4(5) | — | — | — |
| A jurisdictional floor under access-log retention | #3242 | Art. 5(1)(e) | — | — | NEN 7513 retention |
| The accessing organisation on every access record | #3204 | — | — | Annex II 3.2(a) | — |
| The actor’s roles on every access record | #3239 | — | — | — | NEN 7513 actor role |
The legal sources, each cited to its publisher:
GDPR,
EHDS,
EDPB Guidelines 01/2025 on pseudonymisation,
NEN 7510,
NEN 7513.
NEN 7510-2 is the control set of that standard; the entries above name the
control family a measure belongs to rather than quoting a clause, because the
standard’s text is not free to redistribute and the linked publisher is the
authority.
8. What this software cannot do for your assessment
- It cannot decide your lawful basis, your purposes or your retention. Those are the assessment’s own findings.
- It cannot tell you who your processors are, including whichever managed PostgreSQL, object store, broker or terminology server the deployment uses.
- It cannot serve a data subject’s rights on its own. Which requests the software can answer and which the organisation must is shared responsibility.
- It cannot certify anything. No conformity assessment has been carried out and no EU declaration of conformity exists; see EHDS readiness.
Next
- Records of processing — the Art. 30 template.
- Go-live checklist — the checks to run before a deployment holds real patient data.
- Threat model — the residual risk at each boundary.
Records of processing
GDPR Art. 30 requires a controller to keep a record of its processing activities, and Art. 30(2) requires a shorter one from a processor acting on a controller’s behalf. Both records are the organisation’s, not the software’s. This page is a template for the part a FerroEHR deployment contributes: the activities the software performs by design, with the fields it can answer already filled and the fields only you can answer marked.
FerroEHR is software rather than a controller or a processor, so nothing here is a record on anyone’s behalf. It is a starting draft that saves you reading the source to find out what the server does with personal data.
- How to use this
- 1. Storing the clinical record
- 2. Versioning and change control
- 3. Query
- 4. Identity and linkage
- 5. Access logging
- 6. Change-event publication (off by default)
- Fields no software can fill
- Next
How to use this
Copy the activity tables below into your own register and fill the three columns nobody but you can fill: the purpose your organisation processes for, the lawful basis it relies on, and the recipients it discloses to. Delete any activity your deployment has switched off. Activities 6 and 7 are off by default and only apply if you turned them on.
The identity fields Art. 30(1)(a) asks for are yours in every case:
| Field | Value |
|---|---|
| Controller (name, contact) | your organisation |
| Joint controllers, if any | yours |
| Representative in the Union, if applicable | yours |
| Data protection officer | yours |
| Processors engaged | yours: the PostgreSQL operator, and any object store, message broker, terminology server or identity provider the deployment is configured against |
Note
One transmission is on by default and is not a processor relationship: the usage report to FerroPULSE. It carries no patient data and no staff identities. Cadasto B.V. receives it as controller for its own purposes, and its collector stores the IP address each report comes from. Where that makes the report personal data (an instance run by one person from their own connection, for example), list Cadasto B.V. as a recipient in your record, or switch the report off.
1. Storing the clinical record
| Art. 30 field | What the software does |
|---|---|
| Purpose | yours: the care or research purpose the repository serves |
| Categories of data subject | Patients and other record subjects; the health professionals recorded as composers, performers and participants |
| Categories of personal data | Clinical content as the operational templates define it, in the clinical schema, its cold archival partitions included. Special-category health data under Art. 9. The subject appears as an opaque pseudonym once [privacy] subject_namespaces is declared |
| Recipients | yours: whoever the API is exposed to. The software discloses nothing on its own |
| Third-country transfers | yours: wherever the deployment and its backups run |
| Retention | No automatic expiry. Content leaves only by an administrator’s deletion; archiving moves it to the cold tier without expiring it |
| Security measures | Least-privilege database roles per pseudonymisation domain; a data-minimisation pass over every write; one instance per organisation; TLS; authentication and layered authorization |
2. Versioning and change control
| Art. 30 field | What the software does |
|---|---|
| Purpose | Keeping the clinical record attributable and reconstructible, which openEHR’s change-control model requires |
| Categories of data subject | As activity 1, plus the committing user |
| Categories of personal data | One contribution and one audit entry per commit, naming the committer, the change type and the commit instant; every prior version of every object |
| Recipients | yours |
| Third-country transfers | yours |
| Retention | Indefinite by design. A version is superseded, never overwritten |
| Security measures | Contribution and audit written in the same transaction as the content; a temporal version table rather than in-place update; optional version signing |
3. Query
| Art. 30 field | What the software does |
|---|---|
| Purpose | yours: the reporting, cohort or operational purpose queries serve |
| Categories of data subject | As activity 1 |
| Categories of personal data | Whatever the query projects out of stored clinical content |
| Recipients | yours |
| Third-country transfers | yours |
| Retention | Results are not stored. Stored query definitions are, as AQL text |
| Security measures | Queries run on the clinical credential only, so no query can reach the demographic or linkage domain; the planning gate refuses constructs the active specification generation does not define; result caps and statement timeouts bound one query’s reach |
Note
Small-cell suppression applies to the cohort query only. A cohort result serving fewer distinct EHRs than
cohort.small_cell_thresholdhas its rows withheld and is marked suppressed; an ordinaryPOST /query/aqltakes no threshold, so a query returning one row about one rare condition is served like any other.
4. Identity and linkage
| Art. 30 field | What the software does |
|---|---|
| Purpose | Holding who the record subjects are, and resolving a person to their record where a caller is entitled to |
| Categories of data subject | Patients, and the parties a record refers to |
| Categories of personal data | Parties in the party schema with names, addresses and contacts as the template defines them; protected national identifiers sealed in national_identifier; the party-to-EHR map in linkage, which holds identifiers and a validity period and nothing else |
| Recipients | yours |
| Third-country transfers | yours |
| Retention | Parties leave only by an administrator’s deletion. A linkage mapping leaves only with the EHR it names: a merge or a split closes its period and opens a successor, and erasing the EHR erases the rows naming it |
| Security measures | Three schemas under three roles, revoked from each other in both directions and checked at boot; AES-256-GCM sealing with a keyed HMAC-SHA-256 digest for lookup, under per-domain subkeys; the one crossing runs in the application over two pools and writes an access record |
5. Access logging
| Art. 30 field | What the software does |
|---|---|
| Purpose | Recording who reached what, which EHDS Annex II 3.2 requires of an EHR system and which NEN 7513 specifies the content of for Dutch deployments |
| Categories of data subject | The record subject, and the person who accessed the record |
| Categories of personal data | Per access: the accessing person and organisation, the roles held, the object and its domain, the outcome, the declared purpose of use, the configured legal basis and the time. Refusals are recorded too, unattributed where no principal was established |
| Recipients | yours: the local repository, plus any syslog or FHIR-feed Audit Record Repository configured |
| Third-country transfers | yours |
| Retention | [audit.store] retention_days, 0 meaning forever, reaped hourly otherwise. Where an active identifier rule names a jurisdiction with a registered floor, a shorter value is refused at boot |
| Security measures | Records are hash-chained and verifiable with audit.verify_audit_chain(); the runtime role may insert and stamp delivery and nothing else; the audit schema sits outside all three pseudonymisation domains; the syslog feed can run over RFC 5425 TLS with a client certificate |
6. Change-event publication (off by default)
| Art. 30 field | What the software does |
|---|---|
| Purpose | Telling downstream systems that a commit happened, when [events] is enabled |
| Categories of data subject | Record subjects, indirectly |
| Categories of personal data | A PHI-free envelope: the contribution id, the EHR id, the commit instant, and per version the object id, kind, version number, change type and template. No clinical content |
| Recipients | The configured AMQP broker, and whoever consumes from it |
| Third-country transfers | yours: wherever the broker runs |
| Retention | Published rows pruned after [events] retention_days, seven days by default |
| Security measures | Written in the same transaction as the commit it announces, so there is no event without its commit; optional TLS to the broker; the envelope carries identifiers, so a consumer still has to authenticate to the API to read anything |
Fields no software can fill
The record is not complete until these are answered, and they are answered by the organisation:
- The purpose of each activity and the lawful basis for it, which for health data means an Art. 9(2) condition beside the Art. 6 one.
- The recipients, including every category of person inside the organisation who can reach the API.
- Transfers to third countries and the safeguard relied on, which follows from where the database, the backups and every configured peer run.
- Time limits for erasure per category, which the software will not apply for you: it deletes when an administrator asks.
- The general description of security measures in Art. 30(1)(g), for which the technical half is the DPIA page and the threat model.
Next
- Data protection impact assessment — the technical description and the risk register.
- Go-live checklist — what to verify before real data arrives.
- Shared responsibility — which duties the software can carry.
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"
[storage.party]
url = "postgres://app_party:***@pg:5432/ferroehr"
[storage.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 clinical login role’s membership inferroehr_clinicalalso covers the local Audit Record Repository, which that role writes; it holds 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 /management/status):
- shared_credential: ...
The gaps are shared_credential, shared_cluster, open_subject_namespace,
audit_off, audit_fails_open, no_category_map, open_ehr_access_default,
migrate_on_runtime_credential, auth_off, rbac_off, plaintext_listener,
plaintext_broker, plaintext_audit_feed and plaintext_object_store. shared_cluster is read from
pg_control_system().system_identifier on each pool, the audit pool included,
rather than from the DSN text, so two names for one cluster do not pass it.
Two of them are the shipped compatibility defaults, kept as they are because
the conformance instrument and the development stacks need them: the
EHR_ACCESS default is open, and the audit trail fails open. A production
deployment either changes both, or names each in deployment_accepts and
carries the decision.
plaintext_listener opens whenever the main listener speaks plain HTTP on an
address reachable off the host. Behind a TLS-terminating ingress, accept it by
name: the entry records that the ingress carries the encryption. It also opens
when a separate management listener (management.port) is set, because that
listener binds every interface and never speaks TLS: unset the port so the
surface shares the main listener, or accept the gap by name and keep that port
off any network clinical clients or the internet reach.
The syslog feed has no gap of its own: it sends over RFC 5425 TLS only.
plaintext_broker opens when the change-event outbox ([events]) is on with an
amqp:// URL and tls = false, which publishes change events to the broker
unencrypted. Use
an amqps:// URL or set tls = true, or accept the gap by name where the path
to the broker stays on a trusted segment.
plaintext_audit_feed opens when the audit FHIR feed ([audit.fhir_feed]) is on
with an http:// URL, and plaintext_object_store when the multimedia store is
on with allow_http = true. Use https:// for both, or accept the gap by name
where the repository or the store is reached on a trusted segment.
A gap you have decided to run with goes in deployment_accepts by name. It is
then stated on every boot and on GET /management/status, so it is run
rather than hidden.
-
deployment_profile = "production"and the server starts. -
[authz.rbac] ehr_access_defaultand[audit] fail_modeare the values this deployment intends, not the ones it inherited. - Every entry in
deployment_acceptsis a decision someone recorded, not a leftover. -
GET /management/statusshows the profile and an accepted-gap list you recognise.
3. Identifier scanning is on and strict
[privacy.identifier_scan]
mode = "strict"
rules = ["ch-ahvn13", "ch-epd-pid", "de-kvnr", "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. FerroEHR mints none; under
the IHE patient identity source it is the clinical domain’s assigning
authority, ehr_index.assigning_authority.
-
subject_namespacesnames at least one namespace. - The
open_subject_namespacegap no longer appears onGET /management/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-party
persistentVolumeClaim: ferroehr-backup-party
linkage:
existingSecret: ferroehr-backup-linkage
persistentVolumeClaim: ferroehr-backup-linkage
Each credential is a read-only role on its own domain, and it cannot be the pool’s: each domain’s runtime role is revoked from the other domains, so a dump taken through one would be silently partial. 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
# 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 horizon is a boot error
naming the floor. Every EU Member State carries three years from each date of
access (EHDS Art. 9(2)); the Netherlands five years, from the
Besluit vaststelling bewaartermijn logging;
Switzerland one year (DSV Art. 4 Abs. 5). The floors are in calendar years, so
state the horizon as [audit.store] retention_years, which is compared
exactly; a retention_days is held to the most days those years can span
(1096, 1830 and 366). The audit trail page
lists the floors with their sources. A deployment in a jurisdiction with no
floor registered sets its own horizon 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_years(orretention_days) is0or 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_clinical
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.
Enterprise identity providers
FerroEHR does not manage users. There is no user table, no user API, and no plan to add one: identity administration is delegated to your identity provider (IdP), and the CDR consumes standard OAuth 2.0 bearer tokens. This page records that posture and walks through connecting the two enterprise IdPs we are asked about most, Microsoft Entra ID (Azure AD) and AD FS, plus the answer for plain-LDAP directories.
- The posture: users live in the IdP
- How the CDR consumes an IdP
- Microsoft Entra ID (Azure AD)
- AD FS (on-premises Active Directory)
- “We only have LDAP”
- Serving SMART apps
The posture: users live in the IdP
A clinical data repository is the wrong place to store credentials. A user store would make the CDR an authentication product (password lifecycle, lockout policy, MFA, recovery flows, and the largest new attack surface the product could grow) duplicating what a dedicated IdP already does under your existing governance. So the split is deliberate and permanent:
- The IdP owns identities: accounts, passwords, MFA, group/role membership, lifecycle (joiners/movers/leavers), and session policy.
- The CDR owns authorization: it validates the token, mines roles from its claims, and enforces RBAC/ABAC and per-EHR access control on every request.
The HTTP Basic user list in ferroehr.toml is for machines and bootstrap,
not a user store; people authenticate with bearer tokens from the IdP.
Note
The viewer follows the same rule: it authenticates against the same credentials the CDR accepts (the same OIDC issuer, or Basic) and has no user-management screens. To create, disable, or re-role a user, use your IdP’s own administration surface.
How the CDR consumes an IdP
Three things do all the work:
- Token validation (
[auth]): the server reads the issuer’s metadata and key set at boot and validates each bearer token’s signature (asymmetric algorithms only),iss,exp/nbf,audandtyp; the audience list is mandatory, so a server with none refuses to boot rather than accepting another service’s token. See the bearer settings table. - Role mining (
[auth]):FERROEHR__AUTH__ROLE_CLAIMS(default["roles","groups","entitlements"], the RFC 9068 §2.2.3.1 carriers; a Keycloak realm addsrealm_access.roles) names the JWT claim paths whose values become the caller’s roles for the role layer. A path may be dotted to walk nested claims, and a claim holding a single string is accepted as readily as an array. - SMART on openEHR scopes: every route requires a scope from the
token’s
scopeclaim (user/composition-*.cruds,user/aql-*.sand so on; see SMART App Launch). Your IdP has to mint them, for example as Keycloak client scopes assigned to the client. A token with roles and no SMART on openEHR scope authenticates and is then refused with403on every route.
Everything below is those three pointed at a different issuer.
Microsoft Entra ID (Azure AD)
Entra ID exposes a standards-compliant OIDC issuer per tenant.
-
Register an application (Entra admin center → App registrations). Note the Directory (tenant) ID and the Application (client) ID.
-
Define app roles (App registration → App roles): create roles named after the CDR roles you use (for example
USER,CLINICAL,ADMIN) and assign users/groups to them (Enterprise applications → your app → Users and groups). Entra puts assigned app roles in the token’srolesclaim. -
Expose an audience: either use the client ID as the audience or add an Application ID URI (for example
api://ferroehr). -
Point the CDR at the tenant issuer and mine the
rolesclaim:export FERROEHR__AUTH__ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0 export FERROEHR__AUTH__AUDIENCE=api://ferroehr # Entra ID mints no RFC 9068 tokens (`typ` JWT, the scopes in `scp`). export FERROEHR__AUTH__TOKEN_PROFILE=jwt export FERROEHR__AUTH__ROLE_CLAIMS='["roles"]' -
Verify: request a token for the app (any OAuth2 client credential or auth-code flow) and call the API. Read the status codes as a diagnostic:
Status What it tells you 200-familyroles arrived and the operation was permitted 401no credential, or one the server rejected (the body deliberately never says which) 403the token is valid but carries no role or SMART on openEHR scope the operation needs: a clinical call needs a matching scope and, unless every SMART on openEHR scope it holds is a system/scope, at least one role, an admin call needs the admin role and the product scope400the Authorizationheader itself is malformed; no credential was ever read503the CDR could not read your issuer’s key set again, so the token was never judged: check network egress, not the token
Important
A role must arrive in a role claim, not in
scope. The OAuth2scopeclaim grants a client delegated authority (RFC 6749 §3.3) and says nothing about the subject’s roles, so it is not a role source. If your IdP is configured to put role names inscope, map them intoroles(or another entry ofROLE_CLAIMS) instead.
Tip
Group-based deployments can emit the
groupsclaim instead and list it inROLE_CLAIMS, but group claims arrive as object IDs unless you configure group names, so app roles usually read better in policy.
Warning
Two configuration mistakes are boot errors rather than runtime surprises, so you will find them the first time you start the server: an issuer that is not an
httpsURL with no query or fragment, and an empty audience list. Both are deliberate: the second is what stops this server accepting a token minted for a different service. An issuer the server cannot reach at boot refuses the start too.
AD FS (on-premises Active Directory)
AD FS 2016+ speaks OIDC. This is also the supported path for on-premises Active Directory in general: front AD with AD FS (or another OIDC-capable broker) rather than pointing anything at LDAP.
-
Create an Application Group (AD FS Management → Application Groups → Web API template, or Server application + Web API for interactive clients). The Web API’s identifier becomes the token audience.
-
Issue role claims: on the Web API’s Issuance Transform Rules, add a rule mapping AD group membership to the
roleclaim (template: Send Group Membership as a Claim), one rule per CDR role. -
Point the CDR at the AD FS issuer and mine the
roleclaim:export FERROEHR__AUTH__ISSUER=https://adfs.example.com/adfs export FERROEHR__AUTH__AUDIENCE=ferroehr-api # AD FS mints no RFC 9068 tokens. export FERROEHR__AUTH__TOKEN_PROFILE=jwt export FERROEHR__AUTH__ROLE_CLAIMS='["role"]'Discovery works out of the box (
https://adfs.example.com/adfs/.well-known/openid-configuration). -
Verify with the same status table as above:
401without a token,403with a token that lacks the required role.
Note
AD FS emits a single string for one role and an array for several; the role-mining layer accepts both shapes on any configured claim path.
“We only have LDAP”
The CDR does not speak LDAP, by design: LDAP bind would put password handling back inside the CDR. Front the directory with an OIDC-capable broker and connect that instead:
- Active Directory → AD FS (above) or Entra ID (if synced).
- Generic LDAP → Keycloak with LDAP user federation (the Keycloak example then applies verbatim), or any other OIDC provider that can federate LDAP.
The broker owns the LDAP bind; the CDR sees only signed tokens.
Serving SMART apps
If your IdP is also the authorization server for SMART App Launch apps, the same
[auth] issuer does double duty: the CDR validates the tokens those apps come
back with, and the discovery document it serves is built from that issuer’s
metadata, so the server it advertises to apps is the one it accepts tokens
from. See SMART App Launch.
SMART App Launch
FerroEHR is the resource server of a SMART on openEHR platform: a clinical
app is launched with an OAuth 2.0 access token from your authorization server
(Keycloak or any standards-compliant identity provider), and the CDR checks
the token, enforces the SMART on openEHR scopes it carries, and binds a
patient/ grant to the EHR of the launch context. FerroEHR never issues
tokens, registers clients, or serves the OAuth 2.0 endpoints itself; those
remain your authorization server’s job. Token checking and scope enforcement
run on ferrohealth-smart, the crate every FerroHEALTH product shares.
- Scopes are enforced on every route
- The scope grammar
- What each route requires
- Launch context: binding to one EHR
- The discovery document
- Configuration keys
Scopes are enforced on every route
Whenever [auth] is on, every route carries a SMART on openEHR requirement,
and a credential whose scopes do not meet it is refused with 403 and the
insufficient_scope challenge (RFC 6750 §3.1). There is no switch: a route the
table does not know is refused too, so a route can never answer to a token
that carries no grant for it. A bearer token’s scopes come from its scope
claim; a Basic user’s from its scopes in [[auth.basic.users]]. With
authentication off nothing is checked.
The scopes and the RBAC and ABAC layers are all checked: SMART only narrows, and never lets through what a role or a policy refuses (see Security).
The scope grammar
SMART on openEHR resource scopes have the form
<compartment>/<resource>.<permissions>:
- Compartment:
patient(the launch context’s EHR only),user(what the user may see), orsystem(a backend service, no user). - Resource: one of three families,
composition-<template-id>,template-<template-id>, oraql-<stored-query-name>. - Permissions: any combination of
ccreate,rread,uupdate,ddelete,ssearch/execute, in any order (.crud,.rs,.sr).
Resource ids accept wildcards: * matches within one ::-delimited namespace
segment, ** matches across namespaces, and a bare * or ** matches every
id. Every other character, :: and . included, is literal. The permission
tail is split at the last dot, so template ids and query names keep their
internal dots and versions.
| Scope | Grants |
|---|---|
user/composition-*.crud | Create, read, update, and delete any composition, and the EHR’s other data. |
user/composition-MyHospital::*.r | Read compositions of any template in the MyHospital namespace (not sub-namespaces). |
user/template-*.cruds | Full access to template definitions. |
user/aql-*.s | Run any query. |
patient/composition-*.r | Read the compositions of the launch context’s EHR only. |
system/aql-org.openehr::bloodpressure.v1.s | A backend service may run that one stored query. |
The FHIR v1 suffixes (.read, .write) are not part of this grammar. A token
may carry SMART on FHIR scopes beside these; they grant nothing on an openEHR
route.
What each route requires
| Routes | Requirement |
|---|---|
| COMPOSITION and versioned-composition operations | composition-<template> with the operation’s permission, the template read from the body or the stored version |
EHR, EHR_STATUS, DIRECTORY, CONTRIBUTION, the item tags, the EHR extract (/message) | composition- with the operation’s permission over every template: they are the EHR’s data, and the draft names no family of their own |
| operational templates, archetypes and artefacts | template-<template> with the operation’s permission, the template from the {template_id} path parameter |
| query execution | aql-<query> with s, the query from the {qualified_query_name} path parameter |
| stored-query definitions | aql-<query> with the operation’s permission |
| the DEMOGRAPHIC API | a client listed in [smart] demographic_clients, or the [auth] product_scope: no SMART on openEHR scope covers demographic data |
| the ADMIN API | the [auth] product_scope |
| the terminology API, the ITI-81 audit retrieval | an authenticated caller |
Note
An unresolved resource id matches only a bare wildcard. A template upload or list, an ad-hoc query and an EHR-level operation name no template or query, so only a scope on every resource (
*or**) covers them. Grant those scopes deliberately.
A system/aql-* grant “would grant access to all registered and ad-hoc AQL
queries system-wide” (master08 §Resource Scopes), so it counts only for a
backend client listed in [smart] backend_clients.
Launch context: binding to one EHR
When an app is launched for a patient, your authorization server puts the
openEHR EHR id of the launch context in the token’s ehrId claim
(master07 §Context Selection). A patient/ grant then reaches that one EHR:
- it counts only for an issuer listed in
[smart] patient_issuers, because anehrIdnames an EHR of the platform that issued it; - an EHR route whose
{ehr_id}names another EHR is refused, and so is an operation that names no EHR, such as creating an EHR; - a query runs only within the launch context’s EHR, named by its single-EHR
ehr_idparameter oropenehr-ehr-idheader; a query naming no EHR, or another one, is refused; - it reaches EHR data alone, never a template definition.
user/ and system/ grants carry no such binding, and when a user/ grant
covers the same operation the wider grant wins.
The discovery document
With [smart] enabled = true, the server serves the SMART configuration
document, unauthenticated, at:
GET /ferroehr/rest/.well-known/smart-configuration
(relative to the REST root, or to [smart] platform_base_url when set). The
document is built from the first [auth] issuer’s metadata, read at boot: the
authorization, token, registration, introspection and revocation endpoints,
the grant types and client authentication methods it supports, and its
jwks_uri. It therefore needs an issuer whose metadata names a
token_endpoint, which the document requires (SMART App Launch 2.2.0,
Conformance, §Metadata). An issuer read from a jwks_file has no metadata of
its own: [smart] issuer_metadata_file names a JSON file with its RFC 8414 §2
metadata, which must name that issuer and hold every endpoint to https (a
loopback host excepted). Without it, enabled = true refuses the start.
export FERROEHR__AUTH__ISSUER=https://as.example/realms/ferroehr
export FERROEHR__AUTH__AUDIENCE=ferroehr-api
export FERROEHR__SMART__ENABLED=true
# The external origin absolute service URLs are built from; required.
export FERROEHR__SMART__PUBLIC_BASE_URL=https://cdr.example.com
A document looks like:
{
"jwks_uri": "https://as.example/realms/ferroehr/certs",
"authorization_endpoint": "https://as.example/authorize",
"token_endpoint": "https://as.example/token",
"token_endpoint_auth_methods_supported": ["client_secret_basic", "private_key_jwt"],
"grant_types_supported": ["authorization_code", "client_credentials"],
"scopes_supported": [
"openid", "profile", "launch",
"user/composition-*.cruds", "user/template-*.cruds", "user/aql-*.cruds",
"system/composition-*.cruds", "system/template-*.cruds", "system/aql-*.cruds"
],
"services": {
"org.openehr.rest": {
"baseUrl": "https://cdr.example.com/ferroehr/rest/openehr/v1",
"description": "The openEHR REST API baseUrl"
}
},
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"capabilities": [
"permission-user", "client-confidential-symmetric",
"client-confidential-asymmetric", "launch-standalone",
"context-openehr-ehr", "openehr-permission-v1"
]
}
servicesnames the openEHR REST service;org.fhir.restis never advertised, because FerroEHR serves no FHIR API for clinical data. EachbaseUrlis absolute, built by prefixing the CDR’s own base path withpublic_base_url, which is why that key is required.capabilitiescarries what the server enforces:context-openehr-ehrandopenehr-permission-v1always,permission-user, andpermission-patientonly whenpatient_issuersbinds an issuer; the client types and launch modes the issuer supports;sso-openid-connect, with theissuermember it requires, when the issuer’s metadata lists theopenidscope;context-openehr-episodeandlaunch-base64-jsonwhen their switches are on; and your[auth] capabilities.grant_types_supportednames onlyauthorization_codeandclient_credentials, whatever else the issuer advertises: the implicit and password grants are deprecated (master06 §Deprecated Flows).code_challenge_methods_supportedalways carriesS256and neverplain.scopes_supportedlists[auth] scopes_supportedwhen set, otherwise the scopes the route table honours above, with thepatient/ones only when an issuer is bound. It never copies the issuer’s own list: “the server SHALL support all scopes listed here”.
With enabled = false the path is not mounted at all (404).
Configuration keys
The keys live in [auth] and [smart]; see the
configuration reference.
| Key | Default | Meaning |
|---|---|---|
FERROEHR__SMART__ENABLED | false | Serve the discovery document. |
FERROEHR__SMART__PUBLIC_BASE_URL | unset (required when enabled) | The external origin absolute services.*.baseUrl values are built from, e.g. https://cdr.example.com. |
FERROEHR__SMART__PLATFORM_BASE_URL | unset | Base the discovery document hangs off. Unset = the REST root (/ferroehr/rest). A leading path is honoured (/gateway/v1 → /gateway/v1/.well-known/smart-configuration). |
FERROEHR__SMART__BACKEND_CLIENTS | [] | The client_ids a system/aql-* grant counts for. |
FERROEHR__SMART__PATIENT_ISSUERS | [] | The issuers whose patient/ grants count. |
FERROEHR__SMART__DEMOGRAPHIC_CLIENTS | [] | The client_ids that reach the DEMOGRAPHIC API. |
FERROEHR__SMART__LAUNCH_BASE64_JSON | false | Advertise the base64 JSON launch-parameter capability (experimental; consumed by the app, not the CDR). |
FERROEHR__SMART__EPISODE__ENABLED | false | Advertise and accept episode launch context (experimental; advisory only, no episode filtering). |
FERROEHR__AUTH__PRODUCT_SCOPE | __ferroehr.platform | The scope that admits the ADMIN API and the DEMOGRAPHIC API. |
FERROEHR__AUTH__CAPABILITIES | [] | Capabilities the discovery document adds. |
FERROEHR__AUTH__SCOPES_SUPPORTED | [] | The scopes the discovery document lists. |
Note
Episode context is experimental: enabling it advertises the capability and accepts
launch/episode, but the server applies no episode-scoped filtering: openEHR has no first-class episode resource yet.
Audit trail (IHE ATNA)
FerroEHR keeps a full security audit trail of API access (who did what to which resource, with what outcome, from where, and when) following the IHE ATNA (Audit Trail and Node Authentication) profile, the standard the openEHR reference text points at (its platform service description names a system log “IHE ATNA-compliant”). It is on by default: every deployment records a queryable audit trail with zero external dependencies.
Warning
Two postures are legitimate to run and wrong to run silently, so the server says them at
warnon boot (structured fieldposture = "audit"), in theaudit_senderreadiness indicator and on/management/info: auditing off ([audit] enabled = false) writes no access log and leaves the EHDS logging component off, and the shipped defaultfail_mode = "open"drops a record the audit queue cannot take while the request succeeds, so an access under load can go unrecorded. A deployment that must never serve an unrecorded access setsfail_mode = "closed", which answers503andRetry-Afterinstead. A deployment declaringdeployment_profile = "production"refuses to start with auditing off.
- The record, in both official formats
- Reads are logged too, per record
- The EHDS logging elements, mapped
- Sinks
- Tamper evidence
- Retrieving audit records (ITI-81)
- Node authentication (ITI-19, mutual TLS)
- Against independent ATNA implementations
- Configuration summary
The trail is orthogonal to openEHR’s own CONTRIBUTION/AUDIT_DETAILS
change-control audit (which the server always writes in the same transaction
as every version change): openEHR audit records what a version says about its
own authorship; ATNA records security surveillance of access, including
reads and rejected attempts.
The record, in both official formats
Every audited operation produces one record, rendered in the two formats the
IHE standards define. Both are written by the shared audit crates of the
FerroHEALTH products (FerroAUDIT): ehds-logging writes the EHDS access
record, and ihe-atna writes the BALP AuditEvent and the DICOM audit
message and sends them. FerroEHR decides which accesses are logged, what each
record says and where it is stored.
- FHIR R4
AuditEventfollowing the IHE BALP 1.1.4 (Basic Audit Log Patterns) content profiles: the modern RESTful-ATNA form and the canonical stored form. An access to clinical content is the EHDS access record:ehds-loggingwrites it on theRESTfulpattern of its action, with the patient behind the EHR as the patient entity (PatientRead,PatientCreateand the otherPatient*claims), the categories, the retention, the security labels and the origins. Every other record (an authentication, a template or party operation, a refused access) is a BALP exchange FerroEHR fills directly. The person is theagent:userslice, named by the token’s issuer and subject, with the token’sjtias itspolicy, the caller’s roles as itsroleand the client application as an agent of its own (BALP 1.1.4 §3:5.7.5.4); a Basic user is named by the account alone, with no issuer and no client. The ITS-REST operation id is a secondsubtype(urn:openehr:its-rest:operation). The object an access record reads, creates, updates or deletes is named by its openEHR object id asentity.what.identifierin the systemurn:openehr:object-id, never as awhat.reference, since it is no FHIR resource (FHIR R4 references.html §2.3.0.3). The outcome is stated inoutcomeDesc, andsource.typenames an application server. Query executions carry the search expression. - DICOM Audit Message (DICOM PS3.15 §A.5 XML): the classic ATNA form,
shipped as RFC 5424 syslog over RFC 5425 TLS per IHE ITI-20 when the syslog
sink is enabled, with the optional
EventOutcomeDescription,AuditSourceTypeCode(4, an application server) and each participant object’sParticipantObjectDataLifeCycle. Dedicated DICOM event ids are used throughout: Patient Record (110110), Query (110112), Export/Import (110106/110107, the EHR-Extract directions), User Authentication (110114, with the LoginEventTypeCode110122).
Both formats give the client and the server the same data-flow role: on a
read the server is Source (DCM 110153) and the client Destination (110152),
because the data moves from the server to the client (DICOM PS3.15 §A.5.2.7,
the BALP Read profiles); on a create, an update and a query the client is
Source (PS3.15 §A.5.3.20 for a query, the BALP Create, Update and Query
profiles).
Every server operation is audited (an unrecognised extension operation fails
closed to a generic audited class, so nothing is silently unaudited) and
access refusals are always recorded: a 401, a 403, and the 400 a
malformed Authorization header earns. A refusal is attributed to the caller
only when one actually authenticated; an unattributable denial is recorded
as unattributed rather than under a fabricated subject, so no audit record
ever names an identity that did not authenticate.
Note
Successful-login records are suppressed by default (
[audit] suppress_login_events = true). Set it tofalseto record them. Even then, a login record marks a genuine authentication event, not every authenticated request: it is emitted when credentials were actually verified: a Basic verified-credential-cache miss. A cache hit continues an established session, and a Bearer request authenticated out of band at your identity provider, so neither mints a per-request login record. Rejections are recorded regardless of this switch.
Reads are logged too, per record
A write leaves its own trail in openEHR’s contribution and audit chain. A read leaves nothing behind unless the server records it, and reads are what the access-logging rules are about: NEN 7513 requires per-access logging of who consulted which record and on whose authority, the Wabvpz gives the patient the right to know who consulted their record, and EHDS Art. 9 requires logging of access to electronic health data for primary use. No openEHR specification governs the read side, so this is FerroEHR’s own extension.
Every retrieval in the EHR, Query and Demographic APIs produces a record, and a
test walks the generated route tables to prove it: a newly generated GET that
emits nothing fails the build.
Each record additionally carries:
| Field | What it holds |
|---|---|
domain | The pseudonymisation domain read: ehr, demographic, linkage, or system for an operation that touches no domain. |
organisation | The organisation the caller acted for, read from the access token’s [authz.abac] organization_claim — the same claim the ABAC layer decides on, so no second setting can name a different one. The recording does not depend on that layer being switched on. A Basic-authenticated caller and an unconfigured claim both leave it empty. |
purpose | The purpose of use the caller declared in the x-purpose-of-use header, when the deployment accepts it. |
emergency_access | true when the declared purpose is one of [audit] emergency_purpose_codes: an emergency access (EHDS Art. 11(5)). |
legal_basis | The basis the deployment processes under, from [audit] legal_basis. |
result_count | How many records the operation served. |
request_id | The x-request-id correlation id the response carried. |
A query is the case a single record cannot describe. One AQL statement is one
operation but potentially many disclosures, so it produces its own execute
record plus one access record per EHR it served, each carrying that EHR’s
served-row count. The served set is read off the rows the caller actually
received rather than the rows the statement matched: a legal record of access
must not name an EHR whose content was never disclosed. Two query shapes carry
no per-EHR breakdown, because the shape makes it underivable —
SELECT DISTINCT, where an extra column changes which rows are distinct, and
an aggregate projection, where a per-row EHR is not valid SQL. Those record the
statement and its row count.
NEN 7513 content, field by field
NEN 7513 §5 lists what a logged event has to contain. This is where each item lives in the record:
| NEN 7513 asks for | FerroEHR records it as |
|---|---|
| The identity of the person who accessed the record | principal — the authenticated Basic username or OAuth sub; an unattributable denial is recorded as unattributed, never under a fabricated identity. With [authz.professional] configured, professional_id names the natural person when the token names a client application |
| The identity of the patient whose record was accessed | patient_id — the resolved EHR subject, which under the pseudonymisation boundary is an opaque pseudonym |
| Which record, or which part of it | resource_class + resource_id: the version uid, party uid or EHR id the operation touched |
| The date and time | recorded_at, the event time; stored_at is when the row was persisted |
| The kind of action | action (DICOM C/R/U/D/E) plus operation, the released ITS-REST operation id. It equals the served OpenAPI operationId except for the demographic CONTRIBUTION pair, served as demographic_contribution_create and demographic_contribution_get because the released contribution_create and contribution_get also name the EHR operations and one document needs unique ids |
| Whether it succeeded | outcome, the DICOM outcome indicator (0 success, 4 minor, 8 serious, 12 major) |
| Under which role or authority | roles — the roles the caller held at access time (the RFC 9068 §2.2.3.1 claim carriers for a bearer, the user definition for Basic), rendered as FHIR agent.role on the requestor and as a RoleIDCode per role on the DICOM requesting participant |
| On whose authority, and why | purpose and legal_basis, and permit, the identifier of the data permit a secondary-use caller declares it acts under (Secondary use) |
| Which system, and from where | AuditSourceID and the source participant’s network address (client_ip): the connection’s peer, or the client a peer in [server] trusted_proxies names in its forwarded_header (Server) |
Two of these are the deployment’s to supply. purpose is only as meaningful as
the vocabulary you agree with your callers: set [audit] purpose_codes to that
vocabulary and a code outside it is recorded as absent rather than as free text
that reads at review time like an established purpose. legal_basis is a
deployment-level fact and is recorded on every access once you set it.
Mapping the recorded fields onto your own retention schedule and review process stays yours: the software records the events and serves them back, and no supplier document discharges the obligation to review them.
The EHDS logging elements, mapped
Annex II 3.2 of Regulation (EU) 2025/327 lists five things the European logging software component must record “on every access event or group of events”. This is where each one lands, or does not. The elements were read from the published text on 2026-09-10; the regulation is the authority and the wording below is a paraphrase.
| Annex II 3.2 | Access-event field | DICOM PS3.15 | FHIR AuditEvent | State |
|---|---|---|---|---|
| (a) the healthcare provider or other individuals having accessed | organisation | — (no such attribute) | an agent whose who is a logical reference to an Organization | Recorded |
| (b) the specific natural person or persons having accessed | principal (+ token_id, client_ip), and with [authz.professional] configured professional_id + acting_mode | ActiveParticipant/@UserID (the professional; a client application acting for one is a second, non-requesting participant; the token subject is @AlternativeUserID when it differs) | the agent:user slice, who the token’s issuer and subject, the professional’s identifier as its ihe-otherId, and the client application as an agent of its own (BALP 1.1.4 §3:5.7.5.4) | Recorded |
| (c) the categories of data accessed | categories + category_basis + category_evidence + category_map_digest, classified by ehds-logging through the [audit.categories] map (beside resource_class, domain and result_count) | none (no such element) | the entity named ehds-categories, with one ehds-category detail per category and its basis, each id the classification read and the map digest; each category is also a meta.security label | Recorded |
| (d) the time and date of access | recorded_at | EventIdentification/@EventDateTime | recorded | Recorded |
| (e) the origin or origins of data | origins + origin_count (the FEEDER_AUDIT originating systems of the served version bodies, or the server that created them, derived at commit onto version.origins) | — (no such element) | one entity named origin per origin, its system-id detail the originating system | Recorded |
Read (a) beside (b): they are both about the accessing side, and the pair
distinguishes the organisation on whose behalf the access happened from the
natural person who made it. The principal answers (b); the organisation
column answers (a), resolved from the same access-token claim the ABAC layer
reads ([authz.abac] organization_claim) rather than from a second setting
that could name a different claim. Sharing the setting is not sharing the
switch: the organisation is recorded whether or not the ABAC gate is enabled,
because the trail describes a caller rather than deciding anything about them.
The FHIR rendering carries it as an agent of its own, a logical reference to
an Organization, with no participation code, because AuditEvent.agent.type
is optional and inventing a code would be worse than omitting one. The DICOM
rendering carries nothing: PS3.15 §A.5 defines no organisation attribute on
ActiveParticipant, and AuditEnterpriseSiteID is the reporting node’s own
site rather than the caller’s organisation. Where no claim is configured or the
caller authenticated with Basic, the field stays empty rather than being
guessed.
Element (b) asks for a natural person, and a bearer token can name a client
application instead. With [authz.professional]
configured, every record says which it was and who stood behind it:
| Field | Meaning | DICOM PS3.15 | FHIR AuditEvent |
|---|---|---|---|
acting_mode | person when the token names a natural person, client when it names a client application | the participant split below | the agent split below |
professional_id | the natural person: the person’s own claim, or the professional a client token acts for | ActiveParticipant/@UserID of the requesting participant | the ihe-otherId extension of the agent:user, typed NPI |
principal | the token subject, as before: the client application in client mode | a second ActiveParticipant with UserIsRequestor="false" and the network address; @AlternativeUserID in person mode when it differs from the professional | agent:user who.identifier, with the issuer as its system; the acting mode is its first role |
assurance_level | the eIDAS level (low, substantial, high) the token’s assurance claim maps to under [authz.assurance] | none (no such attribute) | the ihe-assuranceLevel extension of the agent:user |
assurance_value | the raw claim value the level was read from, usually acr | none | none |
PS3.15 §A.5
gives ActiveParticipant no assurance attribute, so the DICOM message and the
syslog feed carry no level. The FHIR export carries the level as the BALP
ihe-assuranceLevel extension (BALP 1.1.4 §3:5.7.5.4) and never the raw claim
value, which lives in the local store’s column only. The tamper-evidence digest covers both columns, like every other column of
the record (see Tamper evidence for records written before
the digest was versioned).
Without [authz.professional] the mode and the professional stay empty and
(b) is answered by principal, which names a client application when the token
names no person. Without [authz.assurance] levels the level stays empty;
the raw value is still recorded when the token carries the claim. A Basic
credential records none of the four.
Element (c) asks for the priority category of the data, in the sense of
Art. 14(1): patient summaries, electronic prescriptions, electronic
dispensations, medical imaging, medical test results and discharge reports,
plus any category a Member State adds in national law. AQL selects archetypes
and templates, never categories, and no openEHR specification maps the one
onto the other, so the deployment declares the mapping in
[audit.categories]: a
template id, or failing that the root archetype id, maps to a set of categories
(a priority category by its code in HL7 Europe’s
EEHRxFDocumentPriorityCategoryCS, such as Patient-Summaries or
Laboratory-Reports, a national category as its <system>|<code>), or to
none for content that holds no data of any category. While you declare no
template and no archetype, the map FerroEHR ships applies: the published CKM
templates and COMPOSITION archetypes whose data sit in one priority category
(the shipped map). Your own
map replaces it, and ehds-logging classifies each access through the map in
force.
Each record is classified when the access happens, from what it served or wrote, and never at commit, because the map is your reading of your templates and can change:
- a composition read or write is classified by the version’s template id, then by its root archetype id;
- an AQL execution is classified by the template and root archetype ids of the
versions its result page came from; where the answer carries no version (a
DISTINCTor aggregate projection, or zero rows) by the query’s positive archetype and template constraints, so an operand underNOT CONTAINSor a predicate underNOTnever counts. Every id the query constrains to must be mapped, and every content operand bound to an id, or the record isunclassifiedbeside what did map. Each per-EHR record of a query carries the categories that EHR disclosed; - a logical delete writes no content, so it is classified by the version it
deleted; a
VERSIONED_COMPOSITIONread by the composition it holds; - a
CONTRIBUTIONread, with or withoutPrefer: resolve_refs, is classified by the versions the contribution committed, a deleted version again by the version it deleted; - an EHR Extract export is classified by every version it carried, and an Extract import by the newest content of every versioned object it landed;
EHR,EHR_STATUS,EHR_ACCESS, the directory, item tags and revision histories are recorded asnone, by resource kind.
An access the map cannot classify is recorded unclassified, with the
template and archetype ids as evidence, and is never refused; the FHIR record
says why (unmapped, unbound, named-nothing). A stored category is its
<system>|<code> token. category_basis says what the classification was read
from (returned, written, queried or resource-kind; records written
before 5.3.2 carry template, archetype or query and the earlier
spellings), and category_map_digest names the map that produced it, so a
later map never rereads an old record. The map’s
digest is also on the boot line and on GET /management/info under
audit.category_map. The classification stays in the access log: it is never
written to the request log, a span or a metric label. The DICOM rendering has
no element for it (PS3.15 §A.5), so it travels in the local store and the FHIR
export only.
Records in the demographic domain carry no classification, because no priority category lives there.
Element (e) asks where the DATA came from, which is not where the request came
from. openEHR models that provenance as FEEDER_AUDIT on the content itself,
so every committed version records the distinct set of
FEEDER_AUDIT.originating_system_audit.system_id values found in its body, or
this server’s own system id when it carries none. A composition, status or
folder read then records the origins of the version it served, and an AQL
execution the union over the version rows its result page came from. The record
carries the true distinct count beside a set capped at 32, so a page drawing on
more origins than that says how many rather than looking complete. A version
committed before the column existed is assessed when it is read.
One further element, adjacent to the five: the declared purpose of use
reaches one export of the two, and only one of them could carry it. It is
stored on the record, served by ITI-81, and rendered as
agent.purposeOfUse on the
requesting person’s agent in the FHIR export. The coding carries the code
alone, with no system: the vocabulary is the one you agree with your callers
in [audit] purpose_codes, which has no published code system URI, and
attributing the code to a system it does not come from would misdescribe it.
The
DICOM Audit Message schema
of PS3.15 §A.5 defines no purpose element at all — EventIdentification,
ActiveParticipant, AuditSourceIdentification and
ParticipantObjectIdentification, and none of them carries one — so on that
side it is a limit of the format, not something this code withholds.
Note
Every row of this table is asserted by a test (
app/ferroehr/tests/it/audit_ehds_mapping.rs), including the gaps: the tests that record an absence fail the day the field arrives, so the table and the code cannot drift apart in either direction.
The common specifications the regulation’s Article 36 provides for have not been adopted. When they are, this mapping is re-verified against them rather than assumed to still hold; see the EHDS readiness page.
Emergency access (EHDS Art. 11(5))
A natural person may restrict health professionals’ access to parts of their data (EHDS Art. 8). Art. 11(5) lets a professional reach restricted data where the person’s vital interests require it, and requires such cases to be “logged in a clear and understandable format” and “easily accessible for the data subject”. Art. 9(1) counts these accesses among the ones the person is told about.
You name the purpose codes your callers declare for an emergency in
[audit] emergency_purpose_codes. Each one must also be on
[audit] purpose_codes; the server refuses to start otherwise. An access
whose declared purpose is one of them carries the emergency mark:
- The stored record: the
emergency_accesscolumn istrue. It isfalsefor every other access and for records written before the column existed. - The FHIR
AuditEvent: theentitynamedehds-emergency-accessthatehds-loggingwrites, whosedescriptionstates the mark in plain words (“Emergency access: the accessor declared that the access was necessary to protect the vital interests of the data subject (Regulation (EU) 2025/327 Art 11(5)).”) and whoseehds-emergency-purposedetails name the purposes that marked it. The declared code stays onagent.purposeOfUseand onpurposeOfEvent. A patient portal keys on the entity and shows its description. - The DICOM syslog feed: no mark. The PS3.15 schema has no purpose element.
The person reads their own log, the marked accesses included, through the subject-scoped ITI-81 retrieval.
[audit]
purpose_codes = ["TREAT", "ETREAT"]
emergency_purpose_codes = ["ETREAT"]
The mark records the declaration and lifts no restriction. A composition or
EHR restricted under GDPR Art. 18 stays refused with an emergency purpose,
because Art. 18(2) admits processing only with consent, for legal claims, for
the rights of another person or for an important public interest, and the
person’s own vital interests are not on that list. The refused attempt is
still logged with the mark. An EHDS Art. 8 restriction of access by health
professionals is different: a caller holding authz.rbac.emergency_access_role
that declares an emergency purpose reads the restricted data, and its record
carries the mark
(restriction of access).
An emergency the caller does not declare is not marked.
Retention, and who chooses it
[audit.store] retention_days decides how long the repository keeps a record:
0, the default, keeps them forever, and any other value sets the horizon.
Every record stores the first date it may be deleted on, and the reaper
deletes each record hourly on or after that date and never before it. An
access record takes its end from the retention its categories and origins
call for (Regulation (EU) 2025/327 Annex II 3.4), so two records written the
same day can end on different dates; every other record ends the day after
its date of access plus the horizon. A record written before FerroEHR stored
per-record ends is reaped by the horizon.
The default is deliberate. An access log is the evidence a data subject’s right of access is served from, and a repository that silently forgets is worse than one that grows — so the software keeps everything until an operator says otherwise, rather than choosing a horizon on their behalf.
Choosing that horizon is the deployment’s, and it is a legal question: EHDS Art. 9(2) makes the access information “available for at least three years from each date of access to the data”, and national law can ask for longer. NEN 7513 is the Dutch reference for this log and the Wabvpz for how long it must survive. The setting is per node, and one node serves one organisation, so two organisations needing different horizons run two instances.
A floor is enforced where a jurisdiction sets one. The jurisdictions a
deployment answers to are the ones its [privacy.identifier_scan] rules name,
and where one publishes a minimum retention for the access log the server
refuses to boot with a shorter horizon, naming the configured value and the
floor. 0 always passes. The floors are written in calendar years, as the law
writes them: a horizon set as [audit.store] retention_years is compared
against the years exactly, and one set as retention_days against the most
days those years can span (the day count in brackets below). The floors
registered:
| Jurisdiction | Floor | Source |
|---|---|---|
| Every EU Member State (AT, BE, BG, CY, CZ, DE, DK, EE, ES, FI, FR, GR, HR, HU, IE, IT, LT, LU, LV, MT, NL, PL, PT, RO, SE, SI, SK) | three years from each date of access (1096 days, the most three calendar years can span) | Regulation (EU) 2025/327 (EHDS) Art. 9(2); a longer national floor wins. Iceland, Liechtenstein and Norway are not listed: EHDS reaches the EEA only through an EEA Joint Committee decision |
| NL | five years from the moment the entry is written (1830 days, the most five calendar years can span) | Besluit vaststelling bewaartermijn logging (Stcrt. 2019, 38007), under Art. 5 of the Besluit elektronische gegevensverwerking door zorgaanbieders, which binds the period to NEN 7513 |
| CH | one year, and kept apart from the processing system (366 days, the most one calendar year can span) | Datenschutzverordnung (DSV) Art. 4 Abs. 5, as amended on 1 December 2025, for the logs of a large-scale automated processing of sensitive personal data; the separate storage is the deployment’s, through the forwarding sinks |
For the Netherlands the five national years win over the three EHDS years;
Switzerland is outside the Union and keeps its one year. Of the other shipped
jurisdictions, DE, FI and SE carry the EHDS floor, while GB and NO have no
floor registered: an unknown requirement is never guessed at, and a deployment
there sets its own. Art. 9 applies from 26 March 2029 for patient summaries,
prescriptions and dispensations and from 26 March 2031 for the other priority
categories (Art. 105). The floor is enforced from this release on, so a log
kept today already meets it when Art. 9 applies.
The effective retention is on the boot line beside the audit-enabled facts and
on GET /management/info under audit.
Getting the log out
Two routes, and they answer different questions.
- ITI-81 retrieval — the audit search
serves the records back as FHIR
AuditEventresources, filtered by patient, agent, date and outcome. This is the operator’s route and the one a patient portal would build on: everything Annex II 3.2 records is reachable through it, including the purpose and the organisation, which the syslog feed’s DICOM form cannot carry. - The BALP feed — records fan out to syslog and to a FHIR feed as they are written, for a SIEM or a central Audit Record Repository.
There is no patient-facing route in the product: a portal that shows a person
who accessed their record builds on ITI-81 and authenticates the person itself.
What the product gives that portal is a grant of its own size:
authz.rbac.subject_audit_role reads the log for one subject at a time, with
the patient parameter required, so the portal never holds an admin credential
over every patient’s log (GDPR
Art. 15 with Recital 63, under which the controller “should be able to provide
remote access to a secure system” where possible; EHDS Art. 9; for Dutch
deployments Wabvpz Art. 15e). The portal’s build is the deployment’s, and the
shared-responsibility page says so.
Sinks
Records fan out to independently configured sinks ([audit] in
ferroehr.toml; see the
configuration reference):
| Sink | Default | What it does |
|---|---|---|
[audit.store] | on | The local Audit Record Repository: records persist in the dedicated audit PostgreSQL schema (append-only and tamper-evident, strictly outside the EHR content), served back via the ITI-81 search below. retention_days prunes old records hourly (0 = keep forever). |
[audit.syslog] | off | The classic ATNA feed: DICOM PS3.15 XML as RFC 5424 syslog over RFC 5425 TLS to an external ARR, per IHE ITI-20. |
[audit.fhir_feed] | off | The RESTful-ATNA feed (ITI-20 ATX: FHIR Feed): each FHIR AuditEvent is POSTed to an external FHIR ARR. With the local store on, delivery is outbox-driven: an ARR outage loses nothing, pending records ship on recovery. The outbox holds the records written while the feed is configured; with the feed off, no record is owed. Records stored before FerroEHR kept an outbox and never delivered ship once a feed runs. |
Emission never blocks the request path: a record is handed to a bounded queue
(queue_capacity) and written by a background drain.
The local store is the durability anchor. Under fail_mode = "closed", an
operation whose audit record cannot be recorded answers
503 Service Unavailable: the deployment demanded an audit trail it cannot
currently deliver, so no un-audited PHI access happens, and the same holds for
the domain-level records the linkage, subject and identifier resolutions and
the EHR-Extract transfers write. open, the default, drops-and-meters instead,
and every loss path is metered (the atna_audit_* counters in
Operations); the server announces that default at boot and on
the readiness indicator as a stated caution, because a metered loss is still a
loss. Login and rejection records never gate a response in either mode.
Tamper evidence
An audit trail that anything with the application’s database password can quietly rewrite is a log, not an accountability record. The local store is therefore tamper-evident: every record is linked into a SHA-256 hash chain maintained inside PostgreSQL, so each record commits to its predecessor and to its own content. The chain is built by the database itself, not by the server, which means it covers every writer: the per-event insert, the batched drain, and any statement typed by hand.
Each record’s digest covers every column of the record except the digest
itself (row_hash) and the two per-sink forwarding stamps
(delivered_syslog_at, delivered_fhir_feed_at). A column added by a later
release joins the digest in the same release.
The store keeps a record’s facts in columns and renders its FHIR AuditEvent
from them when the record is read (ITI-81) or delivered (the FHIR feed). What
the rendering takes from the configuration (source_id, enterprise_site_id,
server_host, value_if_missing, the store horizon, [audit.categories] and
emergency_purpose_codes) is kept once per distinct configuration in
audit.audit_render_policy, keyed by the SHA-256 of its text, and every record
names the policy it was written under. A record therefore renders the same
document after the configuration changes, and the verification below reports a
policy whose text no longer matches its digest, as it reports a changed record.
Release 5.6.0 starts on an empty audit store: its migration refuses a store
that holds records written by an earlier release, and names the remedy.
Four controls sit on top of it, and they are separate on purpose:
-
The table refuses the ordinary rewrite paths outright. The only permitted change to a stored record is the per-sink forwarding stamp; an
UPDATEof any other column, aDELETE, and aTRUNCATEare refused by the database. Retention pruning goes through the one sanctioned deletion path, which records which records it removed and what the surviving chain must link back to, so reaping does not look like tampering, and tampering does not look like reaping. -
The privileges are narrow. The runtime role may record an event, stamp it forwarded, and read the trail back. It holds nothing that can rewrite a record, remove one, or alter the chain’s own bookkeeping. That posture is only fully in force with
db.migrate = "verify"; see Operations, because a self-migrating server owns the schema and can therefore turn the enforcement off. -
Verification is a query you can run yourself. It recomputes every digest, re-walks every link, and checks both ends of the chain:
SELECT * FROM audit.verify_audit_chain();An empty result means the trail is intact. Any row names one record (its chain position, its id, when it was recorded) and what is wrong with it: content modified after it was written, a render policy changed or missing, records deleted with no retention record for the removal, or records removed from the end of the chain where no successor would have noticed.
-
The server runs the check itself, on a schedule. With the local store on, the check runs one minute after boot and then every
[audit.store] verify_interval_seconds(default3600, one hour;0turns it off). Each run verifies the records written since the newest link an earlier run found intact, and the chain head, so its cost follows the records written since rather than the size of the trail (audit.verify_audit_chain_incremental()). A full pass runs every[audit.store] full_verify_interval_seconds(default604800, one week;0leaves it to the query above) and after every run that found damage, so damage to an older record stays reported. A run that finds damage logs atERROR, one summary line with the finding count and one line per finding (the first 20), and adds the count to theatna_audit_chain_findingscounter withkind = "damage". A run that cannot verify at all counts askind = "unverifiable", because a trail that cannot be checked is never a pass. The latest result is theaudit_chainindicator onGET /health/readiness:UPwhile intact andDEGRADEDwhen damaged or unverifiable; its detail onGET /management/healthnames the first finding. It never flips readiness, because the trail keeps recording while the damage is investigated. Alert on the counter or on that component, and use the query above to see every finding. Each replica runs its own check.
Important
This is detection, not prevention, and the boundary is worth stating plainly. The chain is unkeyed, so a party with unrestricted write access to the
auditschema (the database owner, or a superuser) can delete a record and recompute every hash after it. What closes that case is keeping the trail somewhere that party does not control. Enable[audit.syslog]or[audit.fhir_feed]so records leave the box as they are written, and give the server an app-role-only DSN so it is not that party.
Retrieving audit records (ITI-81)
The RESTful-ATNA ITI-81 Retrieve ATNA Audit Event transaction is a FHIR
search on AuditEvent, so it is served at FerroEHR’s FHIR R4 base,
/ferroehr/rest/fhir/r4, beside the openEHR API rather than under it, where a
FHIR client or an IHE Audit Consumer expects [base]/AuditEvent. It is the one
FHIR route FerroEHR serves:
GET /ferroehr/rest/fhir/r4/AuditEvent
It returns a FHIR searchset Bundle of the matching records’ AuditEvent
documents, rendered from the store, newest first, with the full match total, a self link and a next link
while more pages follow. The query is read by ihe-atna, the family’s audit
crate. At least one date parameter is required (RESTful ATNA
§3.81.4.1.2.1): a query without one is a 400. Every search parameter
§3.81.4.1.2.2 says the repository shall support is applied. A parameter
with several comma-separated values matches any of them, and every
parameter of the query must match. A token takes the three forms of FHIR R4
Search §3.1.1.4.10: code, |code (recorded without a system) and
system|code (or system| for any code of that system). FerroEHR records
identifiers without a system, except the object of an access record, whose
system is urn:openehr:object-id: entity.identifier=urn:openehr:object-id|<uid>
and entity.identifier=<uid> find it, and a query that names another system
for an identifier matches nothing. Every parameter answers through an index. A
parameter on an element of the document (address and the .identifier,
entity-type, entity-role, type and subtype parameters) matches the
search tokens the store takes from the record’s rendered document when it
writes the record (audit.audit_search_token, audit.audit_search_set and
the record’s search_tokens), so the search finds what the served document
says and renders only the page it serves.
| Parameter | Matches |
|---|---|
date | required, repeatable: a FHIR date value with a prefix (eq when none, gt, lt, ge, le, sa, eb); a day such as 2026-07-12 covers the whole day, read in UTC |
patient | the recorded patient (EHR subject) id |
patient.identifier | entity.what.identifier of the entity whose role is Patient |
agent | the authenticated principal |
agent.identifier | agent.who.identifier |
entity | the touched resource id |
entity.identifier | entity.what.identifier of any entity |
entity-type | entity.type |
entity-role | entity.role |
source.identifier | source.observer.identifier, the audit source id |
type | type |
subtype | subtype (the REST interaction and the openEHR operation) |
address | a part of agent.network.address, compared without regard to case |
outcome | the outcome indicator: 0, 4, 8 or 12 (FerroEHR records 0, 4 and 8) |
action | the action code: C, R, U, D or E |
_count / _offset | paging; page size defaults to 50, capped at 1000; _count=0 returns the total alone |
A malformed value is a 400 carrying a FHIR OperationOutcome with
invalid; another FHIR R4 AuditEvent parameter, which §3.81.4.1.2.2 does
not ask for, is a 400 with not-supported; a parameter the crate does not
know is ignored
(§3.81.4.1.3). Under RBAC the
unscoped retrieval is admin-only; a caller holding the configured
authz.rbac.subject_audit_role reads it scoped to one subject: one
patient or patient.identifier with one value is required, and the caller
is refused with a 403 that names the parameter when it omits
it. Reading a subject’s log is itself recorded as an access naming that
subject and the record count served. An
emergency access in the subject’s log
carries its mark as the ehds-emergency-access entity. The surface answers 404 when the local
store is disabled.
# Who accessed patient-42's data this month?
curl -u admin:pw \
"https://cdr.example.org/ferroehr/rest/fhir/r4/AuditEvent?patient=patient-42&date=ge2026-07-01T00:00:00Z"
This is also the openEHR “record demerging” instrument: when data lands in the wrong EHR, the patient-filtered audit search shows exactly who read it.
Note
Audit records are themselves sensitive (they name patients, subjects and actions) so this endpoint is a PHI-disclosure surface and is authorized like any other.
Node authentication (ITI-19, mutual TLS)
ATNA’s second half is node authentication. [server.tls] terminates TLS
natively and can demand a verified client certificate:
[server.tls]
certificate_file = "/etc/ferroehr/server.pem"
key_file = "/etc/ferroehr/server.key"
min_version = "1.3" # 1.3 (default) | 1.2
client_auth = "required" # none | optional | required
client_ca_file = "/etc/ferroehr/client-ca.pem"
The protocol floor is TLS 1.3 only by default, as RFC 9325 §3.1.1 has it
supported. Setting min_version = "1.2" enables
1.2 alongside 1.3, never instead of it, for a client that genuinely cannot
do 1.3, such as an older integration engine or a pinned Java runtime. TLS 1.1
and 1.0 are refused when the configuration is read (RFC 8996).
With client_auth = "required", only clients presenting a certificate
chaining to your explicit trust anchor complete the handshake: the IHE
mutually-authenticated-node posture. Deployments terminating TLS at an
ingress leave [server.tls] unset and enforce mTLS there instead.
Warning
The separate-port management listener (
management.port) stays plain HTTP even with[server.tls]set, and it binds all interfaces. Treat it as an internal surface and keep it off any publicly routed port. Underdeployment_profile = "production"setting it opens theplaintext_listenergap, so the server refuses to start unless the gap is accepted by name.
Complete the posture with time synchronisation (IHE Consistent Time): run NTP/chrony on every node so audit timestamps align across systems.
Against independent ATNA implementations
The transactions above are tested by FerroEHR’s own suites, which read the
profiles the way FerroEHR’s authors read them. EHDS Annex II 1.4 asks that a
component meant to work with other products does so reliably, so
scripts/interop/atna.sh
runs the quickstart against software FerroEHR’s authors did not write, each
from a published image:
| Transaction | Independent implementation | What the run requires |
|---|---|---|
| ITI-20 FHIR feed | HAPI FHIR, an open-source FHIR R4 server, as the Audit Record Repository, with IHE BALP 1.1.4 loaded | It accepts every record, and its validator finds no error against base R4 and the BALP profile each record names |
| ITI-20 syslog | syslog-ng over RFC 5425 TLS | Every message parses as RFC 5424, and its payload is a well-formed DICOM PS3.15 A.5.1 AuditMessage (libxml2) |
| ITI-81 | HAPI FHIR’s AuditEvent search over the records the feed delivered | The same searches answer the same records on both |
cargo build --release -p loadtest
bash scripts/interop/atna.sh ghcr.io/ferrohealth/ferroehr:<tag> \
ghcr.io/ferrohealth/ferroehr-postgres:<tag> atna-out
It prints every disagreement and exits non-zero on any. A disagreement is either FerroEHR’s or a shared crate’s defect, filed and fixed, or the other side’s, recorded with the profile text that shows it.
The run of 2026-10-29 found no disagreement: HAPI FHIR accepted and validated all 71 records of the window, syslog-ng parsed all 72 messages, and the ten ITI-81 searches answered the same records on both repositories (the record).
Configuration summary
Auditing defaults to on with the local store only; see the
configuration reference for every
[audit] key and its FERROEHR__AUDIT__* environment form. The syslog sink’s
own keys are host / port / tls_ca_file /
tls_identity_cert_file / tls_identity_key_file under [audit.syslog]; the
switches shared by every sink stay directly under [audit].
Note
There are no alternative spellings for any of these: an unrecognized
FERROEHR_*variable or an unknown TOML key is a boot error naming the spelling it should have had, never a silently ignored setting. Validate a deployment’s configuration up front withferroehr config check.
Version signing
Every version FerroEHR commits can carry a VERSION.signature: a value
computed over the version’s own canonical form, inside the write transaction
that stores it. openEHR defines two depths for that value, and FerroEHR
implements both as first-class modes (RM Common IM, Change Control §Digital
Signature).
| Mode | What is stored | What it establishes |
|---|---|---|
digest (the default) | sha256: + base64(SHA-256(canonical form)) | the version has not been altered since committal |
pgp | an ASCII-armored RFC 4880 detached signature | that, plus which key signed it |
Signing is on by default, in digest mode, and read-time verification of
the server’s own signatures defaults to strict: a served version that no
longer matches its stored signature is a 500 rather than a silently served
record.
The two pages
- Digest signing covers the mechanism both modes share: what goes into the signed form, when it is computed, what a stored digest does and does not prove, how verification at read behaves, and how to reproduce a digest yourself from the served JSON.
- PGP signing covers what changes when a key is involved: the key configuration and its fail-closed boot check, rotation and retired keys, client-supplied signatures versus server-generated ones, and the signature an import wrapper carries.
Read them in that order; the PGP page builds on the digest page rather than repeating it.
Where this sits
Version signing is openEHR’s own record-level integrity mechanism, and it is distinct from the two other integrity surfaces this book describes. The ATNA audit trail records security surveillance of API access, with its own hash chain over audit records. Verifying releases is about the artifacts you downloaded, not the data you stored. All three coexist and none substitutes for another.
Every [signing] key, with defaults and environment forms, is in the
configuration reference.
Digest signing
Every version FerroEHR commits can carry a VERSION.signature. openEHR
defines two depths for that value: hash the version and store the digest,
or sign the hash with a private key. The
openEHR RM Common IM, Change Control §Digital
Signature
puts it directly: “If only the hashing step is done, the digest acts as a
data integrity check, indicating if the data have been tampered with after
creation. If the signing step is carried out, it authenticates the user as
the author of the content.”
This page covers the hashing depth, which FerroEHR calls digest mode and
runs by default. PGP signing covers the other one.
- What the digest covers
- When it is computed
- What a stored digest proves
- Verification at read
- Verifying a digest yourself
- Switching modes later
What the digest covers
The signed value is the whole VERSION object as the server will serve it,
with the signature attribute itself left out. Concretely, for a locally
committed version that is the ORIGINAL_VERSION carrying:
uid, theOBJECT_VERSION_IDthis repository allocated;preceding_version_uid, when the version has a predecessor;contribution, theOBJECT_REFof the enclosing CONTRIBUTION;commit_audit, including its committer andtime_committed;lifecycle_state;data, the clinical content;attestations, the ones committed with the version.
The assembled object is reduced to a canonical string with
RFC 8785 (JSON Canonicalization
Scheme), and the digest is
sha256: followed by the standard-base64 encoding of the SHA-256 of that
string:
sha256:jtWX/CULavvzX0ehjowv2XZPICTQhN1t0+AXHfbEaNc=
The sha256: prefix is a FerroEHR addition. openEHR names the OpenPGP
format for the signing depth, and says nothing about how a bare digest is
spelled on the wire, so the prefix carries the algorithm and encoding that
a raw radix-64 hash would leave unstated. It is also what lets a reader
tell a digest from a PGP signature without a second stored column.
The RM section marks the exact serialization “To Be Determined” and notes that ODIN might be preferred because XML libraries differ. FerroEHR uses canonical openEHR JSON reduced by RFC 8785, which is deterministic and independent of the signature itself.
Note
A logically deleted version has no
dataattribute at all: openEHR deletes by committing a new version whose data is Void. Its digest therefore covers the identity, provenance and523|deleted|lifecycle state, and nothing else. It is a signature over a real version, not an empty one.
When it is computed
At commit, inside the write transaction, before any row is inserted. The commit instant and the CONTRIBUTION id are both known up front (the commit instant is the transaction timestamp), so the server assembles the exact version envelope it will later serve, signs that, and writes the version row, the CONTRIBUTION and the audit together.
Two properties follow, and both matter for verification:
- The signed bytes and the stored bytes are the same bytes. The content is decomposed into storage rows once and reassembled once; that reassembled value is what gets signed and what a read returns. There is no second serialization that could drift.
- A version that fails to sign does not commit. Signing sits inside the transaction, so a canonicalization or signer failure rolls the whole change back rather than storing an unsigned version.
flowchart TD
A["commit<br/>(direct write or CONTRIBUTION)"] --> B{"UPDATE_VERSION.signature<br/>supplied by the client?"}
B -->|yes| C["stored verbatim<br/>signature_client_supplied = true"]
B -->|no| D{"signing.enabled"}
D -->|false| E["no signature stored"]
D -->|true| F["assemble the VERSION envelope<br/>uid, contribution, commit_audit,<br/>lifecycle_state, data,<br/>at-committal attestations"]
F --> G["canonical form:<br/>drop signature, RFC 8785 (JCS)"]
G --> H{"signing.mode"}
H -->|digest| I["sha256: + base64(SHA-256(canonical))"]
H -->|pgp| J["RFC 4880 detached signature,<br/>ASCII-armored"]
I --> K[("version row<br/>same transaction")]
J --> K
C --> K
E --> K
What a stored digest proves
It proves that the version served today canonicalizes to the same bytes it did at commit. Any change to the content, the committer, the commit time, the lifecycle state or an at-committal attestation moves the digest, so a row edited behind the server’s back is detected the next time that VERSION is read.
It proves nothing about who wrote the version. A digest needs no key, so anyone holding the bytes can compute the same value, and an attacker who can rewrite a version row can rewrite its digest with it. Digest mode is a tamper-detection control against accidental corruption and against an actor who reaches the data but not the write path. Authorship and non-repudiation need a key, which is PGP mode.
It also covers one of the two copies FerroEHR stores. Every version’s content is written twice in the same transaction: as the materialized document a point read serves, and as the decomposed rows the AQL engine queries. The digest is computed over the first one, and read-time verification recomputes it from the same place. The decomposed rows are never recomputed on a read, so a row edited behind the server’s back is invisible to this check and can still reach a client through an AQL scalar result.
That copy has its own channel: POST /ferroehr/rest/extensions/v1/admin/integrity/verify
re-derives every stored version from its decomposed rows and reports any
that no longer match the stored document. It is an admin route, it runs
outside the request path, and it reports by identifier rather than by
content. The admin API reference
documents the report and its five defect values.
Verification at read
signing.verify_on_read resolves to strict whenever signing is enabled,
so the default deployment checks its own digests. On a VERSION read the
server rebuilds the envelope from the stored row, recomputes the canonical
form, recomputes the digest, and compares.
verify_on_read | On a mismatch |
|---|---|
strict (the default while signing is enabled) | 500; the record is provably corrupt and is not served |
warn | logs at error level, increments version_signature_invalid_total{verdict="digest_mismatch"}, still serves |
off | no check at all |
Verification runs where the server serves a VERSION object: the
versioned_composition and versioned_ehr_status version routes, their
version_at_time forms, the demographic version reads, and the versions a
CONTRIBUTION read resolves under Prefer: resolve_refs. A plain content read
(GET /ehr/{ehr_id}/composition/{uid_based_id}) returns the COMPOSITION
rather than the VERSION that carries the signature, and runs no check. AQL
reads the decomposed storage rows directly and runs no check either; the
storage-parity sweep is what
covers those.
Warning
A
strictmismatch is a500on purpose. The alternative is serving a record the server can prove was altered after committal, which in a clinical repository is worse than an outage on that one version. If a deployment needs reads to continue while an integrity problem is investigated,warnis the deliberate downgrade, and it is metered so the downgrade is visible.
Two cases are never verified, whatever the setting says:
- A client-supplied signature. A
VERSION.signaturea caller sent in a CONTRIBUTION is stored verbatim and served verbatim. The author may have signed another agreed serialization, which this server cannot recompute, so treating a non-match as corruption would be wrong. The stored row records which of the two it holds. - The
ORIGINAL_VERSIONwrapped inside an imported version. Its signature belongs to the system that created it. See PGP signing.
Verifying a digest yourself
The digest is reproducible from the served JSON. Fetch the VERSION:
BASE=http://localhost:8080/ferroehr/rest/openehr/v1
curl -u ferroehr:ferroehr -o version.json \
"$BASE/ehr/$EHR_ID/versioned_composition/$VO_UID/version/$VERSION_UID"
Then, over version.json:
- read the stored value,
jq -r '.signature' version.json; - drop that member,
jq 'del(.signature)' version.json; - canonicalize the result per RFC 8785 (the served bytes are canonical
openEHR JSON, whose member order is
_type-first, so they are not already in JCS order; this step is the one that decides whether the comparison works); openssl dgst -sha256 -binary,base64, and prefixsha256:.
Step 3 needs an RFC 8785 implementation. FerroEHR ships no command-line tool for it, so use a canonicalizer from your own language’s ecosystem.
Note
One case does not reproduce. openEHR allows an attestation to be added “at any time after committal”, and such an attestation post-dates the signature. FerroEHR appends those to the served
attestationslist after verification, so the served list can hold more entries than the signed form did, and the wire carries no marker separating the two. The server’s own check uses the at-committal set; an outside recomputation over a version attested after committal will not match. Versions with no after-committal attestations reproduce exactly.
Switching modes later
The stored format decides how a signature is checked, so a mode change does
not invalidate history: a sha256: digest keeps verifying after the server
moves to pgp mode, because the check keys off the value’s own prefix.
The reverse direction is weaker. A PGP-signed version read by a server in
digest mode has no key to verify against, so it is served without a
verdict rather than failing. Keep the certificate configured if those
versions must stay checkable.
Every key in [signing], with defaults and environment forms, is in the
configuration reference.
PGP signing
pgp mode replaces the stored SHA-256 digest with an OpenPGP
(RFC 4880) detached signature made
with a key the server holds. openEHR names that format directly: the
signature “is generated according to the openPGP standard”, and where a
digest only detects tampering, a signature “authenticates the user as the
author of the content to readers of the content” and “acts as a
non-repudiation measure, since the signature is stored permanently with the
data” (RM Common IM, Change Control §Digital
Signature).
Read Digest signing first: what gets signed, when, and how the canonical form is built are identical in both modes. This page covers what changes.
- What differs from digest mode
- Configuring the key
- Server-generated versus client-supplied signatures
- Imported versions carry two signatures
- Read-time verification
What differs from digest mode
digest | pgp | |
|---|---|---|
| Stored value | sha256: + base64(SHA-256(canonical form)) | ASCII-armored RFC 4880 detached signature |
| Key material | none | an armored secret key the server loads at boot |
| Hash | SHA-256 | SHA-256, inside the OpenPGP signature |
| Proves | the version was not altered after committal | that, plus which key signed it |
| Boot behaviour | nothing to validate | refuses to start without a usable key |
| Verification at read | recompute and compare | verify against the configured certificate |
The bytes being signed do not change. The server assembles the version
envelope, drops the signature attribute, reduces it to its RFC 8785
canonical form, and hands that string to the signer. Only the last step
differs.
Configuring the key
pgp mode needs a key_path pointing at an armored secret key, and the
passphrase that unlocks it if it has one:
[signing]
enabled = true
mode = "pgp"
key_path = "/etc/ferroehr/signing.asc"
key_passphrase_file = "/run/secrets/pgp-pass"
retired_key_paths = ["/etc/ferroehr/signing-2025.pub.asc"]
The same settings as environment variables:
FERROEHR__SIGNING__MODE=pgp
FERROEHR__SIGNING__KEY_PATH=/etc/ferroehr/signing.asc
FERROEHR__SIGNING__KEY_PASSPHRASE_FILE=/run/secrets/pgp-pass
FERROEHR__SIGNING__RETIRED_KEY_PATHS=/etc/ferroehr/signing-2025.pub.asc
Prefer key_passphrase_file over the inline key_passphrase: it is the
shape Docker Secrets and Kubernetes Secrets deliver, and the passphrase then
never appears in the environment. Setting both of the pair is a boot error.
The full key table, the Kubernetes config.files mount, and the rotation
walkthrough are in the
configuration reference.
Boot is fail-closed. The server loads the key, then signs a fixed test
string with it. A missing key_path, an unparseable file, a wrong
passphrase, or a certificate with no usable signing component all stop
startup with an error rather than leaving a running server that cannot sign.
The signing component is chosen at boot. No subkey signs while the primary key is revoked (RFC 9580 §5.2.1.11: “A revoked key is not to be used”) or past the Key Expiration Time its self-signature states (§5.2.3.13). RFC 9580 does not say in so many words that a subkey inherits its primary key’s revocation or expiry, so applying it is FerroEHR’s own choice: the primary key’s binding is what vouches for the subkey. Otherwise a subkey qualifies to sign when all of these hold:
- its newest valid Subkey Binding signature
(RFC 9580 §5.2.3.10)
carries the data-signing key flag
0x02(§5.2.3.29); - neither that binding nor the subkey has expired (§5.2.3.13);
- no valid Subkey Revocation signature covers it (§5.2.1.12: “A revoked subkey is not to be used”).
Of the qualifying subkeys, the one created last signs; on equal creation times, the later one in the file. RFC 9580 gives no rule for choosing among several signing subkeys, so preferring the newest is FerroEHR’s own choice. It is what makes adding a signing subkey a rotation. An encryption subkey never signs.
When no subkey qualifies, the primary key signs, provided the certificate lets it:
- its governing self-signature carries the signing flag
0x02. RFC 9580 keeps the primary key’s flags in a Direct Key signature or, by version 4 convention, in a User ID self-signature (§5.2.3.10), so the governing one is the newest valid Direct Key signature when it carries key flags, else the newest valid self-signature on the primary User ID. A Direct Key signature that a valid Certification Revocation created at or after it revokes is cancelled and not chosen (§5.2.1.13, §5.2.3.10: “Revoking a Direct Key signature cancels that signature”). A version 6 key without a valid Direct Key signature in force is not used at all, as §5.2.3.10 requires. A self-signature with no key flags grants no signing, since §5.2.3.29 counts an unstated flag as zero. RFC 9580 gives no precedence between the two places, so preferring the Direct Key signature is FerroEHR’s own choice; - neither that self-signature nor the primary key has expired (§5.2.3.13, counted from the primary key’s creation);
- no valid Key Revocation signature covers it (§5.2.1.11: “A revoked key is not to be used”).
When neither a subkey nor the primary key may sign, the server refuses
to start and names the signing flag. It never signs with a key the
certificate does not authorise. Add a signing subkey
(gpg --quick-add-key <FINGERPRINT> ed25519 sign), or re-issue the primary
key’s self-signature with the signing flag.
The choice holds until the next restart, so restart after adding, revoking or expiring a subkey, and before the chosen component expires.
Sign with Ed25519. It is the one algorithm the server signs with that has neither a refusal nor a warning.
An ECDSA or Ed448 signing key is refused at boot. The private-key
arithmetic of both runs on crypto-bigint 0.5, whose operations meant to be
constant-time can branch on secret values (RUSTSEC-2026-0387), and the 0.5
line has no fixed release. That covers ECDSA on every curve (P-256, P-384,
P-521, secp256k1, Brainpool). The server stops before it makes a single
signature and names the advisory. Only the signing component counts: a
certificate whose primary key is ECDSA and whose chosen signing subkey is
Ed25519 is accepted. Such a certificate stays usable for verification as a
retired key, since verifying uses only public
values.
An RSA signing key is accepted with a warning. Every commit would then
perform an RSA private-key operation, the operation the Marvin timing
sidechannel (RUSTSEC-2023-0071 / CVE-2023-49092) concerns, and the
underlying rsa crate has no fixed release. The server warns at boot and
keeps working, because a repository whose history is RSA-signed still needs
that key to verify it. An Ed25519 key keeps that code off the signing path.
Retired keys and rotation
A stored VERSION.signature records no key identifier, and it is an
immutable committed fact that cannot be re-issued. Rotating a key therefore
has to keep the old one verifiable. Two mechanisms do that:
- A new signing subkey on the same certificate. After a restart the server signs with the newest qualifying subkey (see above), the certificate retains the previous one, and verification tries the primary key and every subkey. Revoking the old subkey stops it signing and keeps it verifying. Nothing else changes.
retired_key_paths. A replaced certificate’s public half is listed there and consulted during verification. A public key verifies and can never sign again, so a retired entry cannot become an active signer.
A retired certificate may use any algorithm the server can verify, ECDSA, Ed448 and RSA included, so moving off a refused signing key keeps every version it signed verifiable.
Server-generated versus client-supplied signatures
A VERSION.signature in this repository is one of two things, and the
distinction is recorded on the row rather than guessed from the value.
Server-generated is the ordinary case. The direct write routes
(POST/PUT/DELETE on a composition, the EHR status, the directory, a
demographic party) carry no signature field at all, so every version they
commit is signed by this server when signing is enabled.
Client-supplied reaches the server through one route: an
UPDATE_VERSION.signature inside a CONTRIBUTION commit. When a member
carries one, the server stores it verbatim and does not sign that version
itself, whatever signing.mode says. openEHR models the signature as a
fact created by the committer and carried with the data, potentially in
another agreed serialization, so the server has nothing to recompute it
against. Such a signature is never re-verified at read, and no
verify_on_read setting changes that.
Note
A client-supplied value is stored as sent, so an opaque or invalid one is served back unchanged rather than refused. It is a claim by the committer, and this server neither vouches for it nor mistakes it for its own. Only signatures the server generated are covered by read-time verification.
Imported versions carry two signatures
An EHR Extract import wraps each received ORIGINAL_VERSION in an
IMPORTED_VERSION, and openEHR is explicit about what happens to signatures
there. The wrapped original “is never modified”, and the wrapper is signed
like any other version: “all attributes of the object are serialised and
then used to generate a signature. The result will be that the
IMPORTED_VERSION instance will carry its own signature which signifies the
act of importing and making available locally an ORIGINAL_VERSION from
another system.”
FerroEHR implements exactly that:
- The wrapper is signed by this server, over the whole
IMPORTED_VERSIONincluding the wrappeditem, using the configured mode. An import is a local act of committal, so it is signed like one. - The wrapped original’s signature rides inside
itemuntouched. It belongs to the source system’s key, which this server does not hold, so it is served verbatim and never verified.
Reading such a version verifies the wrapper and leaves the wrapped
signature alone. Reading the same version as an ORIGINAL_VERSION (the form
an EHR Extract export carries) reproduces the received original with its
foreign signature and verifies nothing, which is what makes a re-export a
faithful copy.
Read-time verification
The stored signature’s own format decides how it is checked, which is why a mode change does not strand history. The full path:
flowchart TD
R["read a VERSION resource"] --> A["rebuild the envelope from the stored row<br/>(a deleted version carries no data attribute)"]
A --> B{"signature_client_supplied?"}
B -->|yes| S["serve"]
B -->|no| C{"verify_on_read"}
C -->|off| S
C -->|"warn / strict"| D{"a stored signature?"}
D -->|no| S
D -->|yes| E["recompute the canonical form<br/>drop signature, RFC 8785 (JCS)"]
E --> F{"stored value's format"}
F -->|"sha256: prefix"| G["recompute the digest and compare"]
F -->|"PGP armor"| H{"a PGP key configured?"}
F -->|"anything else"| I["client_foreign: serve"]
H -->|"yes (pgp mode)"| J["verify against the certificate,<br/>its subkeys and the retired ones"]
H -->|"no (digest mode)"| I3{"does the armor parse?"}
I3 -->|yes| I
I3 -->|"no: pgp_invalid"| L
G --> K{"verdict"}
J --> K
K -->|"match"| S
K -->|"failure"| L{"verify_on_read"}
L -->|warn| M["log + version_signature_invalid_total,<br/>then serve"]
L -->|strict| N["500 integrity failure"]
S --> T["append attestations added after committal"]
M --> T
I --> T
The server reaches one of five verdicts: digest_match, digest_mismatch,
pgp_valid, pgp_invalid, client_foreign. Two of them are failures,
digest_mismatch and pgp_invalid, and those are the ones counted, under
the verdict label on version_signature_invalid_total.
Three parts of the path are worth stating in prose:
- Attestations added after committal are appended after verification. openEHR allows an attestation “at any time after committal”, so such an attestation post-dates the signature and cannot be inside it. The server verifies the at-committal form and then extends the served list.
- A version with no stored signature is served normally. Versions committed while signing was disabled carry none, and their absence is not a failure.
strictis the default while signing is enabled. A mismatch is a500rather than a served record that is provably altered.warndowngrades that to a logged and metered event, which is a deliberate reduction in an integrity guarantee rather than a setting to leave on.
FerroEHR Viewer
ferroehr-viewer is a standalone web UI for managing an
ITS-REST-1.1.0 CDR, this server or any other. It is a pure REST client:
everything it does goes through the CDR’s public API (never the database),
so what you see in the viewer is exactly what the API serves. The whole
application is Rust, drawn on the server as plain HTML with FerroUI, the
component library the FerroHEALTH consoles share; the only scripts are
FerroUI’s own small modules, and there is no hand-written JavaScript
anywhere, including its browser tests.
- Running it
- Signing in
- The deployment profile
- Configuration
- How the pages work
- Audit log
- Acknowledgment
Running it
The quickstart compose ships the viewer as the ferroehr-viewer service on
port 3000, behind the viewer profile, so it
is opt-in and does not start with a plain docker compose up:
docker compose --profile viewer up
# → http://localhost:3000 (log in with the quickstart user ferroehr/ferroehr)
Standalone, point it at any CDR:
docker run -p 3000:3000 \
-e FERROEHR_VIEWER__CDR__BASE_URL=https://cdr.example.org \
ghcr.io/ferrohealth/ferroehr-viewer
On Kubernetes
The Helm chart deploys the viewer as its own Deployment, Service and ServiceAccount, with an optional Ingress and a NetworkPolicy that confines its egress to the CDR and DNS: the viewer is a REST client of the CDR by mandate, so the chart enforces that rather than trusting it. Off by default, and off renders nothing:
# values.yaml
viewer:
enabled: true
ingress:
enabled: true
hosts:
- host: viewer.example.org
paths:
- path: /
pathType: Prefix
auth:
oidc:
enabled: true
issuer: https://keycloak.example/realms/ferroehr
clientId: ferroehr-viewer
publicBaseUrl: https://viewer.example.org
# the OIDC client secret is MOUNTED from a Secret you create, never env-borne
existingSecret: viewer-oidc
It needs no database credential and never reaches the database. Before you
enable OIDC: a registered client whose redirect URI matches publicBaseUrl,
and a Secret holding its client secret. To turn the viewer off, set
viewer.enabled: false and upgrade; every viewer object is removed and the
CDR is untouched.
Note
To scale the viewer past one replica, set the same
session.secreton every replica: the session is a sealed cookie any key-holding replica can serve. Without a configured secret each replica seals with its own ephemeral key, and visitors get signed out whenever a request lands on another pod.
Signing in
The sign-in page offers exactly the methods that can actually work: the
viewer’s configured login modes intersected with the authentication
schemes the CDR advertises (its WWW-Authenticate challenge). A Basic
form is never shown against a bearer-only CDR, and vice versa. If the CDR
cannot be reached at all, the page falls back to the viewer’s own
configuration and renders anyway, and the outage then surfaces on the login
attempt instead of hiding the page. A username or password the CDR refuses
brings you back to the sign-in card with the refusal under the password.
The viewer verifies the CDR and the OIDC issuer against the platform trust
store and the extra certificate authorities its configuration names: a CDR or
an identity provider whose certificate comes from a private CA is trusted once
cdr.tls.trust_roots_file or auth.oidc.tls.trust_roots_file names a PEM
bundle holding that CA, on Linux and macOS alike. A file that cannot be read,
or holds no certificate, refuses the start.
The viewer manages no accounts of its own: it authenticates you against the CDR (Basic) or your identity provider (OIDC), and there are no user, role, or password screens to find; those live in the CDR’s configuration and in your IdP.
The button with your name at the end of the top bar opens the user menu: who is signed in and how, Your preferences, Your access, the theme, Keyboard shortcuts and Sign out.
Your access
Your access (/access) answers what may this session do, and who says
so: the principal and how it signed in (a Basic session replays its CDR
account and carries no SMART scopes; an OIDC session carries an access token
whose scopes are listed), every scope on the session read as its parsed grant
(the compartment, the resource family and id pattern, and which of create,
read, update, delete and search it permits, with a broad access marker on a
bare *; an identity or launch scope such as openid, profile or
launch/patient reads as the request it makes), and a previewer: paste any
scope or a whole claim, press Read it, and read the same rendering. A
malformed scope says what the grammar expected. The user menu and Server’s
SMART tab link here.
The reading parses with the same grammar the CDR’s SMART on openEHR enforcement reads, so the two cannot drift. Scopes narrow access and never grant it; the CDR remains the enforcer.
When your session ends
A session ends when you sign out, when its idle window
(session.idle_minutes) passes with no request from your browser, or when
the CDR stops accepting the credential behind it (an expired or revoked
token). Every page is drawn on the server, so the next page you ask for is
where the viewer notices: it answers with the sign-in page, which says the
session ended, and signing in again (Basic or OIDC) returns you to the page you
asked for.
The idle window slides on each request your browser makes. The viewer polls
nothing in the background, so a tab left open and untouched idles out after
session.idle_minutes like a closed one, and the page it shows stays as it
was drawn until you ask for another.
The deployment profile
The status pill at the end of the top bar says whether the CDR answers. A
deployment that declares the sandbox profile (deployment_profile in its
configuration, reported on GET /rest/status) raises a band under the top bar
on every signed-in page: this deployment must not hold real patient data,
with the number of production separations the server reported as open (a
shared database credential, a shared cluster, no subject pseudonym namespace,
no durable audit trail, schema preparation on the runtime credential).
Its Details opens Server’s Overview, which names each one. A production
deployment raises no band. The
quickstart and the composed stacks run as sandbox; a CDR older than the
profile reports none and the viewer claims nothing. See the
deployment profile in
the server configuration.
Configuration
One TOML file (ferroehr-viewer.toml, read from the path --config or
FERROEHR_VIEWER_CONFIG names; with none named, no file is read), with
FERROEHR_VIEWER__<SECTION>__<KEY> environment overrides and --set key=value pairs over both. Unknown keys and unknown FERROEHR_VIEWER_
variables are refused at startup. The CDR refuses only an unknown FERROEHR__
override, because the viewer’s variables share its prefix:
| Key | Default | Meaning |
|---|---|---|
server.listen | 127.0.0.1:3000 (0.0.0.0:3000 in the image) | The socket address the viewer listens on. LEPTOS_SITE_ADDR no longer sets it: a value other than server.listen refuses the start. |
server.tls | unset | The listener’s certificate and key, as the CDR’s [server.tls] table; unset, the viewer speaks plain HTTP behind a terminating proxy. |
server.request_timeout_ms / body_limit_bytes | 60000 / 16777216 | How long one request may take, and the largest body it may post (a template or composition posts whole). |
server.drain_delay_ms / shutdown_timeout_ms | 0 / 10000 | On SIGTERM the viewer withdraws readiness, waits the drain delay, then gives the requests in flight the shutdown bound. |
server.* | as the CDR’s | The other keys of the CDR’s [server] table (header_read_timeout_ms, idle_timeout_ms, max_concurrent_requests, retry_after_s, trusted_proxies, forwarded_header, compression, compression_min_bytes) apply as they do there. base_path and caller_rate are refused: the viewer’s pages are served at /, and it mounts no per-caller limit. |
telemetry.* | as the CDR’s | The CDR’s [telemetry] section: format (auto, json or pretty), filter (default info,hyper=warn,tower=warn,h2=warn, refused at startup when it does not parse; RUST_LOG is a lower-priority fallback, as on the CDR), logged_query_parameters, environment and the [telemetry.otlp] export. A viewer request line has the CDR’s fields, and a panic writes the CDR’s one fixed line. The service.name is ferroehr-viewer; the viewer serves no Prometheus surface, so its http.server.request.duration histogram leaves only over OTLP with otlp.metrics. |
cdr.base_url | http://localhost:8080 | The CDR origin (cdr.base_path is appended). |
cdr.base_path | /ferroehr/rest/openehr/v1 | The CDR’s ITS-REST base path. Mirror of the CDR’s own rest.base_path: set the same value here when a deployment shortens it, or the viewer calls paths the CDR does not serve. The status document, the OpenAPI documents and the SMART discovery document are derived from it by the same rule the CDR uses. |
cdr.request_timeout_secs | 30 | Per-request timeout toward the CDR. |
cdr.tls.trust_roots_file | unset | A PEM bundle of certificate authorities trusted for the CDR beside the platform’s roots, for a CDR behind a private PKI. |
cdr.tls.client_identity (_file) | unset | The client certificate chain and its private key, in one PEM, presented to a CDR that asks for mutual TLS. |
cdr.tls.min_version | "1.3" | The oldest TLS version the CDR client speaks; "1.2" lowers it. |
cdr.management_base_url | derived from cdr.base_url | The CDR’s management surface, base path included; set it when the CDR serves management on its own internal listener (management.port) or under a renamed base path. Drives Server’s management tabs. |
auth.basic_enabled | true | Offer the username/password form (validated against the CDR; held server-side). |
auth.oidc.enabled | false | Offer OIDC login (authorization code + PKCE). |
auth.oidc.issuer / client_id / client_secret (_file) / public_base_url / scopes | — | The OIDC client registration; public_base_url is the viewer’s externally visible origin for the redirect URI. Enabling OIDC without issuer, client id and public base URL is a startup error. |
auth.oidc.tls.trust_roots_file / client_identity (_file) / min_version | unset / unset / "1.3" | The issuer client’s TLS, as cdr.tls: extra certificate authorities for an issuer behind a private PKI, a client identity, and the oldest TLS version. |
auth.oidc.resolve | — | A host=ip:port override for the issuer host, for split-horizon DNS: the viewer reaches an issuer whose canonical name only resolves inside the container network, while browsers and tokens keep the canonical URL. |
login.notice | empty | Informational text on the sign-in card, line breaks preserved. A demo or evaluation deployment states its public credentials and usage expectations here. |
login.links | empty | Links under the sign-in card, each { label, href } — an API reference, a documentation page. |
session.idle_minutes | 60 | Session idle expiry: the window slides on each request the browser makes, and the viewer polls nothing in the background (carried inside the sealed cookie). |
session.cookie_secure | true | On by default; set false only for plain-HTTP local development. |
session.secret | empty | The session-cookie sealing key: base64 of at least 64 bytes (openssl rand -base64 64). Every replica of a scaled deployment must hold the same value. Empty = an ephemeral per-instance key, fine for exactly one replica. |
session.secret_file | — | Path to a file holding the sealing key; wins over session.secret. |
ui.locale | en | The language a page renders in when the browser’s Accept-Language names none the viewer ships. English is the one language that ships today. A language is added as a catalog file under app/ferroehr-viewer/locales/<tag>/ (the viewer’s words) beside FerroUI’s (the components’ words) with no code change; the catalogs are embedded when the viewer is built, so a new one reaches a deployment in a rebuilt image, never at run time. A value that is no BCP 47 language tag is a startup error. |
Commands
ferroehr-viewer (or ferroehr-viewer serve) serves until SIGTERM or
SIGINT. ferroehr-viewer config check reads the configuration and builds
the CDR and issuer clients, reading every TLS file, without reaching either.
ferroehr-viewer healthcheck, the image’s HEALTHCHECK, asks the viewer
beside it for GET /health/readiness over the loopback (over TLS when
server.tls is set) and exits 0 only on 200. The viewer serves the
family’s health routes: /health/liveness, /health/readiness and /health.
Browser security headers
Every response carries a Content-Security-Policy that admits scripts and
styles from the viewer’s own origin alone (script-src 'self'; style-src 'self'), with no nonce and neither 'unsafe-inline' nor 'unsafe-eval',
beside X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
Referrer-Policy: no-referrer and Cross-Origin-Opener-Policy: same-origin.
Every document is Cache-Control: no-store; FerroUI’s stylesheet, theme and
scripts under /ferrohealth-ui/<version>/ are no-cache, so a browser
revalidates them. A request that is not a
safe method (a form post, the search panel’s answer) is refused 403 unless the
browser’s Sec-Fetch-Site says it came from the viewer’s own pages; without
that header its Origin or Referer must name auth.oidc.public_base_url.
The viewer is stateless: it has no database and keeps no local files of
its own. Everything it shows, including how stored queries are grouped,
which is derived from the namespace in each query’s qualified name, lives in
the CDR and is read over ITS-REST, so nothing here needs backing up and
every replica shows the same repository. Sessions are a sealed cookie
(AES-256-GCM, keyed by session.secret), so any replica holding the key can
serve any signed-in visitor.
Login and sessions live in the viewer’s server; the browser stores only the encrypted session cookie: CDR credentials and bearer tokens never reach it in readable form.
How the pages work
The viewer draws every page on its server and sends it complete. Every link is a link and every action is a form that posts to the page it sits on, so the whole viewer works with JavaScript off. The browser then loads FerroUI’s small script modules, which add behaviour to a few parts of a page (the code editor, the path trees, the chart legend, the date pickers, the search panel’s suggestions, the user menu’s keyboard handling, the subject reveal and Copy buttons); nothing on a page waits for them. There is no hand-written JavaScript.
An editor (a new EHR, a composition, the EHR status, the directory, a party, a subscription, a template upload) ends in a bar docked at the foot of the page: what has changed, Discard, and the save. A save that the CDR refuses keeps what you typed on the page beside the CDR’s own words.
The screens are listed in the sidebar; below 48 rem it folds into a drawer behind the menu button.
- Dashboard: record counts, the compositions committed per day, and the stored queries by namespace. See Queries.
- EHRs: find, create and delete EHRs; each EHR’s compositions, directory, status, contributions and tags. See Templates and EHRs.
- Parties: the demographic parties, their versions, relationships and tags. See Parties.
- Templates: upload and read ADL 1.4 and ADL 2 operational templates. See Templates and EHRs.
- Terminology: the terminologies the CDR serves. See Terminology.
- Stored queries and Query: build or write AQL, run it, chart and export the rows, save it to the CDR. See Queries.
- Server: what the CDR is, how it runs and what it serves, and the Audit log. See Server and audit log.
- Subscriptions: the CDR’s event subscriptions. See Event subscriptions.
- Configuration: the CDR’s redacted running configuration, by section.
- About: the viewer, the CDR and their versions, the manufacturer and the CDR’s conformance manifest.
The top bar’s search takes an EHR id, a composition or party uid, a subject id, a template id or a stored-query name. From the second character a panel under the field shows what matches, five of a kind; the arrow keys move through it, Enter opens a hit and Escape closes it. Submitting the search opens the find page, which lists every match, or the other ways to find a record when nothing does.
The footer under every page names the viewer and its version, the manufacturer (Cadasto B.V.), and an About link.
Paging
A listing that can show patient data (the EHRs, an EHR’s compositions and
contributions, the audit log) pages with First, Previous, Next and Last under
the table and keeps its page in a cookie for the browser session, never in the
address or the history, as FerroUI asks of every family console. A filter
stays in the address, so a filtered view is still shareable; the page you
were on is not. Each step is a form that posts to /paging and comes back to
the listing, so paging works with JavaScript off.
Preferences and the keyboard
Your preferences keeps the viewer’s display choices, each in its own cookie for that browser: the theme, the reading choices, and the keyboard shortcuts. See Preferences and the theme.
Audit log
The audit log has its own section in Server and audit log.
Acknowledgment
The viewer’s feature set (the Template Manager, the point-and-click Query Builder, and saved, grouped and cohort queries) is inspired by EHRServer by Pablo Pazos and CaboLabs Health Informatics (Apache-2.0). The interface is reimplemented in Rust over FerroEHR’s own AQL engine, and no code is copied.
Dashboard & queries
The dashboard opens the viewer, and Query and Stored queries cover AQL end to end: build a query from a template or write it, run it, read the rows as a table or a chart, export them, and save the query to the CDR as a versioned stored query.
Dashboard
What the CDR holds, as it answered just now: the EHR, composition, template and stored-query counts (each a link to its screen), the compositions committed per day over the last 30 days, read by commit time with one AQL query, and the stored queries grouped by namespace.
Query
Query has two tabs over one form: Build and AQL. Run in the page header runs whichever tab is open; Save to the CDR beside it stores the query.
Build
Build lowers a template and a few choices into AQL you can read:
- Template, the operational template the query reads, and Returns: data points, whole compositions, a count, or the EHRs (a cohort). Apply redraws the form for a new template or shape.
- Data points, each a path from the template’s Web Template and an optional column name; Add a data point adds a row.
- Conditions, in groups that match all or any of their conditions, each negatable. A condition is a path, an operator for that data type and a value: a quantity takes a number with its unit, a date a date, a coded text one of the codes the template allows. Add a condition and Add a group close each group.
- Order and limit.
The AQL the form lowers to is shown under it, and is what Run sends.
Every control is a field of the page’s form, and Add and Remove post it, so Build works with JavaScript off.
AQL
The AQL tab is a code editor with line numbers and the AQL grammar’s findings
beside the line they name, before anything is sent. A $parameter in the
query gets a row under Parameters: its name and the value Run binds.
Open in Build appears when the query is one Build can represent, and opens
it there.
Results
Results come back under the form: how many rows, whether the query carried
its own LIMIT, and the rows as a table.
Chart in the Results heading runs the query again and draws the numeric columns as lines over the first time column (or over the row order when there is none), one series per column with a legend; Table goes back, and the next Run keeps the view you chose. The table under the chart names each point by its time. With FerroUI’s scripts loaded, pressing a legend entry hides that series; the last one shown stays.
Export in the Results heading opens a menu of the formats: CSV and
JSON download the rows. The query runs again on the viewer’s server and
the file arrives as aql-export.csv or aql-export.json. The menu works
with JavaScript off, and with no row Export is unavailable. The
viewer keeps no results between requests, so an export is always the CDR’s
answer at that moment.
Stored queries
Stored queries lists every stored query the CDR holds, grouped by the
namespace of its qualified name (org.example.vitals::latest-weights sits
under org.example.vitals): its versions, its parameters, and Run, Open in
Query and Delete this version from each row’s menu.
A stored query’s page has three tabs. Definition shows its AQL and facts.
Run runs it on the CDR with a value for each parameter. Which version runs
is the openEHR resolution choice: the latest (no version sent), a version
prefix (1 or 1.1, the latest matching it) or an exact version; the line
under the choice names the request it sends. Numbers and true/false are
sent as they are; quote a number to send it as text.
Versions lists every version.
Saving and versions
Save to the CDR takes a namespace, a name and a version. An empty version
lets the CDR assign one. A stored (name, version) pair cannot change: saving
the same pair again is refused and the popover says so. Opening a stored
version in Query fills Save with its namespace, its name and the next minor
version.
Deleting a version needs the CDR’s ADMIN role and asks first: Delete version… on the query’s page or in its row menu.
Templates & EHRs
Templates holds the operational templates the CDR validates compositions against, and EHRs opens every EHR with its compositions, directory, status, contributions and tags.
Templates
The list has a tab per family, ADL 1.4 and ADL 2, each with its count, a find field over id, concept and archetype, and a row per template with Build a query and Delete… in its menu.
Uploading a template
Upload a template opens an editor for one source: choose or drop a file
(.opt or .xml for ADL 1.4; .adl, .adls, .adlt, .adl2 or .txt for ADL 2; up to 16 MiB) or
paste the source, then Upload this template. The CDR validates it; a
refusal keeps the source on the page beside the CDR’s diagnostics, for ADL 2
the openEHR-ADL engine’s own words. With JavaScript off, paste the source.
A template
A template’s page has four tabs. Overview reads its identity and how to query it.
Paths is its Web Template as a tree: pick a node to read its path, type and codes, and on a COMPOSITION template Build a query on this path. A demographic template’s nodes offer no query, since the query builder reads compositions. With FerroUI’s scripts loaded the tree folds in place and answers the arrow keys; without them each node is a link.
Source is the template as stored, with line numbers and Copy; Download in the More menu saves it as a file. Example is the composition the CDR generates for it, in the format, type and detail you pick.
ADL 2 templates
The ADL 2 tab lists every release of every ADL 2 template, one row each. A
template’s page shows its latest release with a picker over the others, and
the JSON tab beside Source shows the OperationalTemplateV2 document. An
older release opens on its Overview, says which release you are reading, and
links back to the latest.
Deleting a template
Delete… in a row’s menu opens the template with the confirmation inline; on the template’s page it opens a dialog. The CDR refuses while compositions use the template, and deleting needs its ADMIN role; either refusal arrives in the CDR’s words.
EHRs
The list shows every EHR the CDR holds, newest first, and finds one by its subject id and namespace. The subject is shown masked.
New EHR creates one with its status: an optional subject (an id the CDR keeps without reading it, and its namespace) and an optional EHR id; leave the id empty and the CDR mints a UUID. An id that is no UUID is refused before anything is sent; an id the CDR already holds is refused with a link to that EHR.
An EHR
An EHR’s page names it, its subject masked until you press Reveal (with JavaScript off the reveal is a link), and its Queryable and Modifiable badges, on every tab. A revealed subject hides again after the time set in Your preferences (30 seconds unless you change it). New composition and More (Commit several changes, Create the directory, Copy EHR id, Delete EHR…) sit in the header.
The tabs:
- Compositions: the EHR’s compositions, filtered by composer, template and start date; the filters stay in the address.
- Directory: the folder tree, Show one folder by path, and the directory’s versions with Restore… on each older one. Edit opens the editor: add, rename and remove folders and add references to the EHR’s compositions; nothing reaches the CDR until you save. Older versions reads further back than the newest ten.
- Status: the current
EHR_STATUSand its versions; Edit status sets Queryable, Modifiable and the other details as one new version. - Contributions: the contributions per day over the last 30 days and every contribution, each opening its versions.
- Tags: every tag in the EHR, on a composition or on the EHR status, by key and value; a row opens the object it tags.
Every save names the version it supersedes (If-Match). A save over a
version someone else replaced meanwhile is refused with the CDR’s 412, and
the editor offers to reload the newer version.
Deleting an EHR
Delete EHR… asks for the first eight characters of the EHR’s id, then removes the EHR and everything in it; it needs the CDR’s ADMIN role.
Committing several changes at once
Commit several changes builds one contribution: stage a new composition, an amended one or the EHR status, each with its document in an editor of its own, as many as you need, then commit them together with a description. The CDR takes all of them or none: a refused change keeps every staged change on the page beside the CDR’s words.
Compositions
A composition’s page has four tabs. Reading shows it as a person reads it, label and value, by section.
Document shows it in the format you pick (JSON, XML, FLAT or STRUCTURED),
as the CDR converts it. Versions lists every version and reads the
VERSIONED_COMPOSITION and the chosen version’s envelope; At a time opens
the version that was current then. Tags lists the composition’s tags and
sets or removes one; a tag stays on the composition across its versions and
commits no version.
Edit as a new version opens the document in an editor; the save writes the next version. Delete composition… marks it deleted with a new version; its versions stay readable and it leaves the EHR’s list.
Parties
Parties reads and writes the demographic parties the CDR holds (people, organisations, groups, agents and roles), their versions, the relationships between them, and their tags.
Note
Parties follow the released RM 1.1.0 demographic model. The openEHR REST API for them is still in development, and the relationship routes are this server’s own extension: a different openEHR server will not serve them.
Finding a party
Pick a kind and type a party’s uid, then Open. There is no list of parties: the demographic API publishes no “list all people” endpoint, and AQL reads EHRs. A versioned object uid opens the latest version; a version uid opens the object it belongs to. Open is a plain form and works with JavaScript off.
The one demographic collection the API publishes is the tag index, under Open: every tag on any party, each row opening the party it tags.
Creating a party
New party opens an editor with a starting document for the kind you pick
(Start over swaps it for another kind’s). Every attribute in it is one
openEHR requires: the party’s name, which openEHR uses for its type (the
names a person goes by live in identities), the archetype_details and the
root archetype_node_id, at least one identity, and for a role its
performer. Fill in the identity, replace the archetype ids with your own
demographic archetypes’, and create. The document is sent as you wrote it; a
refusal comes back with the CDR’s words and the path it names, the document
still on the page.
A party
A party’s page has five tabs. Overview reads its facts and identities.
Document shows it as the CDR serves it. Versions lists every version, each opening its envelope and its contribution; At a time opens the party as it stood then. Relationships lists the relationships the party is the source of. Tags lists its tags and sets or removes one.
Edit opens the whole party as JSON; the save writes the next version with
the opened one as its If-Match, so a party someone else changed meanwhile is
refused with the CDR’s 412. Delete party… marks it deleted; its versions
stay readable.
Relationships
A relationship joins a source party to a target: an employment, an authority, a care relationship. openEHR models it two ways, and both are visible here:
- inside the source party, which carries the relationships it is the source of; its Relationships tab lists those, each row opening the target;
- as an object of its own, with its id, versions and history, the shape this server’s relationship routes serve.
The target side cannot be listed: openEHR defines “relationships pointing at this party” as a derived attribute the CDR does not populate, and the tab says so where you would look.
Relate this party opens a new relationship with this party as its source: a type (openEHR stores it as the relationship’s name), the target’s kind and uid, and optional details. A relationship’s own page links both ends, reads its facts, versions and document, and edits its type and details; the ends do not change, since a relationship between other parties is another relationship.
Tags
A tag is a key, an optional value and an optional target path. Setting one re-sends the party’s whole tag collection, as the openEHR tag update does: the viewer reads the current tags and merges yours in, so a tag another client added between your read and your save can be lost; read again before editing a busy party. A tag stays on the party across its versions and commits no version.
Contributions
Every write here commits a contribution, openEHR’s record of one change set: who committed it, when and why. A version’s contribution link opens it, with the versions it touched, each linked to the object it belongs to.
Terminology
The Terminology screen browses the coded vocabularies the CDR can answer questions about: which terminologies it serves, what a code means, which codes a value set holds, whether a given code is one of them, and whether one code subsumes another. Everything on it is a read of the CDR’s public API; nothing is stored on the viewer’s side.
Note
The terminology API is this server’s own extension. openEHR’s REST release publishes no terminology contract, so the operations, URLs and JSON envelopes are this server’s own design over the openEHR TERM specification. A different openEHR server will not serve them.
Switching it on
The surface is opt-in and off by default. While it is off the CDR answers its terminology routes as if they were not mounted, and the screen says so in one card instead of pretending to work:
[terminology]
api_enabled = true
or, as an environment override, FERROEHR__TERMINOLOGY__API_ENABLED=true.
Turning it on exposes only lookups over terminologies the server already holds: the bundled openEHR terminology plus the external code sets beside it. Wiring the FHIR terminology server is a separate setting, and it changes what these lookups can answer; both are covered in Terminology servers.
The terminologies
Terminologies, the first of four tabs, lists exactly what the CDR reports:
the internal openehr vocabulary and the external code sets beside it
(languages, countries, character sets, media types). A row opens the
terminology’s descriptor: publisher, identifying URI, version, and the fields
an extract may ask for, with links to look a code up in it and to expand one
of its groups. Fields the server does not publish are left out.
Look up
Pick a terminology, type a code and Look up. A defined term comes back with its text, the language it is written in and whether it is the preferred term. An effective date is optional: supplied, it asks for the definition as it stood that day; the openEHR bundle is one pinned release, so a date changes nothing there, while an external terminology server can answer differently.
A code the terminology does not define is an answer, not an error: the page says so, naming the code and the terminology.
Note
openEHR terminology codes are scoped to their group:
532iscompletein one group andcompletedin another. A lookup reads theopenehrterminology as flat and returns the first group’s rubric, so expand the group under Value sets whenever the group matters.
Value sets
Pick a terminology and a value set (for openehr, a vocabulary group such as
audit_change_type, offered as you type) and Expand it into its members.
Under them, Validate answers whether a code is a member, as a sentence
either way; a value set the CDR does not know has no members.
Hierarchy
Test asks whether one code is broader than another. The test is strict, so a code is never broader than itself, and the openEHR vocabulary defines no hierarchy, so for any two openEHR codes the answer is no; the page says why. Hierarchical answers come from an external terminology server when one is bound.
Every tab is a GET form: the question stays in the address, so an answer is shareable, and every tab works with JavaScript off.
In the query builder
A condition on a coded text in Build offers the codes the template allows as a select, so a coded criterion is never typed from memory; the query carries the bare code.
Server & audit log
Server says what the CDR this viewer reads is, how it runs and what it serves, as six tabs, and the Audit log browses its security audit trail. Everything on them comes from the CDR’s own endpoints over HTTP; the viewer has no privileged channel.
Overview
The CDR’s state, its deployment profile and version, and how it was built:
the version, the git commit, the build date and rustc, the PostgreSQL
target, the audit posture (whether auditing is on, its fail_mode, the local
store and the retention), the active
spec_profile with the
openEHR specification versions it selects, the manufacturer, and the end month
of the release’s support. The conformance manifest the CDR answers at
OPTIONS on its base path (its product, vendor, conformance profile and the
API groups it mounts) is on About.
Health, Metrics and Logging
These three tabs read the CDR’s management surface, and appear only when the
CDR serves it. To get them, enable the surface and give each endpoint an
access level (Operations → The management
surface); every endpoint ships
off:
[management]
enabled = true
[management.endpoints]
info = "admin_only" # the probe the tabs appear on; enable it with the rest
metrics = "admin_only"
env = "admin_only"
loggers = "admin_only"
If the CDR serves management on its own internal listener
(management.port) or under a renamed base path, point the viewer at it with
the full prefix (FERROEHR_VIEWER__CDR__MANAGEMENT_BASE_URL); unset, the
viewer derives {cdr.base_url}/management. Being allowed to read an endpoint
is the CDR’s decision per request, and a refusal is reported on the tab that
asked.
Health lists what the CDR depends on (the database, the migrations, the
audit chain and sender, and any optional components) with each one’s state
and the CDR’s detail. It reads the public readiness probe
(GET /health/readiness). The pill in the top bar answers a different
question: whether the API answers at all.
Metrics has three tiles (compositions committed, AQL queries, database
connections in use) over the whole metric registry: pick a
metric and Show its current samples with their labels. The choice stays in
the address (/server/metrics?metric=db_pool_connections). For dashboards
and alerts, scrape GET /management/prometheus.
Logging shows the filter in effect and the boot filter, and changes the
live filter without a restart: type tracing directives
(ferroehr=debug,sqlx=warn), Apply…, and confirm in the dialog; Reset
to the boot filter puts the startup value back. The tab then reads the
CDR’s answer, so what you see is what the server confirmed.
Important
A log-filter change applies to that CDR instance only and lasts until it restarts. Behind a load balancer each instance is set on its own; for a lasting change, set
log.filterin the server configuration.
API and SMART
API lists the OpenAPI documents the CDR serves for itself, one family at
a time: pick one (openEHR EHR, Query, Definition, Demographic, Admin, and the
FerroEHR extensions, or the complete surface) and read its endpoints by tag.
The family stays in the address (?family=query).
SMART reads the CDR’s SMART discovery document: the scopes it supports as parsed grants, the grant types and PKCE methods, and the document itself. Your access shows the scopes your session holds.
Configuration
Configuration in the sidebar shows the CDR’s redacted running configuration by section, each key with what it means; secrets show as their reference, never their value.
Audit log
The Audit log browses the CDR’s security audit trail (who accessed what,
with what outcome) through the IHE ITI-81 retrieval
(GET /fhir/r4/AuditEvent; see the Audit trail chapter).
Filter by principal, outcome, action, date window and patient; the filters
stay in the address, so a filtered view is shareable. The page lives in the
session, as on every listing that can show patient data.
A row opens its record: the event, who, the patient, what was done and the
outcome, and the stored FHIR R4 AuditEvent with line numbers and Copy.
A filter that matches nothing says so, with the filters named and a way to clear them.
The audit trail is an operator surface: the screen needs the CDR’s ADMIN role, and a session without it is refused in the CDR’s words. When the CDR’s local audit store is off, the screen says so.
Event subscriptions
The Subscriptions screen administers the CDR’s event subscriptions: the server-side filters that decide which committed versions are published to your message broker, and to which queue. Everything on it comes from the CDR’s own subscription API over HTTP; the viewer has no privileged channel and keeps no subscription state of its own.
When it appears
Change eventing is an extension: no openEHR specification governs event publication or a subscription resource, so the whole surface (its paths, its payloads, its status codes) is this CDR’s own design, and it is off unless a deployment turns it on. See Change events (AMQP) for what the stream carries and how a queue is bound.
The screen is probe-and-hide: on every page load the viewer asks the CDR
for GET /admin/event_subscription, and the sidebar entry appears only if that
route exists. A 404 (the CDR’s answer while the subscription API is off,
which is the default) hides the entry entirely; any other answer counts as
present, so a refusal reaches you as a message on the screen that asked rather
than as a missing screen.
To get the screen, turn the subscription API on
([events]):
[events]
admin_api = true # mount /admin/event_subscription
Reaching /subscriptions on a deployment without it is not an error either:
the screen names that switch instead of drawing a table that cannot be read.
Note
admin_apiandenabledare separate switches, and this screen needs only the first. Subscriptions are stored whether or not the publisher is running, so you can define them before a broker exists; they start being delivered when[events] enabled = trueconnects the server to one.
Note
The API is mounted under
/admin, so the CDR’s role-based access control classes every call here as admin work. The screen renders whenever the surface exists; being allowed to use it is the CDR’s per-request decision, and a session without the ADMIN role is refused with a message naming what is missing.
What a subscription is
A subscription is a name plus three predicates, each of which matches one facet of a committed version:
| Field | Matches | Example |
|---|---|---|
| Kind | the versioned object’s type | COMPOSITION, EHR_STATUS, FOLDER |
| Change type | the audit change-type code | 249 (creation), 251 (modification), 523 (deletion) |
| Template id | the template a composition was committed against | vital_signs.v2 |
A field left empty matches anything. Each row says in words what it selects (“Every Creation of a Composition”), so a feed of every committed version reads as one.
The name is unique on the CDR and is also the suffix of the queue the
server declares for the subscription (ferroehr.events.<name> with the default
exchange), so it may hold only letters, digits, and _, . or -, and it
cannot be changed afterwards; a rename would be a different queue.
State is the enabled flag: an enabled subscription is one the server
binds and delivers to; a disabled one is kept, exactly as you defined it, and
not delivered. Disabling is therefore the reversible way to stop a feed.
Administering them
The list shows every stored subscription, newest first: its name, what it selects in words, its state and when it was created, with Edit, Disable or Enable, and Delete… in each row’s menu.
- New subscription opens an editor: a name, and as many predicates as you want to narrow by; leave them all empty for a feed of everything, and switch delivery off to keep it without sending. A name the CDR cannot accept, or one it already holds, is refused on the form, what you typed kept.
- Edit opens the subscription’s current values. Saving replaces every predicate, so a field you clear becomes any on the CDR: the whole update, not a patch. The name is shown and never editable.
- Delete… opens the subscription with the confirmation, and nothing is sent until you confirm.
Every write reports its outcome as a notice, a failure as visibly as a success.
Warning
Deleting a subscription stops the server binding its queue; it does not delete the queue from your broker. A durable queue left behind keeps what it holds and stops receiving new messages, so remove orphaned queues on the broker in the same change.
Preferences & the theme
Your preferences (in the user menu) keeps the viewer’s display choices for you, in this browser: each one is its own cookie, read when the server draws a page, so a choice applies from the first paint and nothing is kept in browser storage. Nothing here names a patient, and nothing changes what you may do. Reset to defaults… clears them all.
Appearance
- Theme: light, dark, or the one your system uses. The user menu sets it too.
- Density: comfortable (the default) gives rows and forms room; compact fits more rows in every list.
- Reduce motion: follow the system, or always reduce.
Reading
- Language: follow the browser, or one of the languages the viewer ships
(English today; see
ui.localeunder Configuration). - First day of the week in the date pickers.
- Show a revealed identifier for 15 seconds to 2 minutes: how long a subject id stays shown after Reveal.
Keyboard
Shortcut hints, the key caps after a label: on wide screens with a keyboard (the default), on all wide screens, or never. Phones never show them. The keyboard shortcuts themselves are listed under Keyboard shortcuts in the user menu.
The dark theme
Every screen is drawn in both themes:
Operations
Running a clinical data repository in production means more than starting the binary: the database must be backed up and least-privileged, traffic must be encrypted, upgrades must be safe while the service stays up, and you need to see what the system is doing. This chapter is a production checklist (database roles, TLS, backup and point-in-time recovery, upgrades and migrations, observability, the health probes, and the management surface) drawn from how the container image and Helm chart are built to run. The operator-facing HTTP surfaces have their own page: Admin & messaging APIs.
- Database roles and least privilege
- Applying migrations
- TLS and database security
- Backup and point-in-time recovery
- Rotating the national-identifier key
- The container image and pod hardening
- Upgrades
- Observability
- The admin and messaging APIs
- Health probes
- The management surface
- The manufacturer and the deployment report
- Next
Database roles and least privilege
FerroEHR connects to an external PostgreSQL 18: a managed service or an operator-run cluster, never a chart-side sidecar, because a database holding PHI must be independently backed up and recoverable. The server carries only a connection string, ideally sourced from a secret.
The database never runs as a superuser at runtime. Two roles cover provisioning and migration:
| Role | Purpose | Used by |
|---|---|---|
| owner | owns the database | provisioning only |
ferroehr_migrator | runs the schema migrations; owns the helper functions | the migration step |
and five cover serving, split by pseudonymisation domain — the clinical record, the identity of its subject, and the map between the two:
| Role | Reads and writes | Barred from |
|---|---|---|
ferroehr_clinical | clinical, archival tier included | party, linkage |
ferroehr_party | party, archival tier included | clinical, linkage |
ferroehr_clinical_reader | read-only over clinical | party, linkage |
ferroehr_party_reader | read-only over party | clinical, linkage |
ferroehr_linkage | linkage (the party-to-EHR map) | clinical, party |
The migrations create these roles idempotently, apply the per-schema grants,
and revoke every other domain explicitly in both directions, and revoke the
ability to create objects in the public schema. All five are NOINHERIT and
none is a member of another, so a boundary cannot be crossed by picking up a
membership. GDPR Art. 4(5) defines pseudonymisation as processing where
attribution to a person needs additional information “kept separately and
subject to technical and organisational measures”, and Art. 32(1)(a) names it a
security measure for health data
(https://eur-lex.europa.eu/eli/reg/2016/679/oj); EDPB Guidelines 01/2025
require that separation to hold against internal actors, operators with
database access included.
Each serving role holds SELECT/INSERT/UPDATE/DELETE on its own domain’s
tables, EXECUTE on the ext helper functions, and MAINTAIN on its store’s
partitioned tables (and ehr, for ferroehr_clinical), and nothing else: it is not
a superuser, does not bypass row-level security, and cannot create, alter or
drop a table, index, schema or role. On the audit trail it is narrower still:
it may record an event and stamp it forwarded, and it holds no privilege that
can rewrite or remove one (see Audit).
MAINTAIN is there so the server can keep those tables’ planner statistics
(see Planner statistics of the partitioned tables).
It also allows VACUUM, CLUSTER, REINDEX and LOCK TABLE on exactly
those tables. The role can already write every row of them, so what it adds
is the locks and index rebuilds those commands take. The reader roles do not
hold it.
Turning the schema split into a role split
The schema separation is unconditional: the server always reads and writes
parties in party, whatever it authenticates as, and each of the four pools
carries only its own schema on its search_path. The role separation is a
deployment choice, and it is one configuration table per domain:
[storage.party]
url = "postgres://ferroehr_party:***@pg:5432/ferroehr"
[storage.linkage]
url = "postgres://ferroehr_linkage:***@pg:5432/ferroehr"
(or url_file, for a mounted secret). With a domain’s url set the server
opens that pool on that credential, and a flaw that reaches one of them reaches
one domain. Left unset, the domain uses [db].url and the separation is
schema-only. linkage holds which party is the subject of which EHR — the one
map that re-joins the other two — so ferroehr_linkage is barred from both of
them and both of them from it. Set every domain and no credential the server
uses can perform that join in SQL; the crossing happens in the application, over
two connections, and is recorded as an access event (see Security → Resolving
across the boundary).
A DSN that names another host moves the domain to a database or cluster of its
own, which is the separation a schema cannot make: a base backup, WAL
archiving and physical replication carry every schema of a database together
(PostgreSQL 18, Backup and
Restore). A database that
holds the linkage domain without the party domain also receives the party
migration set, its schema and grants and never a row, because the linkage set
revokes a function the party set creates.
Two boot checks make the posture honest. Two domains configured on different
DSNs that authenticate as the same database role are refused — a separation
that exists only in the configuration reads as one that holds, which is worse
than none. And a domain role that does not exist is a warning under
deployment_profile = "sandbox" and a refusal under production: absent roles
mean absent grants, and the boundary check would otherwise pass by having
nothing to measure.
Which credential prepares the schema
None of the domain credentials. Preparing a database spans every schema in
it at once: the DDL of each resident migration set under db.migrate = "apply",
and their _sqlx_migrations bookkeeping tables under "verify" — a read, but a
read across the whole database. Each runtime role holds exactly one domain, so
none can do it, and verify is not the exception: a role that is a member of
ferroehr_clinical and nothing else is refused on the very first set: it
cannot read the ext, party, linkage or audit bookkeeping.
So the credential that prepares the schema is named separately, and the server uses it for that one boot step:
[db]
url = "postgres://ferroehr_clinical:***@pg:5432/ferroehr"
migrate_url = "postgres://ferroehr_migrator:***@pg:5432/ferroehr"
[storage.party]
url = "postgres://ferroehr_party:***@pg:5432/ferroehr"
[storage.linkage]
url = "postgres://ferroehr_linkage:***@pg:5432/ferroehr"
migrate_url prepares every domain that reaches the same DATABASE it does,
which is all of them in the posture above: three credentials, one database. A
domain whose DSN reaches a DIFFERENT database is prepared there on that
database’s own migrator, [storage.migrate.<domain>] url, because
migrate_url names one database and a relocated domain is not in it; unset, it
falls back to the domain’s own DSN, which a DML-only role cannot use. Which case a domain is in is read from the server itself
(pg_control_system() and current_database()), never from the DSN text — two
DSNs that differ only in their credential reach the same database.
(or migrate_url_file, for a mounted secret). The connection is opened for
preparation and closed again: no pool is held on it, and no request is ever
served through it. Left unset it falls back to url, which is exactly what a
single-credential deployment has always run — its behaviour is unchanged by
this key existing.
A credential that cannot read a set is told which schema and which role, so the fix is visible from the message:
database role `app_ehr` cannot read the migration state of schema
`ext`: … point `[db] migrate_url` (or `migrate_url_file`) at the credential
that prepares the schema — unset, it falls back to `[db] url` — and, for a
domain in a database of its own, `[storage.migrate.<domain>] url` at that
database's migration credential
Either way the server refuses to boot when the grants themselves are wrong:
a self-check enumerates every table, view, sequence and function in each
domain and fails, naming the role and the object, if either runtime role can
read across the boundary. ferroehr db verify runs the same check. When the
five roles do not exist at all (the development, compose and test-harness case,
where the migrator holds no CREATEROLE) the check warns rather than inventing
a failure — under deployment_profile = "production" it refuses, because absent
roles mean absent grants and there is then nothing to measure.
On Kubernetes the same choice is chart values, mounted as files the same way the clinical DSN is, so no credential enters the pod’s environment:
database:
existingSecret: ferroehr-db # postgres://ferroehr_clinical:…
party:
existingSecret: ferroehr-db-party # postgres://ferroehr_party:…
linkage:
existingSecret: ferroehr-db-linkage # postgres://ferroehr_linkage:…
audit:
existingSecret: ferroehr-db-audit # the audit repository's own DSN
migrateExistingSecret: ferroehr-db-migrator # postgres://ferroehr_migrator:…
Leave a domain’s block unset and that pool uses the shared DSN, which is the schema-only posture.
Provisioning the five roles is yours in both cases. The migrations create them
only when the migrator holds CREATEROLE and skip them with a NOTICE
otherwise, so on a managed database — where the migrator usually does not —
create them before the first deploy:
CREATE ROLE ferroehr_clinical NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_party NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_clinical_reader NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_party_reader NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_linkage NOLOGIN NOINHERIT;
then give each login role membership of exactly one of them. ferroehr db verify tells you whether the boundary holds afterwards.
The local Audit Record Repository is written on its own pool
([storage.audit]), and the audit schema is granted to ferroehr_clinical
(record an event, stamp it forwarded, run the retention reaper, verify the
chain) and to ferroehr_clinical_reader (read it and verify the chain). A
clinical login role that is a member of ferroehr_clinical alone writes its own
access log; no extra membership is needed. The audit trail is not a
pseudonymisation domain, so this grant adds no reach into party or linkage.
The compose stacks create all five and grant the clinical and party domains to the single dev login role, which owns the database. That demonstrates the schema separation and exercises the boot self-check; it is deliberately not the credential separation, because one container with one DSN cannot show that half honestly.
Which of these postures is exercised, and which is not
A recommendation nothing runs is a guess. This is what the suites cover.
Exercised against a real PostgreSQL 18:
- Two runtime credentials serving both domains through the assembled router,
each of the server’s own pools refused every relation of the other domain
(
app/ferroehr-rest/tests/it/credential_separation.rs), and the single-DSN fallback still serving both. - Three runtime credentials across the whole identity-to-party-to-EHR
crossing, with the linkage credential refused the identifier map
(
app/ferroehr/tests/it/pseudonymisation_boundary.rs). - All five domain roles refused every relation in the domains they do not own,
enumerated from
information_schemarather than from a written list (same file). - The boot sequence on separated credentials with
[db] migrate_url, under bothverifyandapply, and the single-DSN fallback under both (app/ferroehr/tests/it/schema_preparation_credential.rs). - The linkage domain relocated to a second database, prepared and verified
there on
[storage.migrate.linkage], with the companion party set, and the refusal when the domain’s two DSNs reach different databases (app/ferroehr/tests/it/relocated_domain_preparation.rs). - The chart’s production overlay on four PostgreSQL clusters, one per domain,
prepared by the migration Job and booting under
production(scripts/deploy-probe-k8s.sh --production-only, probes P-K8S-PROD-BOOT and P-K8S-PROD-SHARED). - The compose stack’s grant boundary read back from the running database by
scripts/deploy-probe.sh, which also records that the stack is single-credential by design.
Not exercised by anything, and stated rather than left to inference:
- A separated
migrate_urlalongside the linkage credential in one boot. The preparation tests configure the clinical and party DSNs. - A real deployment on separated DSNs: a container booting with the DSN
files mounted, and the chart wiring them through
database.party.existingSecret,database.linkage.existingSecretanddatabase.migrateExistingSecret. What is covered is the in-process half. - Role provisioning on a managed database where the migrator holds no
CREATEROLEand the roles are created by the manual step above.
Which posture you actually get depends on db.migrate, because the server’s
embedded migrations are DDL: a self-migrating deployment necessarily runs as a
role that can execute DDL. The single-container quickstart takes that path: its
DSN authenticates as a non-superuser role that owns the database and is a member
of ferroehr_migrator, ferroehr_clinical, ferroehr_party and
ferroehr_linkage.
Applying migrations
db.migrate decides who runs the schema:
apply(the default): the server applies its embedded migrations at boot. This is what makes a fresh checkout and an empty database work with no configuration at all, and it is the right choice for development and for small single-tenant deployments. The runtime DSN must be a member offerroehr_migrator, so the serving process holds DDL rights for its whole life. Least isolation.verify: the server issues no DDL at all. At boot it checks that the database carries exactly this build’s migrations and refuses to start otherwise, naming the schema and what is wrong with it. The pools can then hold no schema rights at all, which is the least-privilege production posture: an application-level SQL flaw can reach rows, never the schema. The check itself still reads every schema’s bookkeeping, so it runs ondb.migrate_url— see which credential prepares the schema, and set that key wheneverdb.urlis a role that holds one domain.
Migrations are append-only, so upgrading an existing database in place is the supported path and always has been the one your data takes. A released migration is never edited: sqlx records a checksum of each applied file and refuses a database whose recorded checksum no longer matches, so a corrected schema arrives as a new migration rather than as a change to an old one. A CI guard fails any pull request that modifies, renames or deletes a migration the base branch already carries.
With verify, something else has to run the migrations first. Use the binary’s
own subcommand under the migrator DSN: a CI/CD stage, a one-shot job, or the
Helm chart’s migrations.job.enabled pre-install/pre-upgrade hook Job, which
Helm waits on so a failed migration fails the release (it takes its own
migrations.job.existingSecret holding the migrator DSN, deliberately a
different credential from the runtime one, and rendering fails without it):
FERROEHR__DB__URL='postgres://ferroehr_migrator:***@pg:5432/ferroehr' \
ferroehr db migrate # applies; exits when done
FERROEHR__DB__URL='postgres://ferroehr_clinical:***@pg:5432/ferroehr' \
FERROEHR__DB__MIGRATE_URL='postgres://ferroehr_migrator:***@pg:5432/ferroehr' \
ferroehr db verify # read-only check; exit 0 iff the schema is current
db verify splits the same way the boot sequence does, and for the same
reason: the schema state is read on migrate_url, while the pseudonymisation
boundary is measured on the runtime DSNs — asking the migrator credential
whether it can reach every domain would answer yes by design and tell you
nothing about the roles that serve requests.
Gate the rollout on the migration step so two server versions never race the
schema. Note the difference in failure shape: a verify server refuses to boot
against an unmigrated database (loud, immediate), while an apply server that
loses its schema later stays up and reports readiness DOWN, the warning
below.
Warning
Migration is a boot step, and nothing re-runs it. A running instance whose database is replaced, wiped, or reachable-but-empty does not migrate. It reports
{"state":"down","phase":"serving","indicators":[{"name":"db","state":"up"}, {"name":"migrations","state":"down"}]}on
/health/readiness(503), with the detailcore schema tables missing (migrations not applied)on/management/health, leaves the load balancer’s rotation, and keeps passing liveness (correctly, since the process is healthy) so nothing restarts it. Under Kubernetes that is a Deployment sitting at0/Nready with no error after the first one.The readiness check re-tests the schema on every probe, so recovery does not require a restart of that instance: it goes back to
UPwithin one probe interval of the schema existing, whoever created it. What needs a restart is the case where the only thing that would migrate is the instance itself: thenkubectl rollout restart deploy/ferroehr(or a migration job) is the remedy.For the out-of-band flow this means: the migration step must complete before the first instance starts, or that instance sits unready until the schema appears, harmless but confusing, and it delays the rollout rather than failing it. Gate the rollout on the migration job.
Planner statistics of the partitioned tables
node, version and vo_attestation are partitioned by storage tier in both
stores, and PostgreSQL estimates a join over them from the statistics of the
partitioned table itself. Autovacuum analyzes the partitions and never the
partitioned table (PostgreSQL 18, §24.1.3 Updating Planner Statistics), so
FerroEHR runs ANALYZE ONLY on the partitioned tables itself, and on ehr
while autovacuum has never analyzed it:
- after it applies the migrations, as the migration credential;
- after
load_ehrsloads an archive; - at boot and then every hour, as each store’s serving role.
Without these statistics an AQL CONTAINS can read every COMPOSITION’s
nodes. ANALYZE needs the MAINTAIN privilege on the table, and the
migrations grant it to ferroehr_clinical on clinical.node,
clinical.version, clinical.vo_attestation and clinical.ehr, and to
ferroehr_party on the three partitioned tables of party. When a serving
role still lacks it, for example because the roles were absent when the
migrations ran, the hourly refresh logs a warning naming the tables it could
not analyze. Grant the role MAINTAIN on those tables, or run the same
ANALYZE ONLY from a scheduled job as the migration role.
Recovering a partially wiped database
A wipe that removes some of the server’s schemas is not a fresh start, and the server cannot tell it from one.
The server owns five schemas: ext, clinical, party, linkage and audit.
A fresh start drops all five. Each domain’s cold archival tier is a partition
of the relation it archives, inside that domain’s own schema, so a
DROP SCHEMA clinical CASCADE takes the archived rows with the live ones. There
is no separate mirror schema left to survive a wipe of the tier it mirrors.
Dropping some of the schemas is not a supported reset, because they are not
independent. btree_gist is installed in ext, so DROP SCHEMA ext CASCADE
also drops the temporal uniqueness constraint on linkage.subject_ehr that the
extension’s operator classes back, and the linkage set, still recorded as
applied, never recreates it. linkage also maps parties to EHRs that a dropped
clinical no longer holds. The next boot rebuilds the dropped schemas, and its
migration check reads each set’s bookkeeping only, so it passes over a database
that has lost those guarantees.
A database written by a release before the storage rewrite is refused outright:
this database predates the storage rewrite: schema `ehr` carries its own
migration bookkeeping, which only a release before the rewrite wrote.
The new migration sets replace the old ones rather than upgrading them, so there is no in-place path. Dump anything worth keeping, recreate the database, and start the server against it.
Tip
When you wipe a FerroEHR database deliberately, drop the database, not a schema.
DROP DATABASEcannot leave half a repository behind, and it is the only wipe with no partial-state failure mode.ferroehr db erasedrops all five schemas of a database in one transaction, for taking an instance out of service (Decommissioning).
TLS and database security
These are database-side settings that belong to whoever provisions PostgreSQL; the deployment references them but cannot enforce them:
- TLS in transit. Require
hostsslon the server and put?sslmode=verify-fullin the DSN so the client verifies the server certificate. - pgaudit. Run pgaudit as the database-layer complement to the openEHR
audit and the ATNA trail (for example
pgaudit.log = 'ddl, role, connection'globally plus object-level audit on the PHI tables) and ship the audit log to an immutable store with long (roughly six-year) retention. - Encryption at rest. Encrypt at the volume or disk layer. Do not encrypt the stored clinical JSON with pgcrypto: it would break AQL’s ability to query inside the data.
Backup and point-in-time recovery
Enable WAL archiving and point-in-time recovery from day one (pgBackRest or a
managed PITR), because a CDR’s data is not reconstructible. Clinical and audit
tables are never UNLOGGED. Test your restore, not just your backup.
Dump each domain separately
A logical backup is where the three pseudonymisation domains are easiest to
re-join by accident. One pg_dump of the whole database produces a single file
holding the pseudonymised clinical record, the identities of its subjects and
the map between them, and whoever can read that file can re-identify every
record in it — which is the separation the schema split and the database roles
exist to maintain. Three domains, three dumps, into targets with different
access control.
How many dumps, by DSN layout. The number of dumps follows the domains, not
the databases: co-located, the four domains share one database and are dumped
--schema by --schema as below; relocated, each database is dumped for the
domains that live in it. What never changes is that the clinical record, the
identities and the map land in three different artefacts with three different
audiences. ferroehr config check prints the resolved layout, which is the
list a backup runbook is written from. The audit repository travels with the
clinical dump while it shares that database (--schema=audit below) and needs a
dump of its own once [storage.audit] names another one. On Kubernetes that
fourth dump is not yours to schedule: the chart renders a backup.audit
CronJob as soon as database.audit carries a Secret of its own, and drops
--schema=audit from the clinical job in the same render.
# The clinical domain
pg_dump --dbname="$CLINICAL_DSN" --format=custom --no-owner \
--schema=clinical --schema=ext --schema=audit \
--file=/backups/clinical/clinical-$(date -u +%Y%m%dT%H%M%SZ).dump
# The identities, into a different directory, owned by a different group
pg_dump --dbname="$PARTY_DSN" --format=custom --no-owner \
--schema=party \
--file=/backups/party/party-$(date -u +%Y%m%dT%H%M%SZ).dump
# The party-to-EHR map, into a third directory with the narrowest audience
pg_dump --dbname="$LINKAGE_DSN" --format=custom --no-owner \
--schema=linkage --extension=btree_gist \
--file=/backups/linkage/linkage-$(date -u +%Y%m%dT%H%M%SZ).dump
# The audit trail, ONLY when [storage.audit] names a database of its own —
# co-located it is in the clinical dump above, and dumping it twice is a second
# copy of who read what
pg_dump --dbname="$AUDIT_DSN" --format=custom --no-owner \
--schema=audit \
--file=/backups/audit/audit-$(date -u +%Y%m%dT%H%M%SZ).dump
Important
--extension=btree_giston the linkage dump is load-bearing. A--schemadump carries no extension — “pg_dump makes no attempt to dump any other database objects that the selected schema(s) might depend upon” — andlinkage.subject_ehr’s temporalUNIQUE … WITHOUT OVERLAPSis a GiST index over that extension’s operator classes. Without the flag the restore reports “data type uuid has no default operator class for access method gist”,pg_restoreignores the error by default, and the table comes back with its rows and without the key that admits one open mapping per party.
The third dump is a separate artefact for the same reason the first two are,
and it is the one that matters most. linkage.subject_ehr says which party is
the subject of which EHR: it is the additional information that turns a
pseudonymised record back into a person (GDPR Art. 4(5)), so a file carrying it
beside either side of that map rebuilds the join the split exists to withhold.
Folding it into the party dump would put the identities and the map in one
holder’s hands — the artefact this whole section is written to prevent.
All three examples ship. In Compose they are the opt-in backup profile:
docker compose --profile backup run --rm ferroehr-backup-clinical
docker compose --profile backup run --rm ferroehr-backup-party
docker compose --profile backup run --rm ferroehr-backup-linkage
Set FERROEHR_BACKUP_CLINICAL_DIR, FERROEHR_BACKUP_PARTY_DIR and
FERROEHR_BACKUP_LINKAGE_DIR to the three targets; they default to
./backups/clinical, ./backups/party and ./backups/linkage.
Run it as yourself — FERROEHR_BACKUP_USER="$(id -u):$(id -g)" — and the dump
lands owned by you. Left unset, the job runs as root inside the container and
keeps one capability, DAC_OVERRIDE, because that is what writing a directory
it does not own actually requires: the container drops every other capability,
and without this one uid 0 is just another user against your directory’s
permission bits. Everything else stays off — no privilege escalation, read-only
root filesystem.
Under Kubernetes the chart renders one CronJob per domain — see the chart’s
backup values.
Warning
A backup credential is not the application role. Each domain’s runtime role is revoked from the other domains, which is the pseudonymisation boundary doing its job — so a dump taken through one of them is silently partial rather than refused. Give each backup job its own role, read-only on its own domain and nothing else.
Two further properties are yours to arrange, because no configuration file can enforce them: the three targets carry different access control, and the credential each job uses reaches one domain. Give the demographic job the demographic DSN once you run two (Deploying); with a single credential you have separated the artefacts but not the authority to produce them.
Note
Point-in-time recovery stays instance-wide. All three domains live in one cluster, so a WAL archive covers them together and a recovery target restores them together. The separation this section is about is the logical dump.
Restoring
A restore is not finished when pg_restore exits. The grants are the
boundary — a database whose roles came back wrong is a database where the
clinical role can read the identities — so re-apply the role grants before the
server starts, then let the server check them:
createdb ferroehr_restored
pg_restore --dbname=ferroehr_restored --no-owner clinical-….dump
pg_restore --dbname=ferroehr_restored --no-owner demographic-….dump
pg_restore --dbname=ferroehr_restored --no-owner linkage-….dump
# then, before anything serves traffic:
ferroehr db verify
Restore the clinical dump first. It is the one carrying the ext schema, whose
helper functions the other domains’ relations depend on.
ferroehr db verify issues no DDL. It checks that the database carries exactly
this build’s migrations, all five sets, so a restore that skipped a domain
is refused by name, and that no runtime role can read across the domain
boundary, exiting non-zero when either is untrue. It is the same check the
server runs at boot, which is why a server pointed at a badly restored database
refuses to start rather than serving from it.
What it does not read is a constraint. pg_restore ignores a failed
statement unless you pass --exit-on-error, so a table can come back with its
rows and without a key while every check above stays green. Read the restore’s
own output, and confirm the map kept its temporal key:
psql -d ferroehr_restored -c \
"SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = 'linkage.subject_ehr'::regclass AND contype = 'u'"
# expect: uq_subject_ehr_party | UNIQUE (party_id, sys_period WITHOUT OVERLAPS)
Restoring only one domain is a supported outcome, not a mistake: a demographic
dump restored on its own gives a database with identities and no clinical
record, which is what a rehearsal of the identity domain’s recovery looks like.
Such a database is not one the server will serve from — ferroehr db verify
refuses it for the domains that are missing, which is the correct answer for a
rehearsal target.
Rotating the national-identifier key
Only relevant when [demographic.identifier_protection] is on. With it on, a
national identifier of a configured scheme never sits in the versioned body:
the value lives in party.national_identifier, sealed with AES-256-GCM under a
key derived per domain from your root key, with an HMAC-SHA-256 digest beside it
so an identifier can be looked up without decrypting anything.
The root key is load-bearing. Lose it and the sealed identifiers cannot be
read back by anything, including you. Hold it the way you hold the database
credentials: in your secret manager, injected as key_file, never in the TOML
you commit.
Rotation re-encrypts; it is not an edit. Both keys have to be present while it runs, because every row is opened under the old key and sealed under the new one, and the lookup digest changes with the key too — so the rotation rewrites the digest column and every stored reference keeps pointing at the same row. The rotation has to cover retired rows as well as live ones: every party version keeps rows of its own, and an older version opens its value from a retired row.
The procedure:
- Generate the new key:
openssl rand -hex 32, or any source of 32 random bytes rendered as hex. - Take a backup and verify it restores. A rotation rewrites every row in the table; a half-finished one you cannot roll back is the failure mode here.
- Stop writes to the demographic domain, or accept that identifiers written during the rotation are sealed under whichever key that write saw. A short maintenance window is simpler than reasoning about the alternative.
- Run the rotation under the migrator role, which is the only identity with rights on the whole table.
- Swap
key_fileto the new key and restart. Verify by resolving one known identifier and reading one party back. - Destroy the old key once step 5 is verified, not before.
The same key binds the admin dump archives. A dump carries the sealed rows as ciphertext, so restoring it needs the root key the rows were sealed under. An archive dumped before a rotation holds rows sealed under the old key, so step 6 also leaves the identifiers in those archives unreadable. Keep the old key for as long as you may restore one of them.
Warning
There is no automated rotation command yet. Until there is, the rotation is a scripted job you run against the demographic database with both keys available, and this page is the procedure it has to follow. Treat the absence of a command as a reason to rehearse the rotation on a copy first.
The subkeys are derived from the root key rather than stored, so rotating the root rotates all of them together. The pseudonymisation domain is part of that derivation: a subkey derived for the clinical domain opens nothing in the party one, so the per-schema backups above are separate artefacts under separate keys even where one root key is configured.
The container image and pod hardening
The published image is distroless and non-root (shell-less, with no package
manager) and is multi-architecture (amd64 and arm64) on GHCR. It is not a
static binary: the server links glibc and libgcc_s dynamically, which is why
the base image is the cc distroless variant rather than the smaller one, and
why the runtime needs no OpenSSL, no JVM and nothing else from a package
manager.
Under Kubernetes the pod runs the Kubernetes restricted profile: non-root
uid/gid 65532, read-only root filesystem with one writable /tmp, no
privilege escalation, an empty capability bounding set, RuntimeDefault
seccomp, and no service-account token (the workload never calls the Kubernetes
API). The chart’s own gates assert that per container on every render, and the
posture read back from a running container, plus the two things the chart
cannot do for you (applying the namespace enforcement label, and narrowing the
NetworkPolicy’s ingress sources: the shipped policy narrows the ports and,
until you set networkPolicy.ingressFrom, admits every source on them; set
networkPolicy.ingressAllowAll: false, and viewer.networkPolicy.ingressAllowAll
for the viewer, to have that state refused at render instead), are covered in
Installation → The workload: security context & admission
and Namespaces, network & policy →
Ingress.
The Compose path carries a comparable floor, and one guard keeps it:
scripts/checks/compose-hardening.sh runs over every committed compose
artifact and fails on a service definition without cap_drop: [ALL] and
no-new-privileges:true, on any privileged: true, on a seccomp=unconfined
or apparmor=unconfined override, on a mounted Docker daemon socket, and on a
published port that names no host address. The compose files go further than
the guard checks (capabilities are added back one at a time only where an
entrypoint provably needs them, file-descriptor limits are bounded, ports
default to the loopback interface, and most services run a read-only root
filesystem) but those extras are conventions, not enforced properties, so
check them when you adapt a file.
Two services in the quickstart file deliberately do not run read-only, and each
says why in place. The S3 gateway writes its volume store; the server cannot,
because Compose refuses an inline config in a read-only service and that
inline config is what makes the file standalone. A deployment that mounts its
configuration from a file instead can add read_only: true and a tmpfs at
/tmp.
What the host owes, and we cannot enforce
Four controls belong to whoever runs the daemon. They are stated here because a container hardening story that ignores them is misleading: the strongest pod security context in the world sits on top of these.
Keep the host kernel and Docker Engine current. A container is a kernel namespace, not a virtual machine: a kernel privilege-escalation bug is a container escape, and every runtime hardening above assumes the kernel enforcing it is patched. Track your distribution’s kernel updates and the Docker Engine release notes with the same urgency you would give a public-facing service.
Prefer rootless mode. Running the daemon as a non-root user means a container
escape lands as an unprivileged user rather than as root on the host
(https://docs.docker.com/engine/security/rootless/). The images here need no
privileged operation, no host networking, and no daemon socket, so nothing in this
deployment prevents rootless: the constraints are usually the host’s (cgroup v2,
newuidmap/newgidmap, and no privileged ports below 1024, which is why the
server binds 8080 rather than 80).
Set the daemon log level to info (the default) and keep it. Docker’s debug
level records request payloads and can put secret material into daemon logs, which
are typically world-readable to anyone with host log access and are shipped
wholesale to log aggregators.
Control who can pull and push your images. GHCR access is the deployment’s authorization boundary for what runs in production: whoever can push a tag your manifests reference can run their code with your database credentials. Restrict push rights, prefer digest pins over mutable tags for anything you deploy (the compose files pin the third-party images by digest for exactly this reason), and verify the published attestations before rollout; the verification command is in Installation → Kubernetes & Helm.
None of these four is something this project can assert on your behalf, which is why they are written as your checklist rather than as our claim.
Upgrades
-
Upgrade in place; the migrations are append-only. A released migration is never edited again, so a database created by an earlier release takes the new release’s migrations on top of the ones it already carries. The server records the checksum of every applied migration and refuses to start against a database whose recorded checksums differ from the files it carries (
migration 1 was previously applied but has been modified), which is why editing one would lock every existing installation out of its own database rather than revise its history. A correction ships as a new migration, and a CI guard fails any pull request that modifies, renames or deletes one the base branch already has. A rolling upgrade must still stay compatible with the previous schema for the window in which both versions run: additive changes first, destructive changes a release later.Releases up to and including 4.0.18 predate this policy and did edit migration files in place. A database created by one of those and never upgraded since is recreated rather than migrated.
-
Sweep for stale decomposition after a release that changes how content is decomposed. The version body is unaffected and every read serves it correctly; what is stale is the decomposed index over it, which a row-level query predicate depends on.
POST /ferroehr/rest/extensions/v1/admin/integrity/verifyreports each such version asstale_decompositionandPOST /ferroehr/rest/extensions/v1/admin/integrity/rebuild-nodes, run once unscoped, rewrites the rows from the stored document. The changelog names the affected object type at each release that needs it; 4.2.0 is one, for demographic parties. Both routes are on Admin & messaging APIs. -
Upgrading a 5.5.0 that sets
[server.tls] client_ca_file: setclient_auth = "required"beside it. Before 5.5.0 the CA alone meant a required client certificate; 5.5.0 read it under the newclient_authdefaultnoneand asked for no certificate, and from 5.6.0 such a table is refused at load (GHSA-52mf-qg39-4q4j). See[server.tls]. -
Bound the DDL yourself. Every pooled connection carries the
db.statement_timeout_msvalue (60 seconds by default), and the migration step runs on that pool, so a runaway statement is cut off, but there is nolock_timeout, so a migration that waits behind a long transaction waits as long as that transaction lasts. On a busy table useCREATE INDEX CONCURRENTLY, add constraintsNOT VALIDandVALIDATElater, and setlock_timeoutin the migration session (or on the migrator role) if you need the wait bounded. -
Pin the image. Deploy an immutable tag or, better, a
@sha256digest, neverlatest; roll back by re-pinning the prior digest, since the schema’s backward compatibility makes that safe. -
Stay available. Keep at least two replicas (or autoscaling) and a pod disruption budget so node drains and upgrades never fully interrupt the API. The default 30-second termination grace period covers the server’s short shutdown drain of the audit and event outboxes.
How long the version you pinned is supported
Plan the upgrade cadence around this:
- Only the newest release is supported. When a new release is published, every earlier release stops receiving fixes: there are no maintenance branches, no long-term-support line and no backports. If you are not on the newest release, upgrade; upgrading is free. Under the Cyber Resilience Act, Cadasto B.V. commits to keep publishing security fixes for at least five years after each release (Art 13(8)) and ships them only in the newest release (Art 13(10)), which every user of an earlier release may run under the same licence terms. Staying on an older release means running without the fix.
- A fix normally arrives as the next patch on the current minor, so taking it does not oblige you to take new behaviour, but that is the usual case, not a promise. Where a fix is only correct alongside a behavioural change, the release carrying it carries the change, and the changelog entry says so.
- A published release is never repaired in place. Release immutability means the assets and the tag of a published release cannot be modified at all, so the remedy for any defect is a new version.
- The Helm chart and the published crates follow their own version lines, each supported only in its newest published version, which is where its fixes ship. A chart-only fix ships as a new chart version between server releases.
The running server states the end month of the commitment. Cadasto B.V.
keeps publishing fixed releases for at least five years after each release
(CRA Art 13(8)), and the server reads its release date from its changelog
heading at build time. The end month, the date until which fixed releases
keep coming, does not make the running release supported: a release is
supported only while it is the newest. The end month is on the boot banner (Support line), in the boot log, in
ferroehr --version, under support in GET /management/info, and under
support in GET /management/status as
{release_date, support_ends, status}, where support_ends is YYYY-MM and
status is supported (the commitment is still running), ended or
unreleased. Once the end month has passed,
status reads ended and the server logs a warn line at boot and once a day
(CRA Art 13(19)). A build of a version whose changelog heading carries no date
is not a release: it reports unreleased and no end month.
The server does not contact anyone to look for newer releases. To hear about
them, subscribe to the release feed
(https://github.com/FerroHEALTH/FerroEHR/releases.atom) and poll the
published security advisories
(GET https://api.github.com/repos/FerroHEALTH/FerroEHR/security-advisories).
The consequence for change control: budget for taking every release, or budget for maintaining a fork. There is no third option, and the full policy (with the reasoning and what to do if you need something stronger) is SECURITY.md.
Observability
tracing is the single instrumentation API. From it, three signal families
fan out, and identified data never enters any of them: telemetry uses
only closed-set labels and opaque request/trace ids, so correlation to a
patient is possible only through the audit trail.
-
Logs go to stdout (JSON when not attached to a terminal, pretty on a TTY). Shipping and rotation are the platform’s job.
FERROEHR__TELEMETRY__FORMAT(auto/json/pretty) andFERROEHR__TELEMETRY__FILTER(orRUST_LOG, defaultinfo,hyper=warn,tower=warn,h2=warn) control them, and the level can be changed at runtime through theloggersendpoint below. Every request writes one request-log line: method, route template, status, latency and the server’s request id. On boot the server prints a one-time ASCII banner (version, maintainer, project URL, and spec pins) to stdout ahead of the logs; it is suppressed underFERROEHR__TELEMETRY__FORMAT=jsonso machine log consumers see only structured lines. -
Traces export to any OpenTelemetry collector (Tempo, Jaeger, and so on) over OTLP, but only when you configure
[telemetry.otlp]. Each request’s span is named by route template, never by a path containing ids. A client’straceparentis never read: every request starts a trace of the server’s own. -
Metrics come from one OpenTelemetry meter provider with up to two readers: a Prometheus reader behind
/management/prometheus, and (with[telemetry.otlp]andmetrics = true) a periodic OTLP reader. Every instrument reaches both by construction, so a family can never exist on the scrape surface and be missing from the push. The catalogue covers HTTP request duration (http.server.request.duration); authentication failures and authorization decisions (Cedar and remote-PDP); database pool state, acquire latency and transaction counts; AQL query counts, latency and plan- cache events; compositions committed (by openEHR audit change type); validation failures and version-signature faults; WebTemplate cache events; events published; the whole ATNA audit pipeline; Tokio runtime gauges; process start time and resident memory; and theferroehr_build_infoidentity.Instrument names carry no
_totalsuffix and no unit suffix: units are declared on the instrument and the Prometheus exporter derives_total/_seconds/_bytesitself. Read the exposition to learn the exact rendered names rather than assuming either spelling.
The telemetry environment variables:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__TELEMETRY__OTLP__ENDPOINT | unset (nothing exported) | OTLP collector endpoint |
FERROEHR__TELEMETRY__OTLP__TRACES | true | export the spans |
FERROEHR__TELEMETRY__OTLP__METRICS | true | push the metrics beside the Prometheus reader |
FERROEHR__TELEMETRY__OTLP__SAMPLE_RATIO | 1.0 | share of traces sampled (start at 0.1 in production) |
FERROEHR__TELEMETRY__ENVIRONMENT | unset | reported deployment environment |
FERROEHR__MANAGEMENT__FLAME_FILE | unset (layer not installed) | write folded span-timing samples to this file for offline rendering, diagnostic sessions only |
Neither reader is reachable until you say so: the Prometheus surface needs
management.enabled plus an access level on the prometheus endpoint (see
the management surface), and the OTLP reader needs
telemetry.otlp.endpoint. A server with neither still records every
instrument; nothing exports it.
Tip
A single-container dev stack (
grafana/otel-lgtm, bundling an OTLP collector, Prometheus, Tempo, Grafana, and Loki) ships as a Compose overlay, together with a provisioned Grafana dashboard (request rate/errors/duration, database pool, AQL latency, validation failures, audit health) and a starter alert pack; point the server at it with the OTLP variables above.
On Kubernetes, the same keys arrive through the chart’s config
passthrough, and the metrics half has a second switch that is easy to miss:
# values.yaml
config:
telemetry:
environment: production
otlp:
endpoint: http://otel-collector.observability:4317
sample_ratio: 0.1
metrics: true
management:
enabled: true
endpoints:
prometheus: admin_only # the scrape endpoint is off until you name it
metrics:
enabled: true # adds the prometheus.io/* pod annotations
serviceMonitor:
enabled: false # or true, with the Prometheus Operator CRDs installed
metrics.enabled only adds the scrape annotations; the endpoint itself is
opened by config.management.endpoints.prometheus. Both are needed for an
annotation-discovering Prometheus, and neither is needed if you push over OTLP
instead. To turn the export off, drop telemetry.otlp; nothing is
exported without it.
The default dashboard. The “FerroEHR — service overview” Grafana dashboard (request rate/errors/latency, AQL rate and phase latency, plan-cache hit ratio, database pool, Tokio runtime, audit throughput — every query written against the served metric names) reaches Grafana three ways:
- Compose: the observability overlay provisions it automatically; nothing to do.
- Kubernetes: set
metrics.grafanaDashboard.enabled: trueand the chart ships it as a ConfigMap labelledgrafana_dashboard: "1", which the Grafana Helm chart’s dashboard sidecar (kube-prometheus-stack includes it) discovers and imports. The sidecar watches its own release namespace by default — install FerroEHR there, or widen the sidecar’ssearchNamespace.metrics.grafanaDashboard.foldersets thegrafana_folderannotation for sidecars with folder routing configured. - Anywhere else: import
deploy/helm/ferroehr/files/dashboards/ferroehr-overview.jsonby hand (Grafana → Dashboards → Import).
Warning
With the chart’s default-deny egress policy on, add the collector to
networkPolicy.egress.rules(port 4317). An OTLP exporter that cannot reach its collector fails silently: no traces, no error.
The usage report
Apart from your own telemetry, the server sends one report of its own: the
usage report to FerroPULSE, the collector Cadasto B.V.
runs. It is on by default outside a CI run and goes out at each start and
once a day, with the version, the licence grant type and coarse latency
figures, and no patient data. ferroehr usage-report --print shows the exact
JSON, and usageReport.enabled=false (Helm) or
FERROEHR__USAGE_REPORT__ENABLED=false switches it off. With the chart’s egress policy on, it needs a rule for
report.ferropulse.eu on port 443; without one it logs a warning.
The admin and messaging APIs
Two operator-facing HTTP surfaces have a page of their own, because each route carries its own switch, authorization class and status-code contract:
- The admin API (
{base}/admin/…, off by default): physical deletion, the activity report, archiving to the cold tier, and whole-repository dump and load. - The messaging API (
/ferroehr/rest/extensions/v1/message/…, always mounted): EHR Extract export and import.
The full reference is Admin & messaging APIs.
Warning
Enabling the admin API puts irreversible, whole-repository operations on the wire:
DELETE {base}/admin/ehr/allwith no parameter empties the repository. Keep it off unless a workflow needs it, turn RBAC on, and gate the admin role tightly.
Health probes
The health endpoints are always served, on the main API port, without authentication. There is nothing to enable and nothing to remember: they are mounted outside the API’s authentication and overload-shedding layers, so an orchestrator can probe a server whose management surface, admin API, and every optional integration are switched off, and a saturated server still answers its own probes.
Choosing a health endpoint
| Endpoint | Contract | Use it for |
|---|---|---|
GET /health | the readiness status (200 or 503) with the state alone, {"state": …} | load balancers, anything that must never be auth-gated |
GET /health/liveness | constant 200 with {"state":"up"}, touches nothing | Kubernetes livenessProbe and startupProbe |
GET /health/readiness | 200 while ready, 503 while the process boots or drains or an indicator is down; the FerroHEALTH readiness document (state, phase and each indicator’s name and state), each check bounded to one second. Only db and migrations read down; every other indicator reads at worst degraded | Kubernetes readinessProbe, ops dashboards |
GET /ferroehr/rest/status | public status document: status, server_version, openehr_rest_api_version, timestamp and deployment.profile. The full document, which adds licence (the grant in force: state, use, licensee, not_after, configured_token), the whole deployment posture (the declared profile, the separations still open and the ones accepted by name) and support (release_date, support_ends, status), is GET /management/status | version/identity checks |
GET /management/* | ops introspection; see below | operators, off by default, enable deliberately |
Every management request is itself recorded in the audit trail as a
system-domain access under its own operation id (management_info,
management_env, management_loggers_set, …), so a runtime filter change or a
configuration read is never an unrecorded administrative act.
There is exactly one health surface: the /health family above, the one every
FerroHEALTH product serves, and the route the container’s ferroehr healthcheck subcommand asks is /health/readiness. /ferroehr/rest/status is
a different contract, and no health endpoint exists under the REST root.
Not every indicator blocks readiness, and the distinction is deliberate:
| Indicator | Checks | Blocks readiness |
|---|---|---|
db | a pooled connection answers | yes |
migrations | this build’s schema is present, re-tested on every probe | yes |
audit_sender | the audit posture: DEGRADED with the consequence stated when auditing is off (no access log, no EHDS logging component); UP with fail_mode, the local store and its retention in the detail, and a stated caution under fail_mode = "open" | no: reports DEGRADED, never 503 |
audit_delivery | each forwarder to an external Audit Record Repository (present when [audit.syslog] or [audit.fhir_feed] is on): the records waiting, those the repository refused, those delivered, and whether the last attempt reached it; DEGRADED when the repository is unreachable or a record was refused | no: records wait in their spool or the store, so delivery problems never take the instance out of rotation |
audit_chain | the latest scheduled verification of the local audit store’s hash chain (incremental from the last intact link, with a periodic full pass) (present when the local store is on and [audit.store] verify_interval_seconds is not 0): UP while intact or before the first run, DOWN naming the finding count and the first damaged position, DEGRADED when the verification could not run | no: a damaged chain reports DOWN in the body, never 503, because the trail keeps recording while the damage is investigated |
events | the event publisher’s broker delivery (present only when eventing is enabled) | no: reports DEGRADED, never 503, since the outbox buffers while the broker is down |
An instance whose event broker is unreachable therefore keeps taking traffic
and says so in the body; an instance that cannot reach its database, or whose
database lost the schema, leaves rotation. The detail strings are written to
carry no connection information (no DSN host, database name or role) because
this surface is unauthenticated by design.
Important
Liveness and readiness are deliberately different: liveness never touches a dependency, so a database outage takes the instance out of rotation (readiness
503) instead of getting the container killed and restarted in a loop. WirelivenessProbeandstartupProbeto/health/livenessandreadinessProbeto/health/readiness. The Helm chart does exactly this out of the box, and only the timings are tunable (probes.liveness,probes.readiness,probes.startup); there is deliberately no option to point the probes at the container’sferroehr healthchecksubcommand instead, because that subcommand probes the status document rather than a health endpoint, which would leave readiness never touching the database.
While the database restarts or is out of reach, requests answer 503 Service Unavailable with Retry-After: those that cannot get a connection, and those
whose open connection the restart or the network cut (SQLSTATE class 08,
57P01 admin shutdown, 57P02 crash shutdown, 57P03 cannot connect now,
or a reset socket). A transaction the cut interrupted is rolled back by
PostgreSQL. A request that waits more than a second for a connection makes
the server try a connection of its own: when that cannot reach the database,
the request answers 503 at once, and so does every request after it, until
a fresh attempt (at most every half second) connects again. A pool that is
only busy keeps its requests queued up to
db.acquire_timeout_secs (30 seconds by
default).
A statement the database cancels (SQLSTATE 57014, by
db.statement_timeout_ms or by an
operator’s pg_cancel_backend) also answers 503 with Retry-After: the
statement rolled back, so the request can be repeated. With the defaults the
request timeout (30 seconds) answers first, so the statement timeout (60
seconds) does not fire on a request. PostgreSQL measures its timeouts against
the database host’s wall clock, so a host whose clock steps forward, such as a
virtual machine resynchronised after a stall, cancels every running statement
at once; keep the database host’s clock synchronised by slewing, not stepping.
The management surface
The management surface is ops introspection only: build info, Prometheus,
the metric views, the effective configuration, and runtime log control. It is
off by default on the bare binary, and each endpoint is independently
opt-in with an access level (admin_only, private, or public). It can be
bound to its own internal port so it never appears on the public API listener.
Keeping it off costs you nothing operationally: the health probes above do not
depend on it.
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__MANAGEMENT__ENABLED | false | enable the management surface |
FERROEHR__MANAGEMENT__BASE_PATH | /management | base path for the surface |
FERROEHR__MANAGEMENT__PORT | unset (main listener) | serve management on its own port |
FERROEHR__MANAGEMENT__ENDPOINTS__<NAME> | off | the access level for ONE endpoint: off, private, admin_only or public. There is no global default beside it: an endpoint you do not name is not mounted and answers 404. |
The ops endpoints:
Every one of them ships off: nothing is mounted until you name the endpoint
and the level it should answer at. The right-hand column is the level to choose,
not a default you already have.
| Endpoint | Endpoint name to set | Purpose | Level to give it |
|---|---|---|---|
GET /management/info | info | product name and version, build SHA, build date, rustc, the active spec_profile, the openEHR specification versions that profile selects, the PostgreSQL target, the audit posture (enabled, fail_mode, local_store, retention_days), the manufacturer of the release (name, postal_address, email, website), and the support period (release_date, support_ends, status) | admin_only |
GET /management/prometheus | prometheus | Prometheus text exposition | admin_only, or public only when the port is not reachable outside the cluster; a public endpoint is served OUTSIDE authentication |
GET /management/metrics | metrics | JSON list of the registered metric names | admin_only |
GET /management/metrics/{name} | metrics | the current value(s) of one metric; 404 for a name that is not registered | admin_only |
GET /management/env | env | effective configuration, with secrets redacted | admin_only |
GET/POST/DELETE /management/loggers | loggers | read and change the log level at runtime | admin_only |
GET /management/flamegraph | flamegraph | on-demand CPU flamegraph of the running server | admin_only |
Paths above show the default management.base_path; change it and every path
moves with it. The metrics name covers both metric routes, and the
prometheus, metrics and loggers routes additionally need their backing
machinery present; without it they are simply not mounted, which is the same
404 as leaving them off.
Warning
/management/envand/management/loggersexpose and change server internals; keep themadmin_only, and prefer binding the surface to an internal-only port.
Profiling: the on-demand CPU flamegraph
When the server is measurably slow, the metrics tell you how slow;
/management/flamegraph tells you where the time goes. The endpoint samples the
whole process with an in-process sampling profiler (the
pprof crate) for a bounded window and
answers with a rendered flamegraph SVG; open it in a browser and read the wide
frames.
# sample 10 s at 99 Hz (the defaults) and open the result
curl -u admin:… -o flamegraph.svg \
"http://cdr.internal:9100/management/flamegraph?seconds=10&frequency=99"
seconds(default 10) andfrequency(default 99 Hz) are capped bymanagement.profiling.max_secondsandmax_frequency(30 and 999 out of the box); a request beyond a cap is refused with400, never silently clamped.- One sample window at a time: a second request while one runs answers
409; retry when the window completes. - Sampling is low-overhead but not free; profile under the real load you are
diagnosing, and keep the endpoint
admin_onlyon an internal port like the rest of the surface. - Best results come from the container images and release builds, which keep
line tables (
debug = "line-tables-only") so frames resolve tofile:line.
With the surface enabled, the viewer grows an Operations screen over
it (dependency health, build provenance, the metric registry, and runtime log
control) which appears only while the CDR serves /management/info. See
Viewer → Server.
The manufacturer and the deployment report
Cadasto B.V. is the manufacturer of each tagged release. The running system names it, with its postal address and single point of contact, on every surface an operator or a user reads:
| Surface | What it shows |
|---|---|
| startup banner | Manufactured by Cadasto B.V. · https://www.cadasto.com/contact/, then the postal address and info@cadasto.com |
ferroehr --version | the version (a release build prints the release, any other build appends its commit as +dev.<commit>, as server_version on GET /ferroehr/rest/status does), then Manufactured by Cadasto B.V., Comeniusstraat 2d, 1817 MS Alkmaar, The Netherlands, info@cadasto.com and the website |
GET /management/info | a manufacturer object with name, postal_address, email and website |
OPTIONS on the API base path | vendor defaults to Cadasto B.V. ([rest.identity]) |
| viewer | the footer of every screen, and a Manufacturer card on /system |
| container images | the eu.ferroehr.image.manufacturer label |
ferroehr report writes one JSON file naming what a deployment runs: the
version, commit and build date, the enabled features, the specification pins
and openehr-* crate versions, the schema migration level, the deployment
posture, the licence summary, the manufacturer and the effective
configuration with every secret redacted. Attach it to a complaint or a
serious-incident report; see
ferroehr report for what it
holds and what it leaves out.
Next
- Admin & messaging APIs — the operator-facing HTTP surfaces, route by route.
- Usage report — what the daily report to FerroPULSE carries, and how to switch it off.
- Configuration reference — every setting, with the server, database and telemetry keys on their own page.
- The API reference at
/ferroehr/rest/swagger-uion your own deployment, or https://sandbox.ferroehr.eu/ferroehr/rest/swagger-ui on the hosted sandbox — the document the server itself generates.
Admin & messaging APIs
Two groups of routes sit beside the clinical API for operators rather than for clinical clients: the admin API, which physically deletes, reports on, and moves repository content, and the messaging API, which exports and imports whole records as openEHR EHR Extracts. This page is the operator’s reference for both: every path, what gates it, what it accepts, and every status code it answers.
- Where these routes live, and what gates them
- Physical deletion
- The activity report
- Archiving
- Legal marks: restriction, objection and retention
- Reading a party’s protected identifiers
- Storage integrity
- Dump and load
- EHR Extract export and import
- Other routes under
{base}/admin
Where these routes live, and what gates them
The routes ITS-REST defines (the two EHR deletes) sit under the openEHR base
path, {base}: with the default rest.base_path it reads
/ferroehr/rest/openehr/v1. Every other route on this page is FerroEHR’s own
extension and sits beside it under {ext}, which reads
/ferroehr/rest/extensions/v1; so {ext}/admin/dump is
/ferroehr/rest/extensions/v1/admin/dump. Nothing ITS-REST does not define is
served under the openEHR base path.
The two groups are gated differently, and the difference matters operationally:
| Group | Switch | Authorization class | While switched off |
|---|---|---|---|
{base}/admin/… and {ext}/admin/… | admin.enabled (FERROEHR__ADMIN__ENABLED), default false | admin: the configured authz.rbac.admin_role (ADMIN by default) | every route answers 405 Method Not Allowed with an empty Allow header: the resource exists but currently serves no method |
{ext}/message/… | none (always mounted) | ordinary clinical, exactly like the composition API | n/a |
Two consequences worth planning around:
- The messaging routes are not admin-gated. They read and write the same
clinical content the composition API does, so they carry the same ordinary
authentication and the same coarse clinical class. A principal holding the
configured read-only role (
authz.rbac.readonly_role,READONLYby default) is refused 403 on every messaging import, before the body is read; the exports stay available to it. - RBAC has to be on for the admin role to mean anything. With
authz.rbac.enabled = false(the posture the Compose quickstart ships) authentication is the only gate, so any authenticated caller reaches every enabled admin route. Turn RBAC on before enabling the admin API anywhere that is not a laptop.
While the admin API is enabled, the server also advertises /admin in the
OPTIONS conformance manifest it serves at the API base-path root; with the
API off, that entry is absent.
Warning
DELETE {base}/admin/ehr/allwith no parameter empties the repository. There is no confirmation step and no undo. Keepadmin.enabledoff unless a workflow needs it, and gate the admin role tightly.
Physical deletion
Normal openEHR deletes are logical: the version history is retained. The admin API is the exception: physical, irreversible removal, for legal erasure requests and test-data cleanup.
| Route | Success | Refusals |
|---|---|---|
DELETE {base}/admin/ehr/{ehr_id} | 204: the EHR and every resource it owns (compositions, EHR_STATUS, item tags, contributions, and all their historical versions) are physically gone | 400 malformed id (rejected before any deletion), 404 no EHR with that id |
DELETE {base}/admin/ehr/all | 204: bulk delete | 400 any id in the list is not a well-formed UUID; the whole request is refused before anything is deleted |
DELETE {ext}/admin/party/{party_id} | 204: the demographic party, every PARTY_RELATIONSHIP naming it as source or target, and all their versions, contributions and commit audits are physically gone | 400 malformed id, 404 no party with that id (a relationship id is refused the same way) |
DELETE {ext}/admin/template/{template_id} | 204 | 404 unknown template, 409 a committed version still references it |
DELETE {ext}/admin/query/{qualified_query_name}/{version} | 204 | 404 unknown name, or a known name with no such version |
GET {ext}/admin/config | 200: the effective configuration as a redacted JSON tree | — |
Every route additionally answers 401 unauthenticated, 403 for an authenticated caller outside the admin class, and 405 while the group is switched off.
What an EHR delete reaches, in the order it runs: one clinical transaction removes the EHR row and, through the foreign-key graph, its versions over both storage tiers, their nodes, attestations, contributions, commit audits, item tags, folder memberships, the restriction and retention marks, the multimedia blob references and every pending change event of the EHR; an erasure tombstone is appended to the change-event stream in the same transaction, so a consumer that derived anything from the EHR is told to delete it (GDPR Art. 19). The cross-reference row that names the EHR’s subject is then erased in the linkage domain, and the externalized multimedia blobs no surviving version still references are removed from the object store.
A party delete announces itself the same way. DELETE {ext}/admin/party/{party_id} deletes a party physically. The released Admin API defines deletion for EHRs only, so the route
is our own extension, and the served OpenAPI document says so. It writes one
erasure tombstone on the party domain’s own outbox before its transaction
commits, naming the party and every PARTY_RELATIONSHIP erased with it. A
consumer of the party stream has no other way to learn the identity is gone.
The access log records it the way it records an EHR delete: action D under
the operation admin_party_delete, on the demographic resource, with the
party id as the resource.
What it keeps: the audit records naming the ehr_id or the party. Art. 17(3)(b)
withholds erasure where processing is necessary for compliance with a legal
obligation, and the national access-logging periods are that obligation. Data
outside the running database (backups, replicas, exports) is reached by your
own rotation, not by this call.
Details that decide behaviour:
- The bulk delete’s parameter is optional, and its absence means
everything.
DELETE {base}/admin/ehr/allwith noehr_iddeletes every EHR on the server. To delete a subset, pass?ehr_id=<uuid>, repeatable (?ehr_id=a&ehr_id=b) or comma-separated (?ehr_id=a,b); blank entries are dropped. An id that names no EHR deletes nothing and is not an error, so the bulk route has no404. - Deleting a template never orphans clinical data. The 409 is a deliberate guard: while any stored composition was committed against the template, the delete is refused and the message says how many committed versions still hold the reference. Delete those compositions first.
- A stored-query delete removes exactly one
(name, version)row. The query’s other versions survive. - The config view is redacted structurally, not by key name. Passwords and
password hashes, signing-key secrets and S3 secret keys render as
***; every URL that can carry credentials (database, AMQP, the FHIR audit feed, the terminology server and its token endpoint, the remote policy server, the OTLP collector, the object store, the usage-report endpoint) keeps its host and path and masks the embedded credentials (postgres://***@host:5432/db). The OIDC issuer and the SMART URLs are refused at boot when they carry credentials, so they render as set. Non-secret identifiers (usernames, roles, an OIDC issuer) stay visible. Redaction is a property of the configuration’s secret types, so no secret value can reach this response.
Note
The template delete, the stored-query delete and the config view are FerroEHR extensions: the openEHR admin API defines only the two EHR deletes. They share the same switch and the same authorization as those deletes.
The activity report
Four read-only counters over the repository’s change history, behind the same switch and role as the deletes.
Every route takes two query parameters:
| Parameter | Required | Meaning |
|---|---|---|
a_service | yes | the service whose versioned content to report on: one of Admin, Definitions, Ehr, Ehr_index, Demographic, Message, Query, System_log, matched case-insensitively |
time_interval | no | <lower>/<upper>, two ISO 8601 date-times matched inclusively against each commit time |
Either bound may be left empty for an open interval
(?time_interval=2026-01-01T00:00:00Z/); an absent parameter reports over all
time. A service that holds no versioned content reports an empty list or 0
rather than failing.
| Route | 200 body |
|---|---|
GET {ext}/admin/report/contribution | the matching CONTRIBUTION ids as a JSON array, ordered by commit time then id |
GET {ext}/admin/report/contribution/count | how many there are, as a bare JSON number |
GET {ext}/admin/report/versioned_composition/count | how many distinct COMPOSITION version containers had a version committed in the interval |
GET {ext}/admin/report/composition_version/count | how many individual COMPOSITION versions were committed in the interval |
400 covers an absent a_service, one that names no known service, a
time_interval that is not <lower>/<upper>, a bound that is not a valid ISO
8601 date-time, and an interval bounded on both sides whose lower bound is
after its upper bound: that is not an interval, and answering it with the
empty result it would select would hand back a truthful-looking count for a
window nobody asked for. Equal bounds are a legitimate single-instant interval
and are reported normally.
Archiving
Four routes that move a selected set of records to the server’s cold storage tier and back, behind the same switch and role. Archiving is not a delete: the archived records stay fully readable through the normal API, with their whole revision history intact.
| Route | Body | Success |
|---|---|---|
POST {ext}/admin/archive/ehrs | {"ehr_ids": ["…"]} | 204: every named EHR and all its versioned content is archived |
POST {ext}/admin/archive/parties | {"party_ids": ["…"]} | 204: every named demographic party is archived |
POST {ext}/admin/archive/ehrs/restore | {"ehr_ids": ["…"]} | 204: every named EHR’s archived content is back in the primary tier |
POST {ext}/admin/archive/parties/restore | {"party_ids": ["…"]} | 204: every named party’s archived content is back in the primary tier |
All four are all-or-nothing and idempotent: a body of the wrong shape or a
malformed id is 400 and an id that names nothing is 404, in both cases
before anything is moved; re-archiving an already-archived record, or restoring
one that is not archived, changes nothing. An empty list succeeds and moves
nothing. A body sent without Content-Type: application/json is 415. A
party that is currently archived is still found by its restore call (the
existence check spans both tiers) so an archived party is never reported
missing.
A party’s PARTY_RELATIONSHIPs are not carried along: each is an
independently addressable versioned object, archived in its own right.
What “cold storage tier” means here: the archived rows are physically moved out
of the primary tables into a separate schema in the same database, so the
tables and indexes that serve everyday traffic shrink by exactly what was
archived: no extra tablespace, volume, or external service to operate. Reads
of unarchived records never touch the cold tier; a read that addresses an
archived record is served from it. Writing to an archived record brings it
back to the primary tier first, so a versioned object is never split across
tiers; a physical delete clears both tiers; and {ext}/admin/dump still
exports archived content.
Two consequences to plan for:
- AQL queries see the primary tier only, so an archived record stops appearing in query results; that is exactly what shedding the query tables’ rows and indexes buys you. Everything addressed by id (an EHR, a composition, a folder, a party, a version, a revision history) keeps working as before. Restoring puts the record back in query results.
- There are three ways back, and only one of them is deliberate. The
…/restoreroutes above reverse a whole set on request; a write to an archived record thaws just that record as a side effect, whichever route the write came in on; and a physical delete removes it from both tiers. Query visibility is the effect to plan around, and the restore routes are how you get it back.
Legal marks: restriction, objection and retention
Eleven routes behind the same switch and role, carrying the four marks the law asks a repository to hold beside its clinical content. None of them deletes anything.
| Route | Body or parameter | Success |
|---|---|---|
POST {ext}/admin/restriction | {"ehr_id": "…", "vo_id": "…"?, "ground": "…", "note": "…"?} | 204: the restriction is recorded and in force |
GET {ext}/admin/restriction | ?ehr_id=… | 200: the register, newest request first, lifts included |
POST {ext}/admin/restriction/lift | {"ehr_id": "…", "vo_id": "…"?} | 204: every in-force restriction at that grain is lifted |
POST {ext}/admin/research-objection | {"ehr_id": "…", "objected": true, "ground": "…"?} | 204: the objection, its override, or its withdrawal is recorded |
PUT {ext}/admin/retention/policy | {"kind": "…", "jurisdiction": "…", "category": "…"?, "origin": "…"?, "period": "…", "anchor": "…", "source": "…"} | 204: the period is declared, keyed on one EHDS priority category when category is set and on one registered origin ([origins]) when origin is set |
GET {ext}/admin/retention/policy | — | 200: the whole retention register, each period with its category and origin (null for every object of the kind, or every origin) |
PUT {ext}/admin/retention/anchor | {"ehr_id": "…", "jurisdiction": "…", "anchored_at": "…"?, "hold_at": "…"?, "hold_ground": "…"?} | 204: the EHR’s anchor and any whole-record hold |
POST {ext}/admin/retention/hold | {"vo_id": "…", "held": true} | 204: the per-object exemption is placed or released |
GET {ext}/admin/retention/due | ?limit=100 | 200: what has run out, oldest first, per period and category, with the due and held object counts |
POST {ext}/admin/access-restriction | {"ehr_id": "…", "vo_id": "…"?, "note": "…"?} | 204: the EHDS Art. 8 restriction of access by health professionals is recorded and in force |
GET {ext}/admin/access-restriction | ?ehr_id=… | 200: the access-restriction register, newest request first, lifts included |
POST {ext}/admin/access-restriction/lift | {"ehr_id": "…", "vo_id": "…"?} | 204: every in-force access restriction at that grain is lifted |
A body of the wrong shape or a malformed id is 400, an id that names nothing
is 404, and a body without Content-Type: application/json is 415, in
each case before anything is recorded. Setting or lifting a mark is an access
record of its own.
A category is named as [audit.categories] names it (a priority category by
its code, Patient-Summaries, Electronic-Prescriptions,
Electronic-Dispensations, Medical-Imaging, Laboratory-Reports or
Discharge-Reports, or as <system>|<code>; a declared national category as
its <system>|<code>; or none; an unknown one is 400) and stored as its
<system>|<code> token, which serves EHDS
Annex II 3.4: retention periods that take the categories of the data into
account. The server mirrors the map into the register at every boot. A period
keyed on a category lists the objects the map places in it; an object the map
cannot classify is listed under the longest period configured for its kind,
and a category row with no object is left out of due. A period with no
category lists every object of its kind, as before.
Restricting a record changes what every other route answers: reads of a restricted object become 403, it leaves AQL results at every scope, exports skip it and the event stream withholds it, and writes to it are refused. On a write the restriction is the LAST gate: content validation answers 422 and the preconditions that run before the commit transaction, a non-modifiable EHR or a second directory, answer 409, while the mark is read inside the transaction. A malformed write to a restricted object therefore answers 422 rather than 403. Both refuse the write and neither changes what is stored, so read the register itself to learn whether a restriction is in force. What each mark means, which provision it serves and what the deploying organisation still has to decide are on Retention, restriction and objection.
An access restriction is not a restriction of processing: it limits what a health professional reaches and nothing else, and it is invisible to them. What it changes is on restriction of access by health professionals.
Reading a party’s protected identifiers
With national-identifier protection
on, a party’s versioned body carries a reference where each protected
identifier stood, and every ordinary read serves that reference.
GET {ext}/admin/party/identifiers is the one route that puts the values back
for a reader entitled to see them. No openEHR specification governs it; it is
our own extension.
curl -H "Authorization: Bearer $TOKEN" \
"$BASE/admin/party/identifiers?version_uid=8d7c…::ferroehr.local::2"
{
"version_uid": "8d7c…::ferroehr.local::2",
"expanded_schemes": ["nl-bsn"],
"version": { "_type": "ORIGINAL_VERSION", "data": { "_type": "PERSON", … } }
}
- Two gates. The route is in the admin group, so it needs
admin.enabledand the admin role, and it also needs the roleauthz.rbac.identifier_reader_rolenames. Holding the admin role alone reveals no national identifier, and with the key unset nobody may call the route (403). - Any version.
version_uidnames one party version; a historical version shows the value it committed. - A view, never a write. The body is the stored
ORIGINAL_VERSIONwith the values put back, so it no longer matches the signed version. Nothing is written back. - Every read is recorded. Each scheme expanded is one access record in the
demographicdomain, naming the reader, the party and the scheme (party:<id>#national-identifier:<scheme>), never the value. A version that holds no protected identifier still leaves one record. Underaudit.fail_mode = "closed"a record the trail cannot take withholds the values with 503. - Refusals. 400 for a missing or malformed
version_uid, or a deployment that configures no identifier protection; 404 when no party holds that version.
Storage integrity
Two routes that check the stored data against itself and repair what they find, behind the same switch and role. FerroEHR stores every version’s content twice: once as the materialized document a point read serves, and once as the decomposed rows the AQL engine queries. A commit writes both in the same transaction, so they always agree; anything that changes one of them behind the server’s back breaks that agreement.
| Route | 200 body |
|---|---|
POST {ext}/admin/integrity/verify | the sweep report |
POST {ext}/admin/integrity/rebuild-nodes | the rebuild report |
The sweep covers both pseudonymisation domains, clinical and demographic, in one pass. Every finding names the domain it came from, so a report can never describe half the store while looking like it described all of it. It re-derives every stored version from its decomposed rows and compares the result with the stored document, then decomposes that document again and compares the rows it would write, which is what catches rows an older release left in a shape this one no longer produces. It reads the archived tier as well, takes no lock, and runs outside the request path of any clinical call, so it is safe to run on a live server. It is also a full scan of what it covers, so schedule it rather than calling it per request.
Four optional query parameters narrow the scan, and they compose. Both routes take the same set:
| Parameter | Effect |
|---|---|
ehr_id | cover only versions belonging to that EHR |
vo_id | cover only versions of that versioned object |
sys_version | cover only that version of it; the ordinal is per-object, so it needs vo_id and is a 400 on its own |
committed_since | cover only versions whose validity begins at or after that RFC 3339 instant |
Use them. Verifying one record after a support incident, or everything written since a known point, costs a fraction of a full repository scan.
Verifying a whole large repository
By default the route computes the whole report before it answers, so it has to
finish inside the server’s 30-second request timeout. Send
Accept: application/x-ndjson and it streams the same sweep instead, writing
each finding as it is made. Nothing bounds that response, so this is how you
verify a repository too large to scope:
curl -sN -X POST \
-H 'Accept: application/x-ndjson' \
"$BASE/admin/integrity/verify"
The body is one JSON object per line, each carrying a type:
type | When | Carries |
|---|---|---|
mismatch | as each disagreement is found | the same five fields the report’s mismatches entries carry, domain included |
progress | once per page of versions read | the counts so far |
summary | once, at the end | the final counts and elapsed_ms |
error | instead of summary, if the sweep failed part-way | a short message; the detail is in the server log |
Two consequences are worth planning around. The status code is sent before the
work is done, so a sweep that fails half-way still answered 200: read to the
end and check that the last line is a summary, not an error. And the stream
carries no reporting cap, so every mismatch reaches you rather than the first
thousand.
The stream has to be asked for by name. A request sending */*, or no Accept
at all, gets the aggregated document below, unchanged.
{
"versions_checked": 128,
"versions_with_body": 126,
"versions_without_body": 2,
"mismatch_count": 1,
"mismatches": [
{
"domain": "clinical",
"vo_id": "8849182c-82ad-4088-a07f-48ead4180515",
"sys_version": 2,
"kind": "COMPOSITION",
"defect": "content_differs"
}
],
"truncated": false,
"elapsed_ms": 431
}
domain is clinical or demographic, naming the schema the damaged version
lives in. It is also what the repair below uses to reach it.
defect is one of five values:
content_differs: both copies exist and hold different content.nodes_missing: the version has a stored document but no decomposed rows.nodes_unreadable: the decomposed rows exist but no longer form one tree.unexpected_nodes: the version is a logical delete, which stores no document, yet decomposed rows exist for it.stale_decomposition: the rows hold exactly the stored document, but not in the shape this version of the server decomposes it into. An older release wrote them. The content is intact and every read serves it correctly; what is stale is the index over it, so a query that depends on the current shape does not reach this version until the repair below rewrites its rows.
mismatch_count is the full count. mismatches is capped at 1000 entries and
truncated says whether the cap was reached; every mismatch is logged at
warn level with its identifiers whatever the cap does, so a truncated report
never loses a finding. The log line and the response carry identifiers only,
never content.
A finding is not a request failure: the sweep ran and is telling you what
it saw, so the status stays 200 and the report is the body. Check
mismatch_count, not the status code.
Note
This is the companion check to digest signing. A stored digest covers the document a point read serves; it says nothing about the decomposed rows, which no read-path check recomputes. Together the two cover both copies.
Repairing what the sweep found
The sweep is the diagnosis. POST {ext}/admin/integrity/rebuild-nodes is the
repair: it re-derives the decomposed rows of every damaged version from that
version’s stored document, one transaction per version.
curl -s -X POST "$BASE/admin/integrity/rebuild-nodes?ehr_id=$EHR_ID"
It runs the same sweep first and writes only the versions that sweep reports damaged, so a run over healthy data writes nothing at all. The scope parameters are the same four, so a repair can be as narrow as one version:
curl -s -X POST \
"$BASE/admin/integrity/rebuild-nodes?vo_id=$VO_ID&sys_version=2"
Which copy wins is not a choice the route makes. The stored document is the version’s canonical serialized form, the bytes a point read serves and the bytes a digest was taken over; the decomposed rows are an index derived from it. So the repair only ever runs in that direction, and a version whose document is the damaged copy is refused rather than having the damage copied into the index.
Each version is repaired inside one transaction: the document is read under a row lock, decomposed, the old rows deleted, the new set inserted, and the version re-derived from what was just written and compared with the document before the commit. Anything that fails rolls that transaction back, so the version’s rows are left exactly as they were. One refusal never stops the run that found it.
An archived object is thawed for the repair and re-archived in the same transaction, marker and all, because a write to a versioned object always happens in the primary tier.
A logically deleted version stores no document, so it rebuilds to no rows,
which is the repair for unexpected_nodes.
The same route is the upgrade step after a release that changes how content is
decomposed. Rows written by the older release hold the right content, so
nothing is damaged, but they are the wrong shape for the new one and the sweep
reports them stale_decomposition. Rebuilding writes them again from the
stored document, which is all the upgrade is. Run it unscoped once and the next
sweep is clean:
curl -s -X POST "$BASE/admin/integrity/rebuild-nodes"
The changelog names the affected object type at each such release.
{
"versions_checked": 128,
"versions_damaged": 2,
"versions_rebuilt": 1,
"versions_refused": 1,
"records": [
{
"domain": "clinical",
"vo_id": "8849182c-82ad-4088-a07f-48ead4180515",
"sys_version": 2,
"kind": "COMPOSITION",
"defect": "content_differs",
"outcome": "rebuilt",
"node_rows": 41
},
{
"domain": "demographic",
"vo_id": "1f0b7d64-6b2a-4a1f-9f0e-6b1d2a3c4d5e",
"sys_version": 1,
"kind": "PERSON",
"defect": "content_differs",
"outcome": "refused",
"reason": "the stored body does not decompose: ..."
}
],
"truncated": false,
"elapsed_ms": 812
}
defect is the sweep verdict that selected the version, from the five values
above. outcome is rebuilt, carrying the node_rows the version now has, or
refused, carrying the reason. records is capped at 1000 entries with
truncated saying whether the cap was reached; every record is logged at
info level with its identifiers whatever the cap does.
A refusal is not a request failure, so the status stays 200. Check
versions_refused: anything above zero means a stored document is itself
damaged, and that is a restore-from-backup question rather than a rebuild one.
Unlike the sweep, this route writes, so a server in read-only mode refuses it.
Dump and load
Two routes that move the whole repository to and from an archive on the server’s file system, behind the same switch and role. Both answer 200 with a JSON array of per-entity failure reports; an empty array means everything succeeded.
POST {ext}/admin/dump
Writes an archive of every EHR and of every standalone demographic
container: the parties and party relationships that live outside any EHR.
The body is {"file_sys_loc": "…"} plus the optional export settings:
| Field | Values | Default |
|---|---|---|
logical_format | openehr_canonical_json or openehr_canonical_xml | canonical JSON |
compression_format | zip or 7z (omit for loose files) | uncompressed |
segment_split_size | segment size in kb (a positive integer) | 1024 |
logical_format chooses how the clinical content is serialized, not how
the archive is packaged:
openehr_canonical_json(the default) keeps each version’s content inline in the segment files, exactly as this server stores it.openehr_canonical_xmlwrites each version to its ownversions/<version_uid>.xmlentry instead: a completeORIGINAL_VERSIONdocument under the openEHR-published<version>root, ready to hand to any tool that reads canonical openEHR XML.
The archive is a directory holding a manifest.json, one or more
segment-NNNN.json files, and a blobs/ subdirectory for any externalized
multimedia. When the repository holds standalone demographic containers, the
archive additionally carries a demographic-commons.json (their shared
audits and contributions) and one or more demographic-NNNN.json segments,
one record per party or relationship, in the same version-record shape the
EHR segments use. With compression_format set, those same entries are
packed into a single archive.zip or archive.7z inside the location
instead. The
archive’s own bookkeeping (the manifest and the segment skeleton) stays JSON
in both logical formats, because openEHR publishes no XML document form for
it.
POST {ext}/admin/load
Populates the repository from an archive. It takes the location and nothing
else: the container (loose files, a single archive.zip, or a single
archive.7z) is detected from what the location holds, and the logical format
is read from the archive’s own manifest, so a load never has to be told how
the dump was written. Archives written before the demographic wave existed
simply carry no demographic entries and load unchanged.
The repository being loaded into need not be empty. An EHR whose id is
already present is reported and skipped rather than failing the load, so the
response array names each one; and a standalone demographic container that
already exists is reported the same way, under its own kind (PERSON,
ORGANISATION, GROUP, AGENT, ROLE, or PARTY_RELATIONSHIP) as the
entity_type:
[ { "entity_type": "EHR",
"entity_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
"dump_status": false,
"error": "an EHR with this id already exists" } ]
Both directions are lossless: a dump and a load reproduce every record, whichever format and container you choose.
With national-identifier protection on, a
party’s demographic record also carries its sealed identifier rows, live and
retired, as base64 ciphertext, nonce and lookup digest. The archive never holds
a protected value in plaintext. The rows open again only under the same
[demographic.identifier_protection] root key, so restore a dump into a
deployment configured with the key it was taken under. A load reports a
demographic container in the response array and restores none of it when:
- one of its sealed rows holds a value that is already live for another party in this repository, or names a scheme this repository has not registered;
- with protection on here, one of its versions carries a protected scheme’s value in plaintext, or references a sealed row the container does not carry.
Refusals
| Status | When |
|---|---|
| 400 | a missing or blank file_sys_loc; a format value outside the lists above; a non-positive segment_split_size; an encoding field (the openEHR service model declares that enumeration with no members, so no value a client could send names one); on load, an archive carrying externalized multimedia this server has no store for |
| 415 | the request Content-Type is not application/json |
| 500 | a location that holds no archive, and one holding an archive that is corrupt (a mangled or truncated container, manifest, or segment) are the same fact and answer the same way: the service model’s single file_not_writable error for these operations. Nothing is loaded either way. On dump, the same status covers a location, segment, payload entry or manifest that could not be created or written |
A single unreadable versions/*.xml entry is not in that family: it belongs
to one EHR, so that EHR is reported in the response array and skipped whole
while the rest of the archive loads.
Note
The activity report, the archive routes and the dump/load pair are FerroEHR extensions: the openEHR service model defines these operations, but the released REST API surfaces no endpoint for them, so their URLs are our own. The two
…/restoreroutes go one step further: the service model declares the archive calls and no un-archive counterpart, so both the operation and its URL are ours. They gate no openEHR conformance claim; see Conformance.
EHR Extract export and import
Four routes under {ext}/message that move whole records between systems.
Unlike the admin extensions above, these are not admin-gated; see
the gate table.
GET {ext}/message/export/{ehr_id}: export one whole EHR. 200 with a JSON array holding oneEXTRACTthat carries every versioned object of the EHR, latest versions only. 400 ifehr_idis not a well-formed identifier, which is refused before any lookup; 404 if the EHR does not exist; 406 ifAcceptcannot be satisfied (the extract list is JSON only).POST {ext}/message/exportwith anEXTRACT_SPECbody — export by specification. 200 with oneEXTRACTper manifest entity, in manifest order. The manifest must name at least one entity, and each entity must name its record byehr_idorsubject_id, otherwise 400; an identifier that names nothing is 404.extract_typemust be one of the extract-content-type codes the openEHR Reference Model names (openehr-ehr,openehr-demographic,openehr-synchronisation,openehr-generic,generic-emr) or the catch-allother; anything else is 400, as is a selection this service does not support (search criteria, an unsupported commit-time interval). This route is classified as a read: it selects over held versions and commits nothing, exactly like the ad-hoc AQLPOST.POST {ext}/message/importwith anEXTRACTbody — clone a whole EHR. Add?ehr_id=<uuid>to fix the identifier the clone lands under; leave it off and the source identifier the extract carries is re-used. 201 with{"uid": "<ehr_id>"}, so a caller that supplied no id still learns what was created. The extract must carry anEHR_STATUS(400), and the target must not already exist: 409, which also covers an importedEHR_STATUSnaming a subject another EHR already holds.POST {ext}/message/import/{ehr_id}with anEXTRACTbody — add the extract’s content to an existing EHR as new versions. 204. 404 if the EHR does not exist; 409 if the EHR already holds anEHR_STATUSorEHR_ACCESSunder a different object id, or the imported status names another EHR’s subject; 422 if a version in the extract is semantically invalid (template, RM-invariant or terminology validation).
All four accept either application/json or application/xml bodies; any
other Content-Type is 415.
Note
The whole
{ext}/messagegroup is a FerroEHR extension: the openEHR service model defines a Message component, but the released REST API publishes no message or extract endpoint at all. These URLs are our own and gate no openEHR conformance claim; see Conformance. For the workflow-level view (what an extract is good for and how to drive an import) see EHR Extract & messaging.
Other routes under {base}/admin
One further family shares the /admin path prefix but not the
admin.enabled switch: it has its own, and answers 404 (not 405) while
that switch is off, because the group is simply not serving:
| Routes | Own switch | Documented in |
|---|---|---|
{base}/admin/event_subscription… | events.admin_api | Change events (AMQP) |
They are still admin-class routes for authorization, so the same 401 / 403 split applies.
For everything else an operator needs (migrations, health probes, the management surface, observability and upgrades) see Operations.
Usage report
Every FerroEHR instance sends a small JSON report to FerroPULSE, a collector that Cadasto B.V. runs. It goes out at each process start and once a day. It says that the instance is alive, which version it runs, under which licence grant type, and how fast it answers, in coarse aggregates. The report is on by default. Every boot logs one INFO line that says whether it is on and how to switch it off.
This page lists every field the report carries, says when it is sent and how to
print it before it is sent, gives the switch, and carries the privacy notice for
the data the collector stores. No openEHR specification governs any of it.
Every FerroHEALTH product sends the same report through the family’s shared
ferrohealth-usage-report crate; FerroEHR supplies its status, its route
groups and the database that holds the report’s state.
- When a report is sent
- What a report contains
- What a report never contains
- See the report before it is sent
- Switch it off
- Network
- Privacy notice
When a report is sent
There are two kinds of report, and both are one HTTPS POST to
https://report.ferropulse.eu/v1/report with a JSON body of at most 64 KiB.
start: at each process start, once the server has connected to its database. An instance sends at most one start report per ten minutes, however many replicas start: the time of the last one is kept in the database. A pod in a crash loop sends one start report per ten minutes, not one per restart.daily: the first attempt 10 minutes after boot, then roughly every 24 hours (23.5 to 24.5 hours after the previous one, spread at random). One replica sends it for the whole instance. At each of its daily attempts a replica adds its counts to a window kept in the database; the replica that claims the report sends the totals and empties the window, and the counts of a replica that finds the report claimed wait there for the next one. A replica can claim it only when no daily report went out in the last 23 hours, so a restart does not add one.
The boot line, with the report on, reads:
usage report ON: instance id, version, licence type, deployment, uptime, host size and coarse request latency per route group, at each start and once a day; no clinical content, identifiers, URLs or request paths. Off: FERROEHR__USAGE_REPORT__ENABLED=false. Details: https://ferroehr.eu/docs/latest/usage-report.html
It carries the configured endpoint as the structured field endpoint. With the
report off, the line reads:
usage report OFF: switched off (FERROEHR__USAGE_REPORT__ENABLED=false); nothing is sent. Details: https://ferroehr.eu/docs/latest/usage-report.html
What a report contains
Every report carries these fields:
| Field | Sent in | What it is |
|---|---|---|
schema | both | Always 2, the version of the report format. |
event | both | start or daily. |
first_start | start | true when this start created the instance id, which marks a new installation; false on every later start. |
product | both | Always ferroehr. |
instance_id | both | A random UUID (version 4), created at the first start and stored in the database. It is not derived from the host, the licence or anything else about the deployment. Every replica shares it, and a restore of the database keeps it. |
version | both | The server version, for example 4.3.4. |
git_sha | both | The commit the binary was built from, 7 to 40 hex digits. A build that recorded no commit, such as one from a source tarball, sends 0000000. |
licence | both | The licence grant type: commercial when an installed licence permits commercial use, non-commercial otherwise. Never the licence id. |
deployment | both | How the instance was deployed: helm, compose, binary or unknown. It comes from [usage_report] deployment, which the Helm chart sets to helm. |
uptime_s | both | Seconds since this process started. |
cpu_bucket | both | The CPUs available to the process, as a range: 1-2, 3-4, 5-8, 9-16 or 17+. |
memory_bucket | both | The memory available to the process in GiB, the smaller of the host’s memory and the container limit, as a range: <4, 4-8, 8-16, 16-32 or 32+. Off Linux the server cannot read it and sends <4. |
status | both | The instance’s state, below. |
metrics | daily | The performance aggregates below. |
The status object carries three members:
| Member | What it is |
|---|---|
db_ok | Whether the database answered the health check. |
migrations_ok | Whether the database carries this build’s migrations. |
postgres_major | The PostgreSQL major version, for example 18. It is left out when the database did not answer. |
Earlier releases send version 1 of the format, or version 2 with a fourth
status member, spec_profile, and the AQL series in the daily metrics, as
described in that release’s book.
A start report, formatted here for reading (the server sends it on one line):
{
"schema": 2,
"event": "start",
"first_start": true,
"product": "ferroehr",
"instance_id": "5f0c6b2e-3d4a-4c8b-9e1f-2a3b4c5d6e7f",
"version": "4.3.4",
"git_sha": "1a2b3c4d",
"licence": "non-commercial",
"deployment": "helm",
"uptime_s": 0,
"cpu_bucket": "3-4",
"memory_bucket": "8-16",
"status": {
"db_ok": true,
"migrations_ok": true,
"postgres_major": 18
}
}
The daily metrics
The daily report adds one object that covers the window since the previous daily report:
"metrics": {
"window_24h": {
"requests_bucket": "1k-10k",
"groups": {
"composition": {
"p50_ms": 12.5, "p95_ms": 80.1, "p99_ms": 240.0,
"errors_5xx": 0,
"histogram": [10, 40, 120, 300, 90, 30, 8, 2, 0, 0, 0]
},
"query": {
"p50_ms": 60.0, "p95_ms": 310.0, "p99_ms": 900.0,
"errors_5xx": 1,
"histogram": [0, 5, 40, 200, 500, 300, 120, 30, 4, 1, 0]
}
},
"counters": {
"responses_4xx": 42
}
}
}
requests_bucket: the number of requests the REST API served in the window, as a range:0,1-100,100-1k,1k-10k,10k-100kor100k+. Each range includes its lower bound and excludes its upper one, so1-100means 1 to 99.groups: one entry per group of endpoints that served at least one request. A request is placed in a group by the part of the route template it matched below the openEHR API mount (server.base_pathfollowed byrest.base_path), never by the path the client sent, so arest.base_pathsuch as/ferroehr/ehr/v1does not move a query intoehr. Routes outside the mount count in no group. The groups areehr(EHR andEHR_STATUS),composition(compositions and their version history),contribution,query(query execution),definition(templates and stored queries),directory,admin, andotherfor the demographic, messaging, terminology and FHIR families. The health family, the management surface and the status and discovery documents are counted in no group.p50_ms,p95_ms,p99_ms: the median, 95th and 99th percentile latency of the group in milliseconds, estimated from its histogram.errors_5xx: the responses with a 5xx status.histogram: 11 request counts, one per latency bucket. The bucket upper bounds in milliseconds are 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, and no bound for the last. Counts add up across instances, so a fleet percentile comes from summed histograms.counters:responses_4xxcounts the grouped requests answered with a 4xx status. It is present once the window holds one.
What a report never contains
A report carries no patient data, no EHR id or any other id taken from a request, no request path, no AQL text or query parameter, no error message, no audit-log content, no user name, no client IP address, no licence id, no host name and no customer or staff name. The report types in the server have no field that could hold any of these, and the collector refuses a report with a member it does not know.
The collector does record the IP address the report arrives from. See What the collector stores.
See the report before it is sent
ferroehr usage-report --print prints the exact JSON body the instance would
send now, and sends nothing. Add --event daily for the daily report; the
default is --event start. The command loads the same configuration as the
server and connects to the database to read the instance id and the shared
window, to which the running replicas add their counts at their daily
attempts. It writes nothing. Run it where the server runs:
# Kubernetes (the image has no shell, so call the binary by its path)
kubectl -n ferroehr exec deploy/ferroehr -- /usr/local/bin/ferroehr usage-report --print
# Docker Compose
docker compose exec ferroehr /usr/local/bin/ferroehr usage-report --print --event daily
The JSON goes to stdout on one line. Notes go to stderr: when the database holds no instance id yet, the id shown is a sample, and the first start creates the real one; with the report off, the command says so, and names the CI variable when a CI run switched it off. It works with the report off, and then shows the instance id stored at an earlier start. That id is what you give Cadasto to have stored reports erased (see Your right to object).
Switch it off
Any one of these stops the report:
[usage_report]
enabled = false
FERROEHR__USAGE_REPORT__ENABLED=false
The quickstart docker-compose.yml forwards this variable from your shell, so
export FERROEHR__USAGE_REPORT__ENABLED=false before docker compose up
switches the report off there too.
# Helm values
usageReport:
enabled: false
Under the Helm chart the switch is usageReport.enabled (on the command line,
--set usageReport.enabled=false). The chart writes it into the rendered
configuration together with deployment = "helm", and it refuses to render
when config.usage_report.enabled or config.usage_report.deployment is also
set.
The other FerroHEALTH products the chart can run report under their own names, and the same switch reaches them:
- FerroTERM (
terminology.enabled) followsusageReport.enabledunlessterminology.usageReport.enabledis set, throughFERROTERM__USAGE_REPORT__ENABLED. - FerroBRIDGE (
bridge.enabled) follows it unlessbridge.usageReport.enabledis set, through[usage_report]in itsconfig.toml. The chart writes that section for a FerroBRIDGE release from 0.0.7 on; the release it pins sends no report. - FerroPIX (
ferropix.enabled, on by default) is a dependency chart, and a dependency chart reads only its own values. Its switch isferropix.usageReport.enabled, and the chart refuses to renderusageReport.enabled=falsewhile that staystrue, so turning the report off takes both:
helm upgrade --install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
--set usageReport.enabled=false --set ferropix.usageReport.enabled=false
In a CI run the report is off whatever the switch says: when GITHUB_ACTIONS or
CI is true or 1 (in any letter case) in the server’s environment, it
sends nothing, and the boot line reads usage report OFF: CI detected (GITHUB_ACTIONS); nothing is sent with the variable it found. A container does
not inherit the CI runner’s environment, so a CI job that starts the image sets
FERROEHR__USAGE_REPORT__ENABLED=false itself; the collector also hides reports
that arrive from GitHub Actions runner addresses from every dashboard.
With the report off, the server sends nothing to FerroPULSE and writes nothing
to its database for the report, and the boot line says usage report OFF.
Nothing else in the server changes. Switching it off stops new reports only.
Reports already stored at the collector stay until the retention period ends,
unless you ask for them to be erased.
Network
- Destination: outbound HTTPS to
report.ferropulse.eu, port 443. Theendpointkey moves it; the server refuses an endpoint that is plain HTTP (except to a loopback host) or that carries credentials. - One direction only: the server reads the status code of the answer and nothing else. It does not follow redirects, and nothing the collector answers changes the server’s configuration or behaviour.
- Failures stay local: a send gives up after 5 seconds without a connection or 10 seconds in total. A failure, and a collector that refuses a report, is logged once at WARN. Boot, the health and readiness probes, and request handling never wait on the report, so a slow or unreachable collector affects none of them. A daily report that does not reach the collector puts its counts back in the database, releases its claim, and is tried again an hour later. A report the collector refuses is not resent.
- NetworkPolicy egress: with the chart’s
networkPolicy.egress.enabledon, the pod needs a rule innetworkPolicy.egress.rulesthat admitsreport.ferropulse.euon port 443. A NetworkPolicy cannot match a DNS name, so the rule is anipBlock; see Egress. Without the rule the report logs a warning and nothing else is affected. If you do not want the report, switch it off: a blocked report is still attempted at every start and every day.
Privacy notice
This section gives the information that Articles 13 and 14 of the GDPR list, for the personal data a report and its stored record can contain. A report names no person. It can still relate to one: the collector stores the address each report comes from, and an instance run by one person, such as a practitioner on their own internet connection, can be linked to that person through the address or through the instance id. Where that is so, the report is personal data, and this notice applies to it.
This page is also part of the user information Cadasto supplies with FerroEHR under Annex II of the Cyber Resilience Act: it names the outbound connection, what it carries, and how to switch it off.
Controller
Cadasto B.V., the Netherlands. Write to info@cadasto.com or use https://www.cadasto.com/contact/. Cadasto has not appointed a data protection officer.
Purposes and lawful basis
Cadasto processes the reports for three purposes:
- to know which FerroEHR versions run in the field, so it can tell how many installations a security release concerns and whether they upgrade;
- to know how many installations run under each licence grant type;
- to see how the product performs in real use, so it can find and fix slow paths.
The lawful basis is Art. 6(1)(f) GDPR, legitimate interest. The interest is Cadasto’s, as the maker of FerroEHR: keeping the product it publishes secure and maintained, which needs the versions in use and the reach of each security release, and improving it, which needs the licence mix and field performance. The data come from the FerroEHR instance itself. You are not obliged to send them: the report can be switched off, and switching it off changes nothing else in FerroEHR.
What the collector stores
- Every report as it arrives, with the fields listed above.
- With each report: the time of receipt, the UTC day, the source IP address (IPv4 or IPv6), and the country derived from that address.
- Per instance id: when it was last seen, its current version, and whether it is alive.
- Per day: latency totals summed across the whole fleet, without instance ids.
The country comes from an IP-to-country database bundled with the collector, so no address leaves the collector host for the lookup. IP geolocation by DB-IP, licensed CC BY 4.0. To limit request rates, the collector also keeps a short counter per source address in memory, which is lost when it restarts.
Recipients and location
Hetzner Online GmbH (Gunzenhausen, Germany) hosts the collector in its cloud and processes the reports as Cadasto’s processor, under a data processing agreement Cadasto has signed (Art. 28 GDPR). The collector runs in Hetzner’s data centre in Falkenstein, Germany. The FerroPULSE dashboard that shows the reports sits behind a GitHub login with no public or anonymous access; only members of the FerroHEALTH organisation can sign in.
Retention
A daily job deletes reports and start reports 13 months after they were received, an instance record 13 months after the instance was last seen, and the fleet-wide daily totals 13 months after their day. Backups of the collector’s database are kept for 14 days, so a deleted report can remain in a backup for at most 14 days more.
Your right to object
Important
You have the right to object at any time, on grounds relating to your particular situation, to the processing of reports that are personal data concerning you (Art. 21(1) GDPR).
To stop future reports, switch the report off as described in Switch it off. No request to anyone is needed.
To have the reports already stored erased, write to info@cadasto.com with the instance id that
ferroehr usage-report --printshows. Cadasto then deletes every report, start report and instance record stored for that instance id, source addresses included (Art. 17(1)(c) GDPR). Switch the report off first, or the next report starts a new record.The fleet-wide daily totals hold no instance ids, so an erased instance’s share in days already summed stays in those totals.
Your other rights
At the same address you can ask for access to the reports stored for your instance id (Art. 15), for their rectification (Art. 16) or erasure (Art. 17), or for restriction of their processing (Art. 18). You have the right to lodge a complaint with a supervisory authority, in particular in the Member State of your habitual residence, place of work or place of the alleged infringement (Art. 77).
Operators in Switzerland
Under the Swiss Federal Act on Data Protection (DSG Art. 19 Abs. 4), these are the states your report’s data are disclosed to: Germany, where the Hetzner data centre in Falkenstein runs the collector, and the Netherlands, where Cadasto B.V. is established. Both are listed in Annex 1 of the Swiss Data Protection Ordinance (DSV) as states whose legislation provides adequate protection (DSG Art. 16 Abs. 1).
Returning to the original state
This page resets a FerroEHR instance to the state its release shipped in: the configuration the release ships, and an empty database carrying the schema that release’s migrations create. Nothing else survives a reset. EHRs, compositions, templates, parties, the party-to-EHR map, the audit trail and the instance id the usage report sends are all gone, and the next boot starts as a fresh install.
Before you reset
Warning
A reset deletes the access log along with the records it describes. If the instance has ever held real personal data, the audit trail is subject to the retention floor of every jurisdiction you operate in (three years in every EU Member State, five in the Netherlands, one in Switzerland; see Audit), and your duty to keep it does not end when the instance does. Take a backup of every domain first, the audit trail included (Backup and point-in-time recovery), and keep it for that horizon.
A reset does not reach anything outside the instance. Your logical dumps, WAL archives, volume snapshots, log collector and any external Audit Record Repository still hold what they held. Deleting those is a separate decision with the same retention duties. To take the instance out of service for good instead of starting it again, follow Decommissioning.
The reset has two halves, and you need both:
- Configuration: the server reads its built-in defaults, then a
ferroehr.toml, thenFERROEHR__…environment variables, then--set(Configuration reference). The original state is the release’s own file and no overrides. - Database: drop the database and let the release’s migrations build it again. Drop the whole database, never single schemas (Recovering a partially wiped database explains why).
The deployment_profile the release ships is sandbox, so a reset instance
says on its boot banner and on GET /ferroehr/rest/status that it must not
hold real patient data. Set deployment_profile = "production" again before
it does.
Docker Compose
The quickstart keeps the database in the named volume ferroehr-pgdata (and,
with the s3 profile, multimedia in ferroehr-seaweedfs), and its server
configuration inline in docker-compose.yml.
-
Stop the stack and delete its volumes:
docker compose down --volumes--volumesremoves the named volumes the file declares (docker compose down). Run it with the same-foverlay files and--profileflags you started the stack with, so it finds every service. -
Restore the configuration. Replace
docker-compose.ymlwith the copy attached to your release on GitHub Releases, and remove everyFERROEHR__…,DATABASE_URLandRUST_LOGvariable you set, from your shell and from any.envfile next to the compose file:env | grep -E '^(FERROEHR__|DATABASE_URL=|RUST_LOG=)' # expect no output -
Start it again:
docker compose up -d --waitThe database image provisions the domain roles on the empty volume, and the server applies its migrations at boot.
-
Read the result back. The effective configuration should be the file’s and nothing else:
docker compose exec ferroehr /usr/local/bin/ferroehr config check
Kubernetes and Helm
The chart deploys no database, so the two halves are separate operations.
-
Remove the release, so no pod holds a connection:
helm uninstall ferroehr --namespace ferroehrThis keeps the Secrets and PersistentVolumeClaims you created yourself, such as the DSN Secret and the backup claims.
-
Drop and recreate the database, as a role that may (the bootstrap superuser or the database owner), connected to a different database:
DROP DATABASE ferroehr WITH (FORCE); CREATE DATABASE ferroehr OWNER ferroehr;WITH (FORCE)terminates the sessions still connected (DROP DATABASE). Use the owner your deployment provisioned the database with. If[storage.party],[storage.linkage]or[storage.audit]name databases of their own, drop and recreate each of those too. The domain roles are cluster-wide objects, so they survive the drop, and the migrations reuse them. -
Install the chart again with only the values every deployment must supply (the posture, the DSN Secret and an authentication mechanism), and none of your other overrides:
helm install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \ --version <your chart version> \ --namespace ferroehr \ -f minimal-values.yamlThe schema is applied at boot (
config.db.migrate: apply, the chart default), or by the migration Job when you enabled it.
The Kubernetes deployment probes run these three steps against a real cluster: the release is removed and the DSN Secret survives, the database is dropped and recreated by the bootstrap superuser, and the reinstalled release migrates the empty database and no longer finds an EHR written before the reset.
A single binary
- Stop the server.
- Drop and recreate the database as in step 2 of the Kubernetes procedure.
- Restore the configuration: replace your
ferroehr.tomlwith a fresh copy ofapp/ferroehr/assets/ferroehr.default.toml(or delete it, which leaves the built-in defaults), and unset everyFERROEHR__…,DATABASE_URLandRUST_LOGvariable except the database DSN. - Prepare the schema with
ferroehr db migrate, or start the server with the defaultdb.migrate = "apply". - Check the effective configuration with
ferroehr config check.
How the procedure is tested
The deployment probe harness (scripts/deploy-probe.sh, its original_state
family) runs the Docker Compose procedure above. It writes an EHR, resets the
stack as steps 1 to 3 describe, and checks that the volume was deleted, that
the EHR is gone, and that the server reports the shipped configuration. The
Kubernetes and single-binary procedures are not exercised by any probe.
Decommissioning
This page takes a FerroEHR instance out of service for good and removes what it
stored. ferroehr db erase deletes the multimedia blobs and every schema the
migrations created, which takes every EHR, composition, template, party, the
party-to-EHR map, the audit trail, the stored settings and the instance id with
it. The rest of the page lists what the command cannot reach and how you remove
it yourself.
To start over with an empty instance of the same release instead, follow Returning to the original state.
- Before you erase
- Stop every server
- The dry run
- The erase
- What the erase does not reach
- Removing a single party
- How the procedure is tested
Before you erase
Warning
The erase cannot be undone. Nothing in FerroEHR keeps a copy.
You are the controller of what the instance holds, and the law may require you to keep some of it after the instance is gone. Settle these before you run the command:
- Clinical records. Medical-records law sets retention periods measured in
years after the last treatment or after death. If the records must survive
the instance, move them first: export each EHR over the API, or take a full
dump through
/ferroehr/rest/extensions/v1/admin/dump(dump and load), and check the copy in its new home before you erase. The retention marks and what is already due are described in Retention, restriction and objection. - The audit trail. Access logs have a retention floor of their own: three
years from each access in every EU Member State, five in the Netherlands, one
in Switzerland (Audit). The erase deletes the
auditschema. Take a dump of it first and keep that dump for the longest period that applies to you (Backup and point-in-time recovery). A dump kept for this reason is still personal data and needs the same protection as the instance had. - Data subjects and processors. If the instance served other organisations or fed other systems, tell them before the data goes.
Stop every server
Stop every FerroEHR server and job that connects to the databases: the replicas, the migration Job, the backup CronJobs and anything else that holds a connection. A server that keeps running holds locks, and the erase gives up after 30 seconds of waiting for one rather than hang. A server that keeps running could also write a new blob after the blobs are deleted.
With the Helm chart, helm uninstall (see Kubernetes
below) stops all of them. With Docker Compose, stop the ferroehr service and
leave the database running.
The dry run
Run the command once without --confirm. It erases nothing, prints what an
erase would delete, prints the value --confirm must carry, and exits with
status 1:
$ ferroehr db erase
`ferroehr db erase --confirm` would permanently delete:
12 multimedia blob(s) in bucket `openehr-multimedia`
database `ferroehr`: schemas audit, linkage, party, clinical, ext
instance id: 0b5e3c1e-6f4a-4d1b-9a43-2b7c5f1d8e90
to erase, run: ferroehr db erase --confirm 0b5e3c1e-6f4a-4d1b-9a43-2b7c5f1d8e90
Error: dry run: nothing was erased
The command reads the same configuration the server does (--config,
FERROEHR__… and --set), so run it with the configuration of the instance
you mean to remove. When the domains live in several databases, it prints one
line per database.
The value to confirm with names the instance:
- The instance id, when the database stores one. The server creates it at the first start with the usage report switched on.
- The name of the database the clinical domain lives in, when the database stores no instance id, because the usage report never ran.
Reading the value off the dry run is deliberate: you confirm the instance the configuration actually reaches, not the one you think it reaches.
The erase
ferroehr db erase --confirm 0b5e3c1e-6f4a-4d1b-9a43-2b7c5f1d8e90
The command runs in this order:
- It lists the blob store. A store that is configured but cannot be reached stops the command here, before any database is touched.
- It checks the confirmation. A value that does not match stops the command with nothing erased.
- It deletes every multimedia blob FerroEHR wrote to the bucket. These are the objects at the bucket root named by a 64-character lowercase hex SHA-256; any other object in the bucket is left alone.
- For each database, in one transaction, it drops the schemas
ext,clinical,party,linkageandauditthat live there, withDROP SCHEMA … CASCADE. The cascade takes every table, row, function, partition and stored setting in them, and thebtree_gistextension installed inext. A database is either erased whole or left as it was.
It connects as ferroehr db migrate does: on [db] migrate_url (or
[db] url when that is unset) for every domain in that database, and on a
relocated domain’s own DSN otherwise. That credential must own the schemas,
which the credential that ran the migrations does.
If a step fails, fix the cause and run the same command again. Blobs are deleted before the schemas, so a failure while deleting blobs leaves the database whole. A second run after a successful one finds nothing, says so, and exits 0.
A build without the multimedia feature refuses to run against a
configuration that names a blob store, because it cannot delete the blobs.
What the erase does not reach
The erase removes what FerroEHR stores in its databases and its bucket. The following are outside them, and removing them is your job.
Backups and dumps
Your pg_dump files, base backups and volume snapshots still hold everything.
With the chart’s backup CronJobs (backup.enabled), the dumps are on the
PersistentVolumeClaims you named in backup.clinical.persistentVolumeClaim,
backup.party.persistentVolumeClaim, backup.linkage.persistentVolumeClaim
and, when the audit domain has its own database,
backup.audit.persistentVolumeClaim. The chart created none of these claims,
so helm uninstall leaves them. Keep the ones your retention duties require
and delete the rest.
WAL archives, replicas and standbys
A WAL archive (pgBackRest, WAL-G, archive_command) holds every change the
database ever made, including the rows the erase dropped. A streaming replica
or standby replays the drop, but a delayed standby, a logical replica or a
replica you detached keeps its copy. Expire the archive and remove every
replica once your retention period allows.
The databases and the roles
The erase drops schemas, not databases. Drop each database when nothing else lives in it:
DROP DATABASE ferroehr WITH (FORCE);
Run it as the owner or a superuser, connected to another database
(DROP DATABASE).
Repeat it for every database [storage.party], [storage.linkage] or
[storage.audit] names.
The roles are cluster-wide, so neither command removes them: the migrations
create ferroehr_migrator, ferroehr_clinical, ferroehr_clinical_reader,
ferroehr_party, ferroehr_party_reader and ferroehr_linkage, and you
created the login roles your DSNs use. Drop them when no other database on the
cluster uses them:
DROP ROLE ferroehr_clinical, ferroehr_clinical_reader, ferroehr_party,
ferroehr_party_reader, ferroehr_linkage, ferroehr_migrator;
DROP ROLE refuses a role that still owns objects or holds privileges in any database of the cluster.
Kubernetes and Helm
helm uninstall removes what the release created, including the Secret the
chart renders from secrets.* values:
helm uninstall ferroehr --namespace ferroehr
It leaves what you created yourself: the Secrets named in the
existingSecret values (the DSNs, the migrator DSN, the backup DSNs) and the
backup claims. List them and delete each one you no longer need:
kubectl get secret,pvc --namespace ferroehr
kubectl delete secret <name> --namespace ferroehr
kubectl delete pvc <name> --namespace ferroehr
Whether a deleted claim’s volume is wiped or kept depends on its storage
class’s reclaim policy
(Persistent Volumes).
A volume with the Retain policy survives the claim and must be deleted on its
own. Delete the namespace last if nothing else runs in it.
Docker Compose
docker compose down --volumes removes the database volume ferroehr-pgdata
and, with the s3 profile, the SeaweedFS volume ferroehr-seaweedfs. The
dumps the backup profile wrote are in ./backups/ next to the compose file.
Configuration, keys and secrets
Delete the files the instance read: ferroehr.toml, the files the *_file
and *_path keys point at (DSNs, TLS keys, the OIDC secret, the signing key
and its passphrase, the national-identifier key, the multimedia secret key),
the licence token, and any .env file. Remove the FERROEHR__… variables from your service definitions
and your secret store. Revoke the credentials themselves where they live: the
database passwords, the object-store access key, the OIDC client and the
broker accounts.
Systems FerroEHR sent data to
Change events on the message broker, records sent to an external Audit Record Repository or a syslog collector, and the logs and traces your telemetry backend received are held by those systems. Remove them there, under the same retention duties.
The physical media
Dropping a schema or deleting an object frees the storage; it does not overwrite it, and a disk, a cloud volume or a bucket can still yield the bytes until they are reused. Sanitising the media (cryptographic erasure of an encrypted volume, the provider’s deletion guarantees, or destroying the disks) is outside what FerroEHR can do. Encrypting the volumes from the start (TLS and database security) is what makes cryptographic erasure possible at the end.
Removing a single party
The openEHR Admin API in ITS-REST defines deletion routes for EHRs only.
FerroEHR adds one of its own for a party: DELETE /ferroehr/rest/extensions/v1/admin/party/{party_id}
runs the specification’s physical_party_delete operation, removing the party
and every PARTY_RELATIONSHIP that names it, with all their versions. It sits
behind the admin API switch and the admin role, like the EHR delete, and
leaves the same access record. This is the route for an erasure request about
one person’s demographic record (GDPR Art. 17):
Physical deletion.
The other two ways to remove a party:
DELETEon the demographic API removes a party logically: a new version marks it deleted, and its history stays.ferroehr db eraseremoves every party along with the rest of the instance.
How the procedure is tested
The integration suite of the ferroehr crate writes an EHR with a composition,
a party, a party-to-EHR mapping, an audit record and an instance id into a
database, checks that a wrong confirmation changes nothing, erases, and checks
that none of the five schemas, and so no row, remains and that a second run is
a no-op. A second test deletes blobs from an in-memory store, and a third shows
that an unreachable store stops the erase before the database is touched. The
command has not been run against a real S3 bucket, a multi-database layout or a
Kubernetes cluster by any probe.
Conformance
FerroEHR makes a measured claim: it is an openEHR-spec-conformant Clinical Data Repository, and that claim is backed by a test run you can reproduce. This chapter explains what conformance means here, how to run the suite against FerroEHR, and how to read the artefacts it produces.
- What is measured
- The current result
- The party set
- Running the suite yourself
- Reading the artefacts
- What conformance does not cover
What is measured
Conformance is checked by Veredictum,
a separate CNF 2.0 reference runner. It lives in its own repository, has the
same maintainer as FerroEHR, and is consumed here at a pinned version. It is a data-driven
interpreter over a committed, machine-readable catalogue authored from the openEHR
Conformance framework itself: protocol-neutral case cores anchored on the official
platform test schedule (case ids follow the schedule’s own naming, e.g.
I_EHR_SERVICE.create_ehr-main), per-operation bindings mapping every outcome to
its cited wire expectation, closed vocabularies for outcomes/selectors/captures, a
provenance-stamped corpus (the official openEHR Robot data sets re-adjudicated to
spec-text-only evidence), and a typed ambiguity register; a specification silence
is never resolved privately. Every expectation traces to specification text, never
to any server’s observed behaviour. The catalogue spans the schedule’s chapters:
| Chapter | Scope |
|---|---|
| EHR / EHR_STATUS | EHR service and status operations |
| COMPOSITION / CONTRIBUTION / DIRECTORY | Clinical content, change sets, folder trees |
| DEFINITION | ADL 1.4 + ADL 2 template and stored-query provisioning |
| QUERY | AQL query execution with committed result-set grounds |
| CONTENT | Reference-Model and archetype-constraint accept/reject tables |
| DEMOGRAPHIC / ADMIN / MESSAGING | Party, admin, and messaging services |
| SYSTEM | The OPTIONS capability/conformance manifest |
| SF | Simplified formats — FLAT and STRUCTURED commit/read, context, examples |
| SEC / SIG | Authenticated access, authorization separation, audit accountability, and version signing in both depths |
| SMART | SMART App Launch discovery and resource-scope enforcement |
| PERF | The measured performance classes (Performance) |
The run is what turns cases into a claim. Verdicts are computed, never asserted: a pure function rolls per-case outcomes up through the capability→tier matrix from the CNF Profiles book into Core / Standard / Options / SEC-BASIC profile verdicts, honouring the party statement’s declared capabilities and option selections. A case whose wire does not exist on the technology profile, or whose ground a shared server cannot establish, is recorded as not applicable with a machine-readable citation rather than silently omitted.
Note
The runner publishes a JSON Schema for every artefact family it writes, so a consumer can validate a record without trusting the tool that produced it. The instrument is built from the currently pinned specifications (AQL 1.1.0, Terminology 3.1.0, ITS-REST 1.1.0, and the Reference Model generation the system under test runs, for FerroEHR the default
developmentprofile, RM 1.2.0; the composed stack takes the profile from configuration, so astable-profile run is the same catalogue against the released generation). The upstream Robot suites are reference material; their official data fixtures enter the corpus only as provenance-stamped re-adjudications.
The current result
The whole conformance story in one picture: every capability of the claims matrix, grouped by profile tier, colored AND glyph-marked by the evidence its cases produced (both charts are generated from the committed runner artefacts and regenerate-and-diff guarded in CI; no number on them is hand-typed):
The same run broken down two levels deep: a header per schedule chapter with its
total, then one bar per band, the surface a case actually exercises (EHR
resource, EHR_STATUS, COMPOSITION, …), with the exact outcome counts printed
beside every row. Every band the taxonomy declares is drawn, so one with no case
for this run shows as an explicit no cases row rather than disappearing, and a
hatched segment marks cited-N/A so it reads as neither a pass nor a failure:
The published run against FerroEHR reports:
- 1145 case-by-format executions: 1104 passed, 0 failed, 0 inconclusive, 41 not applicable with a machine-readable citation (run of 2026-10-30).
- Profile verdicts — Core: PASS. Standard: PASS. Options: PASS. Security (SEC-BASIC): PASS.
- 42/43 capabilities satisfied (passed, or excused by a schedule-registered ambiguity — an unrealizable wire on this technology profile is an explicit scope exclusion, never a silent pass).
Cases that did not execute are not-applicable with a machine-readable citation (an unrealized wire on this technology profile, an undeclared option branch, or a ground a shared server cannot establish), never silent omissions. Options aggregates optional capabilities under the Profiles book’s “any passes” rule.
The party set
The runner is a separate project with its own release line, so the instrument
and the system it judges do not share a build. It assesses any openEHR CDR
reachable over HTTP; this repository runs it against FerroEHR alone. What it
reads about FerroEHR is the party set under docs/conformance/party/ferroehr/:
statement.jsonclaims the capability folders of the catalogue FerroEHR serves and selects one arm of every option the catalogue leaves to the implementation (canonical XML on each resource, the Simplified Formats, version signing, the administrative delete, and the others). Each arm states what the server does, andveredictum validate --statementholds the file to the published statement schema and the catalogue before a run.ixit.jsondescribes the deployment: thesystem_idthe server stamps into commit audits and the version ids it mints, theenvironmenta measured run happened on, andexclusive_server, which lets the workflows that delete every EHR run.
Credentials are in neither file. The run reads them from the environment.
Running the suite yourself
The suite runs against a real, deployed server (the same container image and stack a deployment uses), so the wire under test is always the production artefact. From a checkout with Docker or Podman available:
bash scripts/conformance.sh
The script reviews the statement, composes one FerroEHR deployment from the
current sources on fresh volumes, runs veredictum test through the pinned
release image with the Basic users of docker/ferroehr.dev.toml, writes
results.json, recording.har and the JUnit, CTRF and Markdown views export
renders to docs/conformance/ferroehr/, and tears the stack down. The
recording holds the server’s response bodies, so it is never committed.
Useful knobs: CONF_DOCUMENTS to narrow the run to named documents (a working
tool, never the record), CONF_OUT for a different artefact root,
CONF_NO_COMPOSE with CONF_BASE_URL to run against an already-deployed
FerroEHR, SKIP_BUILD to compose a published image instead of building from
source, and CONTAINER_ENGINE to choose between Docker and Podman.
The script also takes one stage: review runs veredictum validate --statement alone, up composes the deployment and leaves it serving, and
down removes it with its volumes. The Conformance workflow uses these stages
to run the same deployment in CI. It builds FerroEHR from the commit, runs
up, and drives the catalogue through the Veredictum GitHub Action, pinned by
commit SHA, on the same release image by digest. The job fails on a failed or
errored row, puts the Markdown summary on the job summary, and keeps the
results, JUnit and CTRF views as an artifact. It runs on pull requests that
touch the server, the party set, the conformance compose files or the
workflow, on main, on a release tag and twice a week.
The posture a run covers
The one deployment runs the SMART resource-server role with a trusted test issuer, digest version signing, and FerroTERM as its terminology server, in the fail-open posture.
- SMART is part of the posture. The tokens the SMART documents present are signed by a committed test issuer: public test key material for the harness, never usable for anything else.
- External terminology is part of the posture too. An archetype can
constrain a coded element to a value set only an external terminology query
server can resolve, so the pipeline composes a real FHIR R4B server beside the
CDR, seeded with synthetic test code systems and value sets. That covers AQL
TERMINOLOGY()resolved through the routed server, and commit-time validation of a bound value set. What a deployment does when the value set cannot be resolved at all is not decided by any openEHR text, so it is a declared option arm rather than a verdict.
A measured performance or stress run adds one more posture: rate limiting is
turned off for the duration, because the instruments deliberately offer load past
the server’s knee and a throttled request would measure the limiter instead of the
server. Both instruments refuse to write a record if the server answered any
429, so a measurement can never be silently limiter-shaped.
Reading the artefacts
A run writes machine records and three human-readable documents to
docs/conformance/<sut-name>/. Each has a distinct job.
The machine records
results.json is the party results record: one outcome per case with its
rows-driven coverage, failing step and reason where applicable, and the excusing
citation for every not-applicable entry, alongside the system-under-test identity,
the runner’s verification-pack status, the technology profile, and the ixit
digest. verdicts.json is the computed verdict report, and
run-exceptions.json registers anything the interpreter itself could not cover.
Every other artefact (the three documents, the badges, the charts) is generated
from these; nothing downstream is hand-edited.
The conformance report
CONFORMANCE_REPORT.md is the honest, scoped record of this run: the system
under test, the outcome counts, the per-capability evidence rollup, the
machine-computed profile verdicts, and every not-applicable entry with its
excusing citation. Read this when you want to know exactly what happened and why
any case did not run.
The conformance statement
CONFORMANCE_STATEMENT.md is the concise, generated claim: the supported
specification versions, the declared external data formats (JSON and XML), and the
profile results. Every line is a pure function of the machine verdicts, so the
statement can never claim more than the run proves.
The conformance certificate
CONFORMANCE_CERTIFICATE.md follows the structure of the openEHR conformance
certificate template: the system under test, the scope of test, and a
per-capability profile report showing which capabilities are required in each
profile, what each was verified against, and whether each passed. The
Realization column separates capabilities verified over released ITS-REST
operations from any verified over routes a product serves of its own design — the
latter never gate an openEHR profile tier. Where the certificate carries a
measured run, its Workload Coverage table additionally shows which claimed
capabilities the hospital-simulation load actually exercised; a capability the
simulation does not reach must carry an adjudicated exclusion, printed with its
reason, and the runner’s validation gate refuses an artefact tree that leaves such
a row undecided. It always identifies itself as a framework assessment with the
claim computed from the attached run; it is never an official openEHR
certification. This is the document to hand to a procurement or evaluation
reviewer who wants the capability-by-capability picture.
Tip
The conformance badges in the project README are generated from the same run and carry the measured amounts (per-profile capability counts, the overall driven-case count, the earned performance class). A badge can never show a pass unless the machine verdict does, so a green badge is a claim you can immediately reproduce with
scripts/conformance.sh.
What conformance does not cover
The catalogue measures the openEHR platform surface, including the simplified (FLAT/STRUCTURED) formats chapter of the ITS-REST specification. It deliberately does not stand in for a performance benchmark: durations recorded during the functional run are telemetry only, and the measured classes own that claim (Performance).
The other honest boundary is the gap between openEHR’s service model and its
released REST wire. Several service operations were never surfaced as
endpoints (listing an EHR’s contributions, counting stored templates or queries,
deleting a template or a stored query) so a case that addresses one has no wire
to drive on this technology profile. Those cases are excused through the
schedule’s typed ambiguity register, with the citation printed in the report, and
reported as an explicit scope exclusion on the certificate; never a silent pass
and never an unavoidable failure. FerroEHR does serve routes of its own design
for several of them (see
Admin & messaging APIs and the archetype routes in
Templates & validation); those are marked
extension on the certificate and never gate an openEHR profile tier.
Performance
FerroEHR applies the same discipline to performance that it applies to conformance: a class is a verdict a server earns by measurement on a stated environment, or does not. The performance chapter of the CNF suite runs an open-loop clinical workload at a published offered-load floor, records the result as a re-checkable histogram, and lets the verdict pipeline recompute (earned or not earned) from that artefact. Nothing on this page is hand-typed; every number comes from the committed measurement records or the generated assets below.
- The volumetric class ladder
- Where the floors come from
- The durability floor — what a single write can never beat
- The hospital simulation
- How a measured run works
- Reproducing it
- Stress testing — the second instrument
- The storage benchmark harness
- The latest measured run
The volumetric class ladder
Performance conformance is graded on a small, closed ladder of deployment
classes: proof-of-concept (POC), small (S), large (L), and regional
(R). Each
class fixes an offered-load floor (the peak API arrival rate the server must
sustain), a latency budget (a p99 service-level objective, uniform across
classes), and an error budget (zero: a failed request under load is a failed
class). A class is earned only when a measured run holds every threshold; a
class is never declared.
Crucially, a class verdict is environment-bound: it is meaningful only alongside the hardware, core count, memory, storage class, and topology it was measured on, which the runner records in the measurement’s environment block and stamps into every asset. The same binary earns different classes on different hardware, and the artefact always says which.
Where the floors come from
The offered-load floors are anchored to population, so a class corresponds to a real catchment a deployment might serve. The derivation is a short chain of published, official activity statistics:
-
Clinical documents per person per year. Summing the major encounter types that each commit a clinical document gives roughly forty-six documents per capita per year: primary- and specialist-care consultations (OECD, Health at a Glance 2023), inpatient discharges (OECD/Eurostat hospital discharge statistics), emergency-department visits (OECD emergency-care indicators), laboratory reports (Royal College of Pathologists activity data), diagnostic-imaging events (NHS England Diagnostic Imaging Dataset, 2023/24), and dispensed prescriptions (NHS Business Services Authority Prescription Cost Analysis, 2024/25). The result sits between the major-document exchange rates Denmark and Estonia report and Finland Kanta’s all-inclusive figure, which is the sanity check that keeps it arguable.
-
Average write rate. Multiplying a class’s served population by that per-capita rate and dividing by the number of seconds in a year gives the average sustained document-write rate for the class:
-
Busy-hour peak. Real clinical traffic is not flat: it concentrates in ward rounds and clinic hours. Following the ITU-T E.500 busy-hour engineering convention, the average is scaled to a busy-hour peak by a peak factor of eight:
-
Read multiplier. A CDR is a read-heavy OLTP system: charts are read far more often than they are written. Following the read-heavy OLTP convention used by standard database benchmarks (YCSB, OLTP-Bench), the offered load applies a read-to-write ratio of 10:1 on top of the write rate; audit-log evidence of how many record interactions one encounter actually generates bounds the read-heavy band from above.
The floors that fall out of this chain are the published defaults the runner enforces; the concrete rates per class are carried in the class ladder above and the summary table below, never re-typed into this prose.
The durability floor — what a single write can never beat
Every committed version is durable: the transaction’s WAL records are flushed
to disk before the server answers, so an acknowledged commit survives a crash.
That flush (one fsync on the WAL device per commit) is a physical lower
bound on single-client write latency, and no storage design takes a lone
sequential client below it. On a laptop-class Docker setup the flush alone
dominates a single write’s budget; on server NVMe it is typically an order of
magnitude smaller. FerroEHR’s optimization target is therefore everything around the
flush: the database’s own per-commit work (one folded commit statement, one
merged placement read) stays far below the flush itself. That is also what
makes concurrent throughput scale: PostgreSQL group-commits, amortizing one
flush across every transaction that reaches it in the same window.
The knob behind this boundary is PostgreSQL’s
synchronous_commit.
Setting it off makes commits return before the WAL flush: single-client
write latency drops to the statement work alone, and a server crash can lose
the most recent acknowledged commits (up to three times wal_writer_delay,
per the PostgreSQL documentation). The database stays consistent; the tail of
acknowledged writes is what is at risk. FerroEHR never defaults this off
and does not recommend it for clinical data. It is an operator decision,
made per deployment on the database side, defensible only where the record of
loss is acceptable (a load-test rig, a reseedable sandbox, an analytics
replica). All published FerroEHR numbers are measured with full durability
on.
The hospital simulation
The measured workload is not a flat operation mix: it simulates a hospital, end to end. Load arrives as clinical journeys (ordered, time-offset operation sequences drawn from a committed journey catalogue):
- ADT flow: an admission creates the EHR, sets its status, commits the admission problem list and summary, and opens the per-episode directory folder tree; a discharge writes the discharge summary and closes the episode out.
- Monitoring: nursing observation rounds commit vital-signs documents at ward cadence.
- The medication loop: an order is followed by scheduled administration commits at the drug-round interval; medicines reconciliation reads the standing medicines list and amends it as a new version.
- Order → result pipelines, asynchronous: a laboratory or imaging request is committed at one instant and its result lands as its own arrival after a realistic turnaround drawn from the catalogue; the ordering clinician’s chart review follows later still. Nothing ever blocks on anything else.
- Clinical review: ward-round chart reads (at version, current, and the revision history), per-patient AQL trends, cross-EHR ward worklists, and a registered stored query executed continuously.
- Governance: versioned amendments, the occasional logical delete, contribution inspection (the audit trail’s read side), and workflow tagging of hot documents.
- The platform surface: template listing and retrieval (the integration-engine poll), specialist synoptic reports, registry submissions, and statutory public-health notifications.
Every stage of every journey instance is its own planned arrival instant on the global open-loop schedule (an order at , its administrations at , the result at ) so many patients’ journeys interleave exactly as wards do, and cross-operation state effects (status transitions during active commits, folder consistency under parallel writes, version chains under interleaved amendments, AQL against a mutating corpus) are exercised under load, which a flat four-operation mix can never reach. A dependent stage whose prerequisite has not landed when its instant fires (a stalled server) records honestly as an error; that is the measurement.
The journey payloads commit against published openEHR CKM templates (vital signs, laboratory results, ePrescription, medicines list, problem list, the International Patient Summary, imaging and cancer synoptic reports, registry and public-health forms), vendored with provenance and committed as byte-identical example skeletons so every measured server receives exactly the same bytes.
The envelope stays population-anchored. The derivation above still fixes the aggregate operation arrival rate (the class floor) and the read:write ratio; the journey catalogue only decomposes those totals into many more operation kinds. Each journey cites the activity statistic that grounds its shape (the same register the floors derive from) and the runner’s artefact validator recomputes the expansion on every load: the catalogue-expanded write share must reconcile to the derivation’s read-heavy band (between the 10:1 floor convention and the audit-log-evidenced ceiling), so the mix stays arguable, never arbitrary.
For the extended eight- and twelve-hour holds, the schedule can follow a diurnal day curve (morning and afternoon peaks, shift-change bumps, a night-time trough) applying the same ITU-T E.500 busy-hour convention the peak factor cites: the class floor is then the busy-hour rate, and the off-peak troughs are the design, not a shortfall.
Finally, the conformance certificate prints the workload coverage: the set of claimed capabilities the simulation actually exercised, joined against the claims matrix. A claimed capability the hospital never touches must carry an adjudicated exclusion printed with its reason: a design-time operation like archetype provisioning is not a sustainable per-patient arrival, and saying so beats pretending the load reached it.
How a measured run works
A performance run is deliberately open-loop: the runner plays a seeded arrival schedule (request is due at a planned instant computed before the run starts) rather than a closed loop of virtual users that would slow its own offered load down whenever the server stalls. This makes the run coordinated-omission-free: each request’s latency is measured from its planned arrival instant, so a server that pauses cannot hide the queue it built up behind a handful of fast replies.
A run has two phases: a discarded warmup window (caches fill, pools warm, the
JIT of a compared server settles) followed by the sustained measurement window
at the class’s offered-load floor. Latencies are accumulated into per-operation
HDR V2 histograms, which are base64-encoded verbatim into the measurement
record in results.json. Because the full histogram is embedded (not just a
handful of pre-computed percentiles) any consumer can re-derive every percentile
and re-check every threshold from the committed artefact. The class verdict itself
is then recomputed by the verdict pipeline from those records: the runner
never writes a verdict it cannot reproduce from the data.
The measured corpus is seeded strictly through the public write path (create EHR, commit composition) and never a database backdoor, so what the run reads is exactly what the server’s own write path produced. The database’s maintenance debt is settled deterministically, and identically for every system under test, outside the measured windows, so neither a stale-statistics plan nor an autovacuum firing mid-window ends up in the record.
Reproducing it
The measured run is a stage of the conformance pipeline. Selecting a class seeds
the matching scale corpus, plays the open-loop schedule against the composed
server, and merges the measurement records into results.json:
# seed the class corpus, run the open-loop schedule, merge the record
CONF_PERF_CLASS=POC bash scripts/conformance.sh
# hold the same offered load for longer — the hours ladder is 1 (the case's
# normative window and the default), 2, 4, 6, 8, or 12
CONF_PERF_CLASS=POC CONF_PERF_HOURS=8 bash scripts/conformance.sh
There is deliberately no shortened run: the measurement record always covers at least the case’s normative window, so nothing sub-normative can ever be mistaken for a measured result. A longer hold is a stricter demonstration of the same class, not a different one. The record carries the actual warmup and window it held, and the verdict machinery re-derives everything from the embedded histograms. Every run seeds a freshly composed server from empty; there is no seed reuse, so no run ever measures another run’s leftovers.
The runner can also be driven directly against a running server, which is how you
point it at a deployment the pipeline does not compose. Its perf subcommand
takes the catalogue root, the ixit topology file (whose environment block is
mandatory: a measurement without the deployment described is not a
measurement), the results.json to merge into, and the class:
veredictum perf --root <catalogue-root> --ixit <ixit.json> \
--results <results.json> --class POC --hours 8
Stress testing — the second instrument
Beside the class runs sits a deliberately different instrument: a step-load stress test. Where a class run holds a real-life, population-anchored rate for at least an hour, the stress test climbs a geometric ladder of short, intense load steps (about two minutes each, doubling the offered rate every step) until the system leaves the stress envelope, then bisects between the last stable rate and the breached one to locate the boundary precisely, the point performance engineering calls the knee of the latency-throughput curve. The headline it finds is the maximum sustainable throughput: the highest offered rate held inside the latency budget (the same idea TPC benchmarks report as maximum qualified throughput).
veredictum stress --root <catalogue-root> --ixit <ixit.json> \
--out <stress.json> --corpus-class POC
The two instruments never blur. A stress report earns no class, never touches
results.json, and carries no class vocabulary at all; the class ladder belongs
to the measured class runs, and the stress chart shows one thing only: where the
system breaks. Their envelopes differ on purpose too: a conformance class demands
a zero error budget, while a stress step allows the small error tolerance standard
load testing works with, because the instrument’s job is to find the knee rather
than to certify anything. Every load step embeds its own re-checkable histograms
and its own resource telemetry (the same per-container CPU/memory/I/O series the
measured runs record, so a breached step shows where it saturated), a breached
step is reported with the exact envelope violation, and a step where the load
generator topped out before the server is flagged as such rather than counted
against the system.
The optimization probe
Between the two verdict-bearing instruments sits a third, purely diagnostic one: the AQL probe. It seeds the same class corpus fresh, fires the measurement machinery’s own AQL set repeatedly, and records each query’s wire-latency percentiles alongside the database-side cost per SQL statement, so an optimization is argued from attributed evidence on a realistically seeded database, never from a hunch on an empty one. Its report is exploration evidence for the optimization loop: it earns nothing and never touches the conformance record.
veredictum aql-probe --root <catalogue-root> --ixit <ixit.json> \
--out <aql-probe.json>
The published assets on this page are rendered from the committed results.json
by the runner’s perf-assets subcommand (wrapped by
scripts/render/perf-assets.sh); the docs CI job re-renders and diffs them, so a
hand-edited or stale asset fails the build.
The storage benchmark harness
A fourth instrument measures the storage layer directly, below the wire:
STORAGE_BENCH_CLASS=poc cargo bench -p ferroehr --bench storage
It seeds a corpus through the ordinary write path, then times the storage layer’s hot paths one at a time: a composition commit, a supersession, the supersession that states the version it replaces, a point read by version uid and one by versioned-object uid, the version at an instant, the revision history, an AQL CONTAINS chain over one EHR and over the whole population, archive and restore of an EHR, and one retention prune.
Beside each operation’s wall-clock it records what the database did for it. The
per-relation tuple counters (inserted, updated, updated in place, dead, live)
and the relation sizes come from pg_stat_user_tables, the buffer hits and
misses from pg_stat_database, and the write-ahead-log bytes from the WAL
position either side of the measurement. One representative commit is probed on
its own, and the population query is explained with
EXPLAIN (ANALYZE, BUFFERS, WAL) inside a transaction that is rolled back, so
the plan shape and its index choices land in the record too.
Everything runs through the service and the public storage API, and the
relations are discovered from the catalogue, so no table name appears anywhere
in the harness and the same file measures a rewritten schema. That is the
point: a storage change is argued from a before-and-after pair taken with one
instrument. It earns no class and touches no conformance artifact; the record’s
shape is documented in docs/benchmarks/storage/README.md. Records are local by
default, and the committed baseline is what this page renders.
Running it against a committed baseline
A dispatch-only workflow (.github/workflows/storage-bench.yml) runs the same
benchmark on a GitHub-hosted runner, against a PostgreSQL 18 service with its
durability left on, and compares every operation with the record committed for
that schema generation. The dispatch picks the corpus class and the tolerance.
The run summary lists each operation with its baseline p50 and p99, the
measured pair, the delta and a verdict, and a p50 or p99 that rose further than
the tolerance fails the run. Both machines are named in the summary, because a
hosted runner is shared hardware and a latency measured there is read against
the box it came from.
That lane is exploration, never a conformance record. It earns no performance
class, publishes no number, and writes nothing back to the repository: the
fresh record leaves as a run artifact, and the committed baseline under
docs/benchmarks/storage/<generation>/ moves only in a pull request that says
which machine measured it. A generation with no committed record is reported as
such and the run passes; a run whose class differs from the baseline’s shows the
deltas and judges nothing, since the two measured different corpora.
Schema generation generation-1, class s
| Operation | Iterations | p50 | p95 | p99 | WAL | Buffer hits |
|---|---|---|---|---|---|---|
create_first_version | 1 200 | 3.6 ms | 5.3 ms | 5.3 ms | 47 MiB | 1 008 028 |
supersede | 870 | 11.2 ms | 14.4 ms | 14.4 ms | 37.5 MiB | 30 795 283 |
supersede_if_match | 850 | 11.4 ms | 14.5 ms | 14.5 ms | 36.5 MiB | 29 699 039 |
by_version_uid | 13 640 | 298.6 µs | 374.6 µs | 374.6 µs | 77.3 KiB | 250 604 |
latest_by_versioned_object_uid | 8 550 | 479.9 µs | 1 ms | 1 ms | 456 B | 121 845 |
version_at_time | 7 260 | 597.3 µs | 640 µs | 640 µs | 136 B | 122 318 |
revision_history | 25 230 | 193.9 µs | 216.6 µs | 216.6 µs | 88 B | 671 513 |
contains_one_ehr | 2 850 | 1.7 ms | 1.8 ms | 1.8 ms | 368 B | 766 648 |
contains_population | 3 440 | 1.1 ms | 1.1 ms | 1.1 ms | 0 B | 1 294 649 |
archive_ehr | 90 | 23.3 ms | 30.3 ms | 30.3 ms | 252.7 MiB | 6 969 118 |
restore_ehr | 160 | 24.9 ms | 69.1 ms | 69.1 ms | 400.5 MiB | 9 276 746 |
retention_prune | 25 650 | 166.7 µs | 185.7 µs | 185.7 µs | 104 B | 35 104 |
One representative commit wrote 2.6 MiB of WAL and touched 88 172 buffers. The population CONTAINS statement plans as Gather and reads 55 817 shared blocks.
| Relation | Live rows | Dead rows | Updates | HOT updates | Size |
|---|---|---|---|---|---|
ehr.audit | 7 188 | 0 | 0 | 0 | 1.9 MiB |
ehr.contribution | 6 988 | 0 | 0 | 0 | 1.3 MiB |
ehr.ehr | 100 | 0 | 0 | 0 | 112 KiB |
ehr.event_outbox | 6 988 | 0 | 0 | 0 | 3.9 MiB |
ehr.node | 296 059 | 0 | 0 | 0 | 271.8 MiB |
ehr.template_ref | 1 | 0 | 0 | 0 | 32 KiB |
ehr.template_store | 1 | 0 | 0 | 0 | 136 KiB |
ehr.vo_archive | 0 | 0 | 0 | 0 | 40 KiB |
ehr.vo_version | 7 088 | 0 | 2 330 | 0 | 30.1 MiB |
Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit 6ebfdb32b304 on 8 cores against PostgreSQL 18.6.
Schema generation generation-2, class poc
| Operation | Iterations | p50 | p95 | p99 | WAL | Buffer hits |
|---|---|---|---|---|---|---|
create_first_version | 530 | 8.5 ms | 9.1 ms | 9.1 ms | 24.2 MiB | 395 240 |
supersede | 470 | 10.3 ms | 11.1 ms | 11.1 ms | 21.6 MiB | 905 767 |
supersede_if_match | 540 | 10 ms | 11.2 ms | 11.2 ms | 24.1 MiB | 1 070 922 |
by_version_uid | 8 290 | 519.5 µs | 538.3 µs | 538.3 µs | 168 B | 213 581 |
latest_by_versioned_object_uid | 8 630 | 534.3 µs | 553.9 µs | 553.9 µs | 120 B | 204 101 |
version_at_time | 4 290 | 990.6 µs | 996 µs | 996 µs | 0 B | 135 299 |
revision_history | 15 730 | 300.1 µs | 312.9 µs | 312.9 µs | 0 B | 437 101 |
contains_one_ehr | 1 140 | 4 ms | 4.2 ms | 4.2 ms | 128 B | 605 955 |
contains_population | 2 270 | 2.1 ms | 2.2 ms | 2.2 ms | 0 B | 620 677 |
flat_2_sibling_leaves | 210 | 21.9 ms | 22.7 ms | 22.7 ms | 112 B | 3 639 915 |
flat_3_sibling_leaves | 190 | 25.5 ms | 26.7 ms | 26.7 ms | 88 B | 3 422 117 |
streaming_2_sibling_leaves | 870 | 5.9 ms | 6.2 ms | 6.2 ms | 0 B | 2 524 699 |
streaming_3_sibling_leaves | 800 | 6.2 ms | 6.3 ms | 6.3 ms | 0 B | 2 580 569 |
contains_exists | 580 | 8.4 ms | 8.5 ms | 8.5 ms | 0 B | 6 860 724 |
node_predicate | 1 240 | 4 ms | 4.2 ms | 4.2 ms | 0 B | 2 130 540 |
where_magnitude | 110 | 46.4 ms | 47.3 ms | 47.3 ms | 0 B | 3 399 817 |
where_coded | 200 | 23.4 ms | 25.8 ms | 25.8 ms | 0 B | 3 495 572 |
where_date_time | 1 070 | 4.5 ms | 4.6 ms | 4.6 ms | 0 B | 1 884 349 |
where_text | 210 | 23.2 ms | 24.4 ms | 24.4 ms | 0 B | 3 628 755 |
wide_projection_one_ehr | 100 | 53.2 ms | 55.3 ms | 55.3 ms | 56 B | 464 725 |
wide_projection_population | 180 | 29.2 ms | 32.9 ms | 32.9 ms | 0 B | 3 761 922 |
select_observation | 760 | 6.5 ms | 6.8 ms | 6.8 ms | 0 B | 1 804 991 |
select_composition_one_ehr | 860 | 5.5 ms | 6.7 ms | 6.7 ms | 0 B | 474 289 |
aql_version_at_time | 1 220 | 4 ms | 4.2 ms | 4.2 ms | 0 B | 1 827 733 |
archive_ehr | 30 | 91.4 ms | 101.2 ms | 101.2 ms | 52.9 MiB | 1 038 199 |
restore_ehr | 30 | 106.1 ms | 108.4 ms | 108.4 ms | 52.7 MiB | 1 121 364 |
retention_prune | 23 420 | 211.1 µs | 236.3 µs | 236.3 µs | 8.3 KiB | 33 082 |
One representative commit wrote 305 KiB of WAL and touched 3 903 buffers. The population CONTAINS statement plans as Nested Loop and reads 43 224 shared blocks.
| Relation | Live rows | Dead rows | Updates | HOT updates | Size |
|---|---|---|---|---|---|
clinical.commit_audit | 2 324 | 0 | 0 | 0 | 632 KiB |
clinical.contribution | 2 284 | 0 | 0 | 0 | 584 KiB |
clinical.ehr | 20 | 0 | 0 | 0 | 56 KiB |
clinical.event_outbox | 2 284 | 0 | 0 | 0 | 1.4 MiB |
clinical.node_cold | 0 | 0 | 0 | 0 | 88 KiB |
clinical.node_hot | 100 332 | 0 | 0 | 0 | 81.4 MiB |
clinical.template_ref | 2 | 0 | 0 | 0 | 32 KiB |
clinical.template_store | 2 | 0 | 0 | 0 | 192 KiB |
clinical.version_cold | 0 | 0 | 0 | 0 | 120 KiB |
clinical.version_hot | 2 304 | 0 | 0 | 0 | 8.8 MiB |
clinical.vo_head | 1 020 | 60 | 3 948 | 3 948 | 312 KiB |
Measured over 20 EHRs and 300 compositions of template Vital signs at commit c82f0992d308 on 4 cores against PostgreSQL 18.6.
Schema generation generation-2, class s
| Operation | Iterations | p50 | p95 | p99 | WAL | Buffer hits |
|---|---|---|---|---|---|---|
create_first_version | 510 | 9.7 ms | 9.9 ms | 9.9 ms | 110.9 MiB | 470 378 |
supersede | 500 | 9.9 ms | 10.4 ms | 10.4 ms | 22.8 MiB | 1 034 920 |
supersede_if_match | 540 | 10 ms | 10.4 ms | 10.4 ms | 24.4 MiB | 1 151 542 |
by_version_uid | 8 700 | 488.6 µs | 493.7 µs | 493.7 µs | 2.8 KiB | 222 174 |
latest_by_versioned_object_uid | 9 360 | 489.2 µs | 512.2 µs | 512.2 µs | 128 B | 218 149 |
version_at_time | 4 490 | 946.8 µs | 992.3 µs | 992.3 µs | 56 B | 146 116 |
revision_history | 16 450 | 287.8 µs | 300.8 µs | 300.8 µs | 0 B | 453 104 |
contains_one_ehr | 1 240 | 3.6 ms | 3.7 ms | 3.7 ms | 136 B | 658 178 |
contains_population | 1 740 | 3 ms | 3 ms | 3 ms | 0 B | 1 020 037 |
flat_2_sibling_leaves | 60 | 92.3 ms | 95.9 ms | 95.9 ms | 16.4 KiB | 33 018 094 |
flat_3_sibling_leaves | 60 | 95.9 ms | 97.8 ms | 97.8 ms | 0 B | 33 030 979 |
streaming_2_sibling_leaves | 660 | 7.6 ms | 7.9 ms | 7.9 ms | 0 B | 2 398 276 |
streaming_3_sibling_leaves | 630 | 8 ms | 8.1 ms | 8.1 ms | 0 B | 2 137 922 |
contains_exists | 170 | 30.2 ms | 34.2 ms | 34.2 ms | 0 B | 11 007 303 |
node_predicate | 930 | 5.4 ms | 5.5 ms | 5.5 ms | 0 B | 2 127 292 |
where_magnitude | 50 | 120.1 ms | 121.4 ms | 121.4 ms | 0 B | 5 504 663 |
where_coded | 720 | 6.9 ms | 7.2 ms | 7.2 ms | 0 B | 2 269 954 |
where_date_time | 820 | 6.1 ms | 6.2 ms | 6.2 ms | 0 B | 1 917 884 |
where_text | 730 | 6.9 ms | 7 ms | 7 ms | 0 B | 2 293 234 |
wide_projection_one_ehr | 50 | 114.2 ms | 115.6 ms | 115.6 ms | 0 B | 1 977 924 |
wide_projection_population | 380 | 13.3 ms | 13.7 ms | 13.7 ms | 88 B | 2 326 270 |
select_observation | 620 | 7.9 ms | 8 ms | 8 ms | 0 B | 1 607 057 |
select_composition_one_ehr | 600 | 8.2 ms | 8.5 ms | 8.5 ms | 0 B | 683 661 |
aql_version_at_time | 800 | 6.2 ms | 6.3 ms | 6.3 ms | 0 B | 6 498 839 |
archive_ehr | 20 | 173 ms | 181.1 ms | 181.1 ms | 68 MiB | 1 320 254 |
restore_ehr | 20 | 204.5 ms | 207.9 ms | 207.9 ms | 60.6 MiB | 1 298 376 |
retention_prune | 23 980 | 207.7 µs | 209 µs | 209 µs | 2.1 MiB | 73 258 |
One representative commit wrote 297.2 KiB of WAL and touched 960 buffers. The population CONTAINS statement plans as Nested Loop and reads 42 257 shared blocks.
| Relation | Live rows | Dead rows | Updates | HOT updates | Size |
|---|---|---|---|---|---|
clinical.commit_audit | 5 486 | 0 | 0 | 0 | 1.5 MiB |
clinical.contribution | 5 286 | 0 | 0 | 0 | 960 KiB |
clinical.ehr | 100 | 0 | 0 | 0 | 152 KiB |
clinical.event_outbox | 5 286 | 0 | 0 | 0 | 2.9 MiB |
clinical.node_cold | 0 | 0 | 0 | 0 | 232 KiB |
clinical.node_hot | 252 462 | 0 | 0 | 0 | 167.6 MiB |
clinical.template_ref | 2 | 0 | 0 | 0 | 32 KiB |
clinical.template_store | 2 | 0 | 0 | 0 | 192 KiB |
clinical.version_cold | 0 | 0 | 0 | 0 | 144 KiB |
clinical.version_hot | 5 334 | 782 | 0 | 0 | 16.3 MiB |
clinical.vo_head | 3 940 | 13 | 4 430 | 4 430 | 936 KiB |
Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit c82f0992d308 on 4 cores against PostgreSQL 18.6.
Schema generation generation-3, class poc
| Operation | Iterations | p50 | p95 | p99 | WAL | Buffer hits |
|---|---|---|---|---|---|---|
create_first_version | 840 | 5.8 ms | 14.6 ms | 14.6 ms | 34.8 MiB | 648 612 |
supersede | 610 | 11.4 ms | 18.9 ms | 18.9 ms | 23 MiB | 13 048 343 |
supersede_if_match | 640 | 12.3 ms | 21.6 ms | 21.6 ms | 28 MiB | 19 143 656 |
by_version_uid | 9 920 | 418.8 µs | 439.7 µs | 439.7 µs | 472 B | 245 293 |
latest_by_versioned_object_uid | 11 710 | 404.2 µs | 428.5 µs | 428.5 µs | 0 B | 301 532 |
version_at_time | 4 940 | 874.4 µs | 886.1 µs | 886.1 µs | 0 B | 151 435 |
revision_history | 18 710 | 206.6 µs | 248.5 µs | 248.5 µs | 0 B | 502 662 |
contains_one_ehr | 1 410 | 3.5 ms | 3.6 ms | 3.6 ms | 7 KiB | 859 578 |
contains_population | 2 690 | 1.8 ms | 1.9 ms | 1.9 ms | 8.1 KiB | 824 801 |
flat_2_sibling_leaves | 230 | 19.4 ms | 20 ms | 20 ms | 42.4 KiB | 5 239 470 |
flat_3_sibling_leaves | 210 | 23.4 ms | 23.8 ms | 23.8 ms | 0 B | 4 934 788 |
streaming_2_sibling_leaves | 980 | 5.1 ms | 5.3 ms | 5.3 ms | 3.6 KiB | 2 960 620 |
streaming_3_sibling_leaves | 900 | 5.5 ms | 5.7 ms | 5.7 ms | 0 B | 3 007 660 |
contains_exists | 470 | 10.5 ms | 11.1 ms | 11.1 ms | 0 B | 8 638 704 |
node_predicate | 1 380 | 3.6 ms | 3.7 ms | 3.7 ms | 0 B | 2 969 000 |
where_magnitude | 110 | 47.7 ms | 48.7 ms | 48.7 ms | 0 B | 5 012 010 |
where_coded | 230 | 22.1 ms | 23.1 ms | 23.1 ms | 0 B | 5 209 927 |
where_date_time | 1 210 | 4.1 ms | 4.2 ms | 4.2 ms | 0 B | 2 292 883 |
where_text | 230 | 22.4 ms | 23.3 ms | 23.3 ms | 0 B | 5 210 914 |
wide_projection_one_ehr | 120 | 42.1 ms | 42.9 ms | 42.9 ms | 0 B | 526 091 |
wide_projection_population | 190 | 27.3 ms | 28.6 ms | 28.6 ms | 0 B | 5 064 088 |
select_observation | 850 | 5.7 ms | 5.8 ms | 5.8 ms | 0 B | 2 121 054 |
select_composition_one_ehr | 990 | 4.8 ms | 5 ms | 5 ms | 0 B | 551 999 |
aql_version_at_time | 1 450 | 3.4 ms | 3.6 ms | 3.6 ms | 0 B | 2 565 200 |
archive_ehr | 50 | 51.3 ms | 54.2 ms | 54.2 ms | 87.7 MiB | 1 896 225 |
restore_ehr | 50 | 64.2 ms | 65.2 ms | 65.2 ms | 77.1 MiB | 1 945 710 |
retention_prune | 40 630 | 118.4 µs | 121.5 µs | 121.5 µs | 120 B | 50 499 |
One representative commit wrote 532.7 KiB of WAL and touched 1 322 buffers. The population CONTAINS statement plans as Nested Loop and reads 17 067 shared blocks.
| Relation | Live rows | Dead rows | Updates | HOT updates | Size |
|---|---|---|---|---|---|
clinical.commit_audit | 3 130 | 0 | 0 | 0 | 872 KiB |
clinical.contribution | 3 090 | 0 | 0 | 0 | 640 KiB |
clinical.ehr | 20 | 0 | 0 | 0 | 64 KiB |
clinical.event_outbox | 3 090 | 0 | 0 | 0 | 1.8 MiB |
clinical.node_cold | 0 | 0 | 0 | 0 | 88 KiB |
clinical.node_hot | 134 990 | 0 | 0 | 0 | 89.3 MiB |
clinical.template_ref | 2 | 0 | 0 | 0 | 32 KiB |
clinical.template_store | 2 | 0 | 0 | 0 | 192 KiB |
clinical.version_cold | 0 | 0 | 0 | 0 | 136 KiB |
clinical.version_hot | 3 110 | 0 | 0 | 0 | 12.7 MiB |
clinical.vo_head | 1 458 | 48 | 6 332 | 6 332 | 504 KiB |
Measured over 20 EHRs and 300 compositions of template Vital signs at commit 59b91af928a4 on 4 cores against PostgreSQL 18.6.
Schema generation generation-3, class s
| Operation | Iterations | p50 | p95 | p99 | WAL | Buffer hits |
|---|---|---|---|---|---|---|
create_first_version | 560 | 7.6 ms | 8.6 ms | 8.6 ms | 21.6 MiB | 475 947 |
supersede | 520 | 12.9 ms | 16.4 ms | 16.4 ms | 21.6 MiB | 10 171 802 |
supersede_if_match | 520 | 13 ms | 16.7 ms | 16.7 ms | 20.1 MiB | 9 750 360 |
by_version_uid | 9 210 | 480 µs | 495 µs | 495 µs | 2.2 KiB | 226 321 |
latest_by_versioned_object_uid | 9 340 | 477.9 µs | 486.9 µs | 486.9 µs | 464 B | 224 344 |
version_at_time | 4 510 | 943.2 µs | 1 ms | 1 ms | 0 B | 140 807 |
revision_history | 17 290 | 268.1 µs | 271.6 µs | 271.6 µs | 0 B | 471 470 |
contains_one_ehr | 1 250 | 3.6 ms | 3.9 ms | 3.9 ms | 0 B | 695 743 |
contains_population | 1 680 | 3 ms | 3.1 ms | 3.1 ms | 15.8 KiB | 1 115 539 |
flat_2_sibling_leaves | 140 | 33.9 ms | 38.2 ms | 38.2 ms | 16.2 KiB | 4 120 479 |
flat_3_sibling_leaves | 140 | 37.9 ms | 39 ms | 39 ms | 232 B | 4 152 775 |
streaming_2_sibling_leaves | 580 | 8.5 ms | 9.3 ms | 9.3 ms | 0 B | 2 007 543 |
streaming_3_sibling_leaves | 580 | 8.7 ms | 8.8 ms | 8.8 ms | 0 B | 2 152 855 |
contains_exists | 200 | 25.6 ms | 26.5 ms | 26.5 ms | 0 B | 9 295 830 |
node_predicate | 830 | 6 ms | 6.1 ms | 6.1 ms | 0 B | 2 174 425 |
where_magnitude | 50 | 105.2 ms | 106 ms | 106 ms | 0 B | 4 729 616 |
where_coded | 780 | 6.2 ms | 6.6 ms | 6.6 ms | 0 B | 1 888 942 |
where_date_time | 750 | 6.8 ms | 7.1 ms | 7.1 ms | 0 B | 2 001 044 |
where_text | 810 | 6.2 ms | 6.3 ms | 6.3 ms | 0 B | 1 943 864 |
wide_projection_one_ehr | 50 | 110 ms | 111.3 ms | 111.3 ms | 0 B | 1 950 208 |
wide_projection_population | 410 | 12.4 ms | 12.5 ms | 12.5 ms | 0 B | 2 101 466 |
select_observation | 560 | 8.7 ms | 9 ms | 9 ms | 0 B | 1 619 828 |
select_composition_one_ehr | 600 | 8.4 ms | 8.7 ms | 8.7 ms | 0 B | 685 119 |
aql_version_at_time | 710 | 6.4 ms | 7.2 ms | 7.2 ms | 0 B | 5 900 107 |
archive_ehr | 20 | 138.1 ms | 141 ms | 141 ms | 65.3 MiB | 1 558 971 |
restore_ehr | 20 | 170.1 ms | 173.7 ms | 173.7 ms | 59.2 MiB | 1 516 172 |
retention_prune | 27 760 | 177.4 µs | 180.1 µs | 180.1 µs | 1.2 MiB | 71 274 |
One representative commit wrote 107.4 KiB of WAL and touched 3 784 buffers. The population CONTAINS statement plans as Nested Loop and reads 49 471 shared blocks.
| Relation | Live rows | Dead rows | Updates | HOT updates | Size |
|---|---|---|---|---|---|
clinical.commit_audit | 5 484 | 0 | 0 | 0 | 1.5 MiB |
clinical.contribution | 5 284 | 0 | 0 | 0 | 1.1 MiB |
clinical.ehr | 100 | 0 | 0 | 0 | 96 KiB |
clinical.event_outbox | 5 284 | 0 | 0 | 0 | 3 MiB |
clinical.node_cold | 0 | 0 | 0 | 0 | 232 KiB |
clinical.node_hot | 254 612 | 0 | 0 | 0 | 114.1 MiB |
clinical.template_ref | 2 | 0 | 0 | 0 | 32 KiB |
clinical.template_store | 2 | 0 | 0 | 0 | 192 KiB |
clinical.version_cold | 0 | 0 | 0 | 0 | 160 KiB |
clinical.version_hot | 5 384 | 0 | 0 | 0 | 18.1 MiB |
clinical.vo_head | 3 990 | 104 | 4 958 | 4 958 | 968 KiB |
Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit 59b91af928a4 on 4 cores against PostgreSQL 18.6.
The latest measured run
The per-operation percentiles below are re-derived at build time from the committed HDR V2 histograms for the proof-of-concept class:
What the run cost the machine
Alongside the latencies, every measured run records its resource telemetry: CPU and resident memory for the server and database containers separately, plus block-device and network I/O, sampled at a fixed interval across the whole window with the warmup shaded. These numbers are capacity-planning context; they never influence whether a class is earned.
The database volume’s on-disk size is probed at four anchors: before the scale seed (the empty baseline), after it, after the standing-ward seed, and after the measured window drained. The first two give the storage cost per committed composition; the last two give the sustained load’s write amplification. An anchor that could not be probed is honestly absent rather than guessed:
Both charts render only from a committed measurement record; nothing on this page is ever mocked.
| Class | Corpus | Offered-load floor | p99 budget | Error budget | Measured sustained | Verdict |
|---|---|---|---|---|---|---|
| POC | cnf.scale.10k | 2/s | ≤ 1000 ms | 0 | 2.0/s | EARNED |
| S | cnf.scale.100k | 15/s | ≤ 1000 ms | 0 | — | not measured |
| L | cnf.scale.1m | 150/s | ≤ 1000 ms | 0 | — | not measured |
| R | cnf.scale.10m | 1500/s | ≤ 1000 ms | 0 | — | not measured |
Measured run PERF-hospital_sim-class_POC — class POC, offered load 2.04/s sustained over 3600 s (after 300 s warmup), environment: consumer-laptop (8 cores, 16 GB, nvme, single-node docker compose (8-CPU/8GB Docker VM) on Apple M2, the SMART resource-server posture (docker/sut-smart.yml overlays the base stack) with the external-terminology profile composed beside it (a seeded HAPI FHIR R4 server, docker compose –profile terminology + docker/sut-terminology.yml, fail-open); alongside it a second deployment of the same image in the openPGP version-signing posture (project ferroehr-cnf-pgp, docker/sut-signing-pgp.yml + docker/sut-terminology-failclosed.yml + docker/sut-pgp-parallel.yml, host port 8081) declared as the sut_pgp instance, which carries the fail-closed terminology posture — the measured-performance stage drives the primary deployment alone).
| Operation | Requests | Errors | p50 (ms) | p90 (ms) | p99 (ms) |
|---|---|---|---|---|---|
| adhoc_query | 792 | 0 | 29 | 38 | 64 |
| admin_contribution_report | 60 | 0 | 159 | 176 | 269 |
| analytics_query | 23 | 0 | 62 | 76 | 197 |
| archetype_adl2_list | 60 | 0 | 13 | 18 | 28 |
| composition_commit | 280 | 0 | 50 | 106 | 214 |
| composition_commit_flat | 7 | 0 | 42 | 53 | 53 |
| composition_delete | 4 | 0 | 28 | 35 | 35 |
| composition_read | 1583 | 0 | 24 | 33 | 66 |
| composition_read_current | 896 | 0 | 27 | 36 | 57 |
| composition_read_flat | 7 | 0 | 17 | 25 | 25 |
| composition_revision_history | 889 | 0 | 14 | 21 | 31 |
| composition_update | 68 | 0 | 46 | 67 | 239 |
| composition_version_read | 30 | 0 | 28 | 35 | 49 |
| contribution_commit | 48 | 0 | 54 | 138 | 187 |
| contribution_read | 82 | 0 | 20 | 28 | 60 |
| directory_create | 12 | 0 | 25 | 40 | 86 |
| directory_read | 803 | 0 | 17 | 24 | 40 |
| directory_update | 12 | 0 | 28 | 34 | 42 |
| ehr_create | 12 | 0 | 28 | 32 | 33 |
| ehr_extract_export | 792 | 0 | 149 | 182 | 268 |
| ehr_read | 72 | 0 | 18 | 25 | 37 |
| ehr_status_read | 24 | 0 | 16 | 23 | 25 |
| ehr_status_update | 24 | 0 | 21 | 36 | 55 |
| party_create | 6 | 0 | 22 | 28 | 28 |
| party_read | 6 | 0 | 16 | 24 | 24 |
| party_relationship_create | 6 | 0 | 26 | 30 | 30 |
| party_relationship_read | 6 | 0 | 11 | 17 | 17 |
| party_update | 6 | 0 | 15 | 27 | 27 |
| readonly_write_denied | 7 | 0 | 30 | 46 | 46 |
| smart_configuration_read | 7 | 0 | 15 | 24 | 24 |
| stored_query_execute | 180 | 0 | 41 | 55 | 103 |
| system_options | 7 | 0 | 10 | 18 | 18 |
| tags_put | 30 | 0 | 24 | 33 | 41 |
| tags_read | 30 | 0 | 17 | 26 | 38 |
| tdd_import | 7 | 0 | 35 | 60 | 60 |
| template_adl2_list | 61 | 0 | 13 | 19 | 54 |
| template_example | 60 | 0 | 30 | 102 | 261 |
| template_get | 60 | 0 | 64 | 134 | 240 |
| template_list | 60 | 0 | 53 | 80 | 90 |
| terminology_query | 23 | 0 | 19 | 26 | 73 |
| unauthenticated_probe | 7 | 0 | 18 | 23 | 23 |
| ward_query | 180 | 0 | 28 | 38 | 71 |
Resources (measured context, never a verdict input) — sampled every 10 s; CPU/RSS derived over the measured phase:
| Container | CPU mean | CPU peak | RSS peak |
|---|---|---|---|
sut ferroehr-cnf-ferroehr-1 | 3.6% | 7.7% | 218 MB |
db ferroehr-cnf-ferroehr-postgres-1 | 5.9% | 21.0% | 1.5 GB |
Disk anchors: empty 133 MB → after scale seed 13 GB (≈ 13 KB / composition over 1,000,000 committed) → after ward seed 13 GB → after window 13 GB.
Benchmarks
FerroEHR measures performance with the same instrument family that measures conformance: Veredictum, the separate CNF 2.0 runner described on Conformance. There is no separate benchmark harness: every published number is measured by a committed, re-runnable instrument, reported in both directions, and regenerated from committed artefacts. There is no chart in this project that you cannot regenerate yourself with one command.
The three instruments
- Measured class runs (the runner’s
perfsubcommand): conformance by measurement. The open-loop hospital-simulation workload holds a population-anchored offered-load floor for the normative hour (or an extended hold up to twelve hours), and the volumetric deployment class is earned or not from the committed record. Every measurement embeds re-checkable HDR histograms and per-container resource telemetry. See Performance. - The step-load stress ladder (the
stresssubcommand): exploration. The same workload at geometrically climbing rates until the system leaves the envelope, then bisection to the maximum sustainable throughput (the knee of the latency-throughput curve). Each rung embeds its own histograms and resource telemetry; a rung where the load generator fell behind is flagged generator-bound, never counted against the server. A stress report earns no class and never touches the conformance record. - The AQL probe (the
aql-probesubcommand): diagnosis. The instrument’s AQL set fired repeatedly against a freshly seeded corpus, with wire-latency percentiles and the database-side cost attributed per SQL statement. The optimization loop’s entry point; exploration evidence only.
What the workload simulates
All three instruments drive the same hospital simulation: clinical journeys (admissions, shift vitals, medication rounds, laboratory results arriving asynchronously, chart reviews, AQL ward dashboards, corrections, discharges) expanded from a committed journey catalogue onto an open-loop arrival schedule, with payloads built from official openEHR CKM templates vendored with provenance. Every stage is its own planned arrival instant, so latency is measured from the planned time (coordinated-omission-corrected) and a stalled server cannot hide. The full workload story lives in Performance.
Running the instruments
# the measured class run
veredictum perf --root <perf-root> --ixit <ixit.json> --class POC --results <measured.json>
# the step-load stress ladder (fresh compose + seed, then the climb)
veredictum stress --root <catalogue-root> --ixit <ixit.json> --out <stress.json>
# the AQL optimization probe
veredictum aql-probe --root <catalogue-root> --ixit <ixit.json> --out <aql-probe.json>
Every instrument seeds a freshly composed, empty server through the public API and
the stack is torn down afterwards; there is no seed reuse, so no run ever
measures another run’s leftovers. Committed records land under
docs/conformance/ferroehr/; the published charts regenerate from them
(scripts/render/perf-assets.sh) and are diff-guarded in CI.
Measurement rules
The measurement methodology is enforced by construction:
- The same runner measures every run against the same committed catalogue and the same ladder, each on its own freshly composed stack with the committed party statement.
- Database maintenance is settled deterministically before every measured window, so no run pays another’s maintenance debt or trips an autovacuum mid-measurement.
- Rate limiting is off for measurement runs, because the instruments deliberately offer load past the knee; both instruments refuse to write a record if the server answered any throttled request.
- Configuration differences are explicit and labelled, never quiet. Any setting raised or lowered for a measurement is a visible part of the composed posture, which is why the postures live in committed compose files rather than in a shell variable someone may or may not have exported.
Rust crates
FerroEHR’s openEHR specification layer is published on crates.io as nine standalone Rust crates, usable without the CDR. They are the same crates the server itself is built on: the spec types are generated deterministically from the official openEHR machine-readable artifacts (BMM/XSD/OpenAPI), and the engines (ADL, AQL, Simplified Formats, serialization runtimes) are hand-written against the vendored specification text.
| Crate | Implements | What it gives you |
|---|---|---|
openehr-base | BASE 1.2.0 + 1.3.0 | Foundation + base types (identification, intervals, ISO-8601 types) |
openehr-rm | RM 1.1.0 + 1.2.0 | The Reference Model: COMPOSITION, data structures/types, change control, generated invariant validation, the static RM attribute model |
openehr-am | AM 1.4.0 + 2.4.0 | The Archetype Object Model, both majors side by side |
openehr-adl | ADL 2.4.0 | ADL2/cADL/ODIN parser, AOM2 validation, flattener, OPT2, ADL 1.4→2 conversion |
openehr-term | TERM 3.1.0 | Terminology model + the embedded official openEHR terminology (five languages: en, es, ja, pt, zh) |
openehr-lang | LANG 1.0.0 + 1.1.0 | The BMM meta-model and its P_BMM schema form, plus hand-written ODIN, BEL and Expression-Language readers |
openehr-query | QUERY 1.1.0 | AQL lexer, parser, typed AST with source spans, visitors, parameter binding, and canonical printer; the federation feature for the FROM ENDPOINT directive |
openehr-its | ITS-JSON, ITS-XML, ITS-REST 1.1.0 | Canonical JSON + XML codecs, the generated ITS-REST contract (axum server router, client, and an operation matcher for intermediaries), OPT 1.4 and AOM2 archetype XML |
openehr-sdt | ITS-REST 1.1.0 Simplified Formats + SMART App Launch | Simplified Formats (FLAT/STRUCTURED/Web Template), a canonical PARTY built from a party Web Template, template-independent RM-instance validation, the SMART on openEHR scope grammar |
[dependencies]
openehr-rm = "0.0.98"
openehr-its = "0.0.98"
All nine are edition 2024 with an MSRV of Rust 1.98, and all nine
inherit the workspace lint table, including unsafe_code = "forbid", which no
attribute anywhere in the crate can relax. There is no unsafe block in the
published specification layer.
- Generations: reaching more than one specification version
- Versioning
- Taking only the part of
openehr-itsandopenehr-sdtyou need - Releases
- Information for integrators (CRA Annex II point 8(f))
- Licensing
Generations: reaching more than one specification version
A crate generated from more than one openEHR generation exposes each as a
version-named module: openehr_rm::v1_1 and openehr_rm::v1_2,
openehr_base::v1_2 / v1_3, openehr_am::v1_4 / v2_4,
openehr_lang::v1_0 / v1_1, openehr_term::v3_1. The crate’s prelude
re-exports the current generation, so ordinary code needs no module path;
an older generation is reached by naming its module in full:
#![allow(unused)]
fn main() {
// The current generation (RM 1.2.0), via the crate prelude:
use openehr_rm::prelude::Composition;
// The released generation (RM 1.1.0), via its own module path. Import
// renaming is not used anywhere in this project; where both generations
// appear in one file, give one of them a type alias:
type Rm110Composition = openehr_rm::v1_1::composition::composition::Composition;
}
Each generation mirrors its specification’s own package structure, so the path
after the generation module reads the same in both. Every generation module
also carries its own prelude; no name from one generation is ever mixed into
another.
openehr-lang is the one crate whose generations also differ in what they
contain, because upstream publishes two BMM meta-models side by side. Its
v1_1 generation carries them as sibling specification units: the stable,
tool-implemented BMM v2.x model (bmm, its persistence form
bmm_persistence, and the beom expression model) is on the generation’s
prelude, while the paused BMM3 model (bmm3) is reachable only by full module
path. They cannot be merged (a set of class names, BmmClass, BmmModel and
BmmPackage among them, occurs in both units with materially different
shapes) so the prelude carries the stable units and the choice stays explicit
at the use site. The older v1_0 generation is what its release actually
defines: the BMM model plus an ODIN reader, with no BEL or Expression-Language
notation.
Versioning
The package version is the crates’ own independent SemVer line: it tracks
this implementation’s code and moves freely with fixes and improvements, never
with the vendored openEHR specification versions. While the line is 0.0.x,
expect breaking changes between releases, which always ship in lockstep across
all nine crates.
The implemented specification version is therefore a separate datum, per
generation. Each generated crate emits a Generation enum that is the only
authority for it: one variant per generation module, Default marking the
current one, each variant carrying its specification version as a const fn,
and Display/FromStr round-tripping the module token:
#![allow(unused)]
fn main() {
assert_eq!(openehr_rm::Generation::default().spec_version(), "1.2.0");
assert_eq!(openehr_rm::Generation::V1_1.spec_version(), "1.1.0");
assert_eq!(openehr_rm::Generation::default().as_str(), "v1_2");
}
There is deliberately no crate-level SPEC_VERSION constant in the generated
crates: a single constant would contradict a caller using a non-current
generation. Exactly four crates implement one specification each and do expose
one: openehr_its::SPEC_VERSION, openehr_sdt::SPEC_VERSION,
openehr_query::SPEC_VERSION, openehr_adl::SPEC_VERSION.
Taking only the part of openehr-its and openehr-sdt you need
openehr-its layers its features so a browser or embedded consumer does not
have to compile an HTTP server to read a template. The parsing spine is
json → xml → opt14, each pulling in the one below it. Beside the spine
sit schema-validation (validation against the compiled-in ITS-JSON RM
schema, its only jsonschema user) and three ITS-REST features. rest is the
generated contract (DTOs, parameter structs, route tables and the ApiError
type, plus the routes::lookup operation matcher) over serde and http, with no HTTP engine. rest-server adds the
per-group server traits, their success enums and a generated router(api) per group, which is what brings
in axum. rest-client adds a generated client per API group over a runtime
that sends through reqwest: each operation answers one outcome variant per
status the OpenAPI documents, and an undocumented status is an error. A
consumer that calls a CDR takes rest-client and never compiles axum. The
default feature full is all seven, so an existing dependency line keeps the
crate it had. With default-features = false and no feature at all,
openehr-its compiles empty.
openehr-sdt sits on top: its flat feature pulls openehr-its with opt14
and adds the Simplified Formats and RM-instance validation, and cache adds
the async Web Template cache (the only moka user). Its default full is
both. opt14 and flat build for wasm32-unknown-unknown, so a browser
consumer can call flat::webtemplate::builder::build_web_template or read an
OPT 1.4 template:
[dependencies]
openehr-its = { version = "0.0.98", default-features = false, features = ["opt14"] }
openehr-sdt = { version = "0.0.98", default-features = false, features = ["flat"] }
With default-features = false and no feature at all, openehr-sdt compiles
to the SMART App Launch scope grammar alone: std-only, with no dependency of
any kind, so a REST client can parse scope strings with the grammar the CDR
enforces instead of carrying a second one.
Releases
The nine crates publish as the last leg of the release pipeline, gated on a human: the leg runs in a protected environment with a required reviewer, so the run pauses there and nothing reaches crates.io without an explicit approval. A separate dispatch lane covers a publish between releases and the recovery path when the release leg fails.
Both lanes authenticate with crates.io Trusted Publishing (OIDC, no
long-lived token anywhere) and publish the crates one at a time in
dependency order, treating “already exists on the index” as done, so a run
interrupted halfway can be re-run to finish the set. The lane then reads the
registry back and refuses to report success unless all nine resolve at the
same version, because while the line is 0.0.x a straggler makes its
siblings’ internal requirements unresolvable for every consumer.
Information for integrators (CRA Annex II point 8(f))
The crates are libraries for building into other software. The Cyber Resilience Act (Regulation (EU) 2024/2847) has a product intended for integration into other products accompanied by “the information necessary for the integrator to comply with the essential cybersecurity requirements set out in Annex I and the documentation requirements set out in Annex VII” (Annex II point 8(f)). This section is that information for the nine crates. The rest of the information Annex II lists for FerroEHR is on CRA information and instructions to the user; the manufacturer and the reporting channels there apply to the crates too.
- Reporting a vulnerability in a crate: to Cadasto B.V., through GitHub
private vulnerability reporting or info@cadasto.com,
as
SECURITY.mddescribes. - Hearing of a fixed vulnerability: every fixed vulnerability that reaches a published crate gets a GitHub security advisory on the repository, naming the affected and the fixed crate versions. An advisory that names a published crate also reaches the GitHub Advisory Database in OSV format. Watch the advisories; nothing in the crates contacts anyone.
- Support period: only the newest published version of all nine is
supported. Cadasto B.V. commits to keep publishing security fixes for at
least five years after each version (CRA Art 13(8)), and the fix ships only
in the newest published version, never as a backport (Art 13(10))
(
SECURITY.md§ Supported versions). While the line is0.0.x, the version that carries a fix may also carry a breaking change (see Versioning), so plan for the work of taking the newest version when you set the support period of your own product. - Software bill of materials: each release publishes a CycloneDX SBOM per
crate, listing its normal and build dependencies, attested against the
.cratearchive crates.io serves (the SBOMs). Annex I Part II(1) has you draw up an SBOM of your own product “covering at the very least the top-level dependencies”; the crate SBOMs give you what each crate pulls in below that. - Where the crates come from: crates.io Trusted Publishing authenticates
the upload, and records no build provenance. The source of truth is this
repository at the release tag
(the
openehr-*crates).
What the crates do for the security of your product, and what they leave to it:
- Memory safety.
unsafe_code = "forbid"holds in all nine crates; there is nounsafeblock in them. - Parsers. The canonical JSON and XML readers, the AQL parser, the ADL parser, the OPT 1.4 reader, the simplified formats and the identifier types each have a libFuzzer harness run on a nightly campaign, and a crash is fixed in the crate. Fuzzing finds crashes and does not prove their absence. Bound the size of what you hand a parser, and the time you let it run, in your own product: the FerroEHR server does this with its request body limits and timeouts.
- The REST contract. The
rest-serverrouter ofopenehr-itsdispatches each request to the server traits you implement and authenticates no one. Authentication, authorisation, rate limiting, TLS and an access log are layers your product adds. Therest-clientengine sends over rustls TLS, never follows a redirect, and takes areqwest::ClientBuilderyou configure with your trust roots and proxies. - The SMART scope grammar in
openehr-sdtparses scope strings. It decides no access; enforcing a scope is your product’s. - No storage, no network of their own. The crates write nothing to disk;
openehr-langreads the BMM schema files from the directory your code names. They open no connection except the requests your code sends throughrest-client.
For the documentation of Annex VII: the crates are third-party components of
your product, which CRA Art. 13(5) has you integrate with due diligence. Your
technical documentation names the crate versions you build (the
Cargo.lock records them) and your risk assessment covers how your product
uses them. The FerroEHR CRA risk assessment
assesses the FerroEHR release and covers the crates only where a row says so;
it is not an assessment of your product.
Licensing
The nine crates fall into two groups.
The six generated-or-wire crates (openehr-base, openehr-rm, openehr-am,
openehr-lang, openehr-term, openehr-its) and the AQL parser
openehr-query are Apache-2.0, the
licence of the openEHR machine-readable artifacts they are generated from, so
any Rust project can use them, in proprietary and hosted products included.
They embed material derived from those artifacts (generated types carrying
specification documentation text, codecs and a REST contract generated from
the XSD, OpenAPI and BMM files), which is Apache-2.0 as well, and their files
name the openEHR Foundation as a second copyright holder. openehr-its also
carries the hand-written runtimes, canonical JSON entry points and
wire-validation dispatcher the generated code cannot ship without, and it
packages the vendored ITS-JSON schema as bytes, so its README carries that
file’s attribution (upstream repository, the exact vendored commit, and the
licence) inside the package, where it travels with any redistribution.
openehr-query is hand-written from openEHR’s Apache-2.0 AQL grammar and
ships only its Rust sources, the README and LICENSE-APACHE-2.0.
The two hand-written engines, openehr-adl and openehr-sdt, are under the Business Source License 1.1, the licence of
the FerroEHR application. Each declares BUSL-1.1 and ships only its own
Rust sources, the README and its LICENSE with the parameters. Non-production
use of these two is free, production use is free for Non-Commercial
Purposes, and any other production use, hosting for third parties or
distribution for a fee needs a commercial licence, exactly as for the
application.
openehr-term carries a third term, because it embeds a different kind of
openEHR material: the official terminology XML (the five language bundles, the
external-terminology index, and the property/unit data) is CC-BY-SA 3.0,
redistributed verbatim with attribution. Its manifest declares
Apache-2.0 AND CC-BY-SA-3.0 and the package ships both license texts, so the declaration a consumer reads names every license the
crate’s own bytes are under. If you redistribute the crate, the terminology data
travels under CC-BY-SA 3.0.
Versions 0.0.56 and earlier were published under the MIT terms in force at the
time (MIT AND Apache-2.0 for the crates that embed openEHR material) and keep
them; 0.0.57 was never published; 0.0.58 and 0.0.59 are Apache-2.0 for all
eight crates of that time. From 0.0.60 openehr-query and openehr-adl are
BUSL-1.1, and openehr-its was published as BUSL-1.1 AND Apache-2.0 from
0.0.60 to 0.0.67; its 0.0.68 was never published, and from 0.0.69 it is
Apache-2.0 again. Its hand-written Simplified Formats, RM-instance validation
and scope grammar moved into the new openehr-sdt, first published alone at
0.0.68 under BUSL-1.1 against the 0.0.67 siblings and in lockstep from 0.0.69.
openehr-query is Apache-2.0 again from 0.0.116; its 0.0.60 to 0.0.115 keep
BUSL-1.1.
Published
versions keep the licence they were published with. The full picture, including
the vendored material that never reaches a published package, is in
Licensing.
The openEHR specifications themselves are © the openEHR Foundation.
Contributing
FerroEHR is source-available (the Business Source License 1.1 for the
application, Apache 2.0 for the five generated openehr-* model crates, and
vendored third-party material under its upstream terms; see
Licensing & legal) and welcomes contributions. This
chapter is a short orientation for anyone who wants to file an issue, report a
vulnerability, or open a pull request; the authoritative documents live in the
repository and are linked below. You keep your copyright, and there is no
separate agreement to sign; the terms a contribution lands under are set out
in
CONTRIBUTING.md
§ Licensing of contributions.
Why FerroEHR exists explains what the project offers
the organisations that run it and build on it, and what it asks in return.
A bug report with a reproducing request, a conformance case for behaviour nothing covers yet, a specification ambiguity you had to resolve in your own integration, a documentation correction, or a measurement from your own hardware all count.
- Where to start
- Setting up
- The checks every pull request must pass
- Personal data
- Review
- Profiling: finding where the time goes
- Reporting issues and vulnerabilities
Where to start
The three governing documents are kept in the repository root:
- CONTRIBUTING — the practical rules for setup, the required checks, and pull requests.
- Code of conduct — the Contributor Covenant (v2.1) the community follows.
- Security policy — how to report a vulnerability privately.
Setting up
The Rust toolchain is pinned by the repository’s rust-toolchain.toml (stable
1.99.1), so rustup installs the right version automatically on your first
build. The declared minimum supported version is Rust 1.98 and CI
verifies it independently with cargo hack, so do not reach for a language
feature newer than that. The edition is 2024.
Two extra tools are needed for the full test suite:
- A PostgreSQL 18 server for the database-backed tests. The shared test
harness starts (or re-adopts) one reusable container if Docker is running;
otherwise point it at a server you already run with
FERROEHR_TEST_PG_URL(its role must be able toCREATE DATABASE). See From source → Running the tests. xmllint(fromlibxml2), used by the canonical-XML parity tests.
Install the shared git hooks once with bash scripts/install-hooks.sh.
The checks every pull request must pass
CI runs the same set of gates locally and on every pull request; none of them are advisory:
cargo build --workspace
cargo nextest run --workspace # unit + integration (real PostgreSQL 18)
cargo fmt --all --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
# rustdoc lints + doctests (the rustdoc lint table is inert without a doc run)
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --all-features --no-deps \
--document-private-items
cargo test --workspace --doc
cargo deny check # subsumes cargo-audit: same RustSec DB, plus yanked/licenses/bans/sources
cargo machete # unused dependencies
cargo hack check --rust-version --workspace # the declared MSRV really builds
bash scripts/checks/codegen-drift.sh # generated layer matches the vendored specs
Beyond those, a family of small single-purpose scripts under scripts/checks/
runs on every pull request: comment and doc-comment style, default values
declared inline in their struct’s Default impl, HTTP statuses compared as
types rather than as numbers, licensing declarations and SPDX headers, no Python
anywhere in the tooling, and the documentation-claim gates for this site. Each
is a plain bash script you can run yourself, and the failure message names
what to fix.
CI adds a few gates that need more than a checkout: a container smoke test that
composes the built server image against the database image, the browser
end-to-end battery for the viewer (bash scripts/ui-e2e.sh), the Helm
chart render and boot lanes, and the changelog, crate-version, and attribution
guards. Viewer-only work has its own local battery; see the repository’s
CONTRIBUTING.md.
Important
Two rules are absolute. Never hand-edit a generated file: anything under a
// @generated … DO NOT EDITheader is produced by the code generator; change the generator and regenerate instead. And never weaken, skip, or delete a test to make a build pass, or edit a test to route around a bug it exposes.
A few more conventions worth knowing before you open a pull request:
- Branch from
main, and target your pull request atmain. Branch names are<type>/<slug>with the conventional-commit types (feat/…,fix/…,docs/…,chore/…, and so on), and commit subjects use the same types. - Keep changes focused, and describe what changed and why. For anything that touches openEHR behaviour, cite the relevant specification section.
- Behaviour changes come with tests. Snapshot changes must be reviewed, not blindly accepted.
- Any user-visible change (the REST surface, AQL, validation, configuration, the
CLI, or the deployment artifacts) adds a changelog fragment under
changelog.d/(itsREADME.mdgives the format) and updates the matching page of this documentation, both in the same pull request. CI guards enforce both.
Personal data
The server keeps clinical content, the identities it belongs to and the map between them in three separate schemas, reachable by separate database roles. A change can move that boundary without meaning to, so four rules hold everywhere in the repository.
- Synthetic data only: tests, fixtures, seeds, examples and screenshots use invented values. A real name, national identifier, address, phone number, email or date of birth never goes into the repository, an issue, or a pull request body.
- No identifiers in telemetry: logs, traces, metric labels and
Debugoutput carry record identifiers (an EHR id, a version uid, a template id) and shapes, never the content of a subject’s data. ADebugimpl on a type holding personal data prints field names rather than field values. - No grant across the domains: a database role reaches one of the three pseudonymisation domains, never a second. A migration granting across them rejoins the identities to the records the split exists to separate.
- A review step at the boundary: a change touching the demographic or linkage migrations, the demographic or linkage services, the identifier scanner, the access-event model or an outbox payload builder describes its data flow, names the roles involved, and ticks the “Privacy boundary” checklist in the pull request template.
The privacy-boundary-guard job in CI enforces the last rule: a pull request
touching those paths without a ticked checklist fails. The same job reads the
added lines of every diff and fails on a value shaped like a real Dutch
identifier, a nine-digit number passing the BSN eleven-test or a postcode
followed by a house number. A synthetic value that still has that shape carries
privacy-allow: <reason> on the same line, and the job proves its own detectors
on every run before it judges a diff.
These are design-time rules because GDPR Art. 25 places data protection by design in the design phase, before a line of it is deployed.
Review
Two things review a pull request. The maintainers, who decide; and SonarQube Cloud, which analyzes every pull request (with CodeQL as the security scanner beside it).
The analysis is a second opinion, and deliberately nothing more. Its check is not required and it blocks no merge; if one of its findings is right, the change is written by hand. A finding that contradicts the vendored openEHR specification text, the repository’s own rules, or a local gate is wrong by construction, and saying so on the thread is the correct response.
Profiling: finding where the time goes
Four flamegraph instruments, all built on established crates (the sampling is
pprof, the rendering is
inferno). Pick by situation:
-
A running server (composed stack, staging, production): the
GET /management/flamegraphendpoint; see Operations → Profiling. -
A code path in isolation: the criterion benches carry a pprof profiler, so any bench emits a flamegraph under
--profile-time:cargo bench -p ferroehr --bench aql -- --profile-time 10 # → target/criterion/<bench>/profile/flamegraph.svg -
Async attribution (a sampled stack under tokio often blames the executor’s poll loop; a span flame blames the instrumented operation): set
management.flame_file = "/tmp/ferroehr.folded"; thetracing-flamelayer captures span timings as folded stacks, rendered offline:cargo install inferno inferno-flamegraph < /tmp/ferroehr.folded > span-flame.svg -
A whole local binary run (no code changes needed):
cargo flamegraph, a dev tool, not a dependency (cargo install flamegraph):cargo flamegraph --bin ferroehr # Linux: perf; add -F 999 for finer sampling cargo flamegraph --bench aql -- --bench # profile a bench run end to endOn macOS it uses
dtrace, which needs elevated permissions: run withsudo cargo flamegraph …or grant your terminal Developer-Tools access; on Linux you may needperfinstalled andkernel.perf_event_paranoid≤ 2.
Reporting issues and vulnerabilities
Use the GitHub issue tracker for bugs and feature requests.
Warning
Do not open a public issue for a suspected security vulnerability. Report it privately through GitHub’s private vulnerability reporting (“Report a vulnerability” on the repository’s Security tab). Because the server handles PHI-class data by design, reports about data exposure through the API, AQL, telemetry, or the audit trail are in scope even when they look like “just configuration”. Coordinated disclosure is preferred; please allow a reasonable window for a fix before publishing details.
Licensing & legal
The licence policy of the FerroHEALTH products is written once, in the
FerroHEALTH book: what the Business Source License 1.1 allows without asking
anyone, when you need a commercial licence, the Change Date to the Apache
License 2.0, how to arrange a commercial licence, and the FerroHEALTH
trademarks. Read it at
Licensing and trademarks.
LICENSE in this
repository is the authority for FerroEHR and names the Licensor, the Licensed
Work, the Additional Use Grant and the Change Date.
This page keeps what is particular to FerroEHR: installing a commercial licence in the server, which FerroEHR crates carry which licence, the third-party material this repository and its images ship, the machine-readable licensing, and FerroEHR’s lineage. It is a summary for evaluators and deployers, not legal advice.
- Installing a commercial licence
- FerroEHR’s own code: the Business Source License 1.1
- The published
openehr-*crates - Vendored third-party material
- Machine-readable licensing (REUSE 3.3)
- Rust dependencies
- Trademarks and lineage
- Questions
Installing a commercial licence
A commercial licence is a signed token file the licensor issues to you. Place
it where the server can read it and point [licence] file (or
FERROEHR__LICENCE__FILE) at it; GET /management/status then
reports licence.use = "commercial" with your organisation as licensee. Without a
token the same field reports the non-commercial grant every build embeds.
The server behaves identically under either; identifiers it mints carry a
few bits derived from the licence id in place of random bits, so a record
states which grant it was written under, and nothing else.
A commercial licence is arranged with Cadasto B.V., the Licensor: write to
info@cadasto.com or use
https://www.cadasto.com/contact/. Technical questions go to the maintainer
named in
MAINTAINERS.md.
FerroEHR’s own code: the Business Source License 1.1
Everything written for this project (the server and application crates, the
code generator and tooling, the viewer, and the two hand-written
specification engines openehr-adl and openehr-sdt) is licensed under the
Business Source License 1.1,
SPDX identifier BUSL-1.1. The Licensor is Cadasto B.V. The copyright
holder is stated as Cadasto B.V., identically in LICENSE, in
REUSE.toml, and in every first-party file header; a CI gate compares those
sources with this page and with the README.md of each hand-written engine
crate, so they cannot drift apart. Ruben Talstra remains the maintainer, which
is a credit rather than a holder statement.
FerroEHR is source-available. The Business Source License 1.1 is not an OSI-approved open-source licence, and this project does not claim that it is. Each version becomes available under the Apache License 2.0, its Change License, four years after that version is published.
What the change does not affect. Releases v3.0.0 through v4.0.17 stay under
the MIT terms they were published with, and so do the openehr-* crate
versions 0.0.56 and earlier on crates.io (MIT AND Apache-2.0 for the crates
that embed openEHR-derived material); openehr-query, openehr-adl and
openehr-its 0.0.58 and 0.0.59 stay Apache-2.0, and openehr-its 0.0.60 to
0.0.67 stay BUSL-1.1 AND Apache-2.0, and openehr-query 0.0.60 to
0.0.115 stay BUSL-1.1. Relicensing changes future versions
only. LICENSES/MIT.txt reproduces that grant as those versions carry
it, Ruben Talstra’s copyright line included, because a published grant is a
record of what was handed out and is not rewritten afterwards.
The conformance instrument is no longer part of this repository. It is Veredictum, an independent project under Apache-2.0, and the conformance pipeline here consumes it at a pinned version. The vendored test corpora this repository still carries keep their upstream terms exactly as the table below states.
The published openehr-* crates
Six of the nine published openehr-* spec crates are Apache-2.0, not
BUSL-1.1, so any Rust project can use them, in proprietary and hosted
products included, with no commercial licence involved. openehr-base,
openehr-rm, openehr-am, openehr-lang and openehr-term are the
generated openEHR model, published on crates.io under the licence of the
openEHR machine-readable artifacts they are generated from. They embed
openEHR-derived material (specification documentation text in the generated doc
comments), which is Apache-2.0 as well; their generated files name the openEHR
Foundation as a second copyright holder. openehr-term also embeds the official
openEHR terminology XML, which is CC-BY-SA 3.0 (see the table below) and is
redistributed verbatim with attribution, so it declares
Apache-2.0 AND CC-BY-SA-3.0 and ships both texts.
openehr-its, the ITS wire layer, is the sixth. Its canonical XML codecs,
_type dispatch, REST contract and archetype XML codecs are generated from the
Apache-2.0 openEHR XSD, OpenAPI and BMM artifacts, and it embeds the vendored
ITS-JSON schema. The hand-written runtimes, canonical JSON entry points and
wire-validation dispatcher are Apache-2.0 with it, because the generated code
cannot ship without them. From 0.0.69 it declares Apache-2.0, and every
file names the openEHR Foundation as the second copyright holder.
openehr-query, the AQL parser hand-written from openEHR’s Apache-2.0
grammar, declares Apache-2.0 from 0.0.116 and names the openEHR Foundation
as the second copyright holder in every file.
The two hand-written engines, openehr-adl
(the ADL engine) and openehr-sdt (the Simplified Formats, RM-instance
validation and the SMART scope grammar), are the project’s own engineering and
carry the Business Source License 1.1 like the application, each with its own
LICENSE naming the crate as the Licensed Work and each declaring BUSL-1.1.
See Rust crates.
Vendored third-party material
The repository vendors external material verbatim: machine-readable specification artifacts the code generator consumes, the openEHR specification text used as the conformance oracle, and real-world clinical models and fixtures used as test corpora. Each family keeps its upstream license:
| Material | Source | License |
|---|---|---|
| openEHR machine-readable artifacts (BMM meta-models, XML Schemas, OpenAPI documents, JSON Schemas) | the openEHR specifications-ITS-* repositories | Apache-2.0 |
| The normative ADL, cADL, ODIN, BEL and Expression-Language ANTLR grammars | openEHR/adl-antlr, openEHR/openEHR-antlr4 | Apache-2.0 |
| openEHR specification text (the conformance reference) | the openEHR specifications-* repositories | CC-BY-SA 3.0 |
| The AQL grammar and the computable terminology assets (the terminology XML the server embeds, and its schemas) | specifications-QUERY, specifications-TERM | CC-BY-SA 3.0 |
| Clinical models (archetypes and templates) from the openEHR Clinical Knowledge Manager | ckm.openehr.org | per-file licence metadata — a mix of CC-BY-SA 4.0 and CC-BY-SA 3.0 |
| The ADL 2 archetype library, with its ADL 1.4 twins (the shared corpus) | openEHR/adl-archetypes | no stated licence — see below |
The ADL 2 validator-regression library (the openehr-adl corpus) | openEHR/adl-archetypes, a different subtree | mixed: mostly unstated, else CC-BY-SA 3.0, CC-BY 4.0 or CC-BY 3.0 |
Test corpora (archie fixtures and reference models, Better web-template-tests, EHRbase SDK canonical-JSON data) | Nedap, Better Ltd, vitasystems | Apache-2.0 |
| Three ISO 13606 / rejected-extract BMM reference models inside the archie corpus | offered by their authors under MPL 1.1 / GPL 2.0 / LGPL 2.1 | taken under MPL 1.1 — see the election below |
One terminology schema file, PropertyUnitData.xsd | ADL Designer / ADL2-tools, via the openEHR TERM assets | AGPL-3.0-only — see the contradiction below |
| The self-hosted KaTeX stylesheet and fonts this documentation site renders maths with | KaTeX contributors | MIT |
The Citation File Format 1.2.0 JSON Schema (validates CITATION.cff in CI) | citation-file-format/citation-file-format | CC-BY 4.0 |
EU legal acts vendored for the compliance citations (GDPR, ePrivacy Directive, EHDS, NIS2, CRA, MDR), under docs/law/eu/ | the EU Publications Office (EUR-Lex) | LicenseRef-EUR-Lex-Reuse (Commission Decision 2011/833/EU); the consolidated texts additionally CC-BY 4.0 |
| EDPB Guidelines 01/2025 on pseudonymisation and 2/2023 on the technical scope of Art. 5(3) of the ePrivacy Directive | European Data Protection Board | LicenseRef-EDPB-Reuse |
Dutch acts and decrees (UAVG, Wabvpz, BW Boek 7, the Telecommunicatiewet, the logging-retention decree, Begz), under docs/law/nl/ | wetten.overheid.nl | LicenseRef-Auteurswet-Art11-Public-Domain: no copyright subsists in Dutch legislation |
German federal law (BDSG, SGB V, GDNG, StGB; the DigiG as its Bundesgesetzblatt issue), under docs/law/de/ | gesetze-im-internet.de and recht.bund.de | LicenseRef-UrhG-Para5-Amtliche-Werke: no copyright protection for German legislation (§ 5 Abs. 1 UrhG) |
Swiss federal law (DSG/FADP, DSV/DPO, EPDG, EPDV, EPDV-EDI; German, plus the non-binding English where Fedlex publishes it), under docs/law/ch/ | Fedlex (the Federal Chancellery) | LicenseRef-URG-Art5-Nicht-Geschuetzte-Werke: Swiss legislation and its official translations are not protected by copyright (Art. 5 URG) |
The NEN 7510, 7512 and 7513 standards the compliance pages cite are sold under
copyright and are not in the tree; docs/law/nl/nen-75xx/ holds a record of the
clauses cited and why the text is absent.
Every vendored tree in the repository carries a provenance note naming its exact
upstream source and pinned revision, with the upstream LICENSE file vendored
alongside where the source publishes one; the specification and corpus trees
name their license there as well, and REUSE.toml below is the authority for
all of them. The fuzzing seed corpus is a copy of several of those trees, so it
is declared under the union of their licenses rather than guessing each seed’s
origin from its filename.
The CKM clinical-model corpus is mixed, and the table says so on purpose. A
first-hand count over the vendored CKM material finds both CC-BY-SA 4.0 (the
majority) and CC-BY-SA 3.0 (several hundred files), so no single version is a
true statement about the tree. Each of those archetypes carries its own
licence field inside its description block, and that per-file metadata is
the authority for any individual file, which also means licensing for this
material already survives being copied out of the repository.
The ADL 2 archetype library states no licence at all, and this is the one
tree here that carries no grant. Measured on 2026-09-10 over the vendored copy
(openEHR/adl-archetypes at the pinned commit): of 652 archetypes, exactly one
states a licence — and it states CC-BY 4.0, not CC-BY-SA — while the other 651
carry copyright = <"© openEHR Foundation"> and nothing more. Upstream states
nothing either: no LICENSE file at that commit, and a four-line README.md.
The validator-regression library from the same upstream repository is a different subtree and measures differently: of its 302 archetypes, 111 state a licence and 191 state none, and where one is stated it is predominantly CC-BY-SA 3.0 (100 of the 111), with 10 CC-BY 4.0 and 1 CC-BY 3.0. No file in it states CC-BY-SA 4.0, which is why its declaration names what is actually there rather than inheriting the CKM expression.
An unstated licence is not a permissive one, so this material is declared
LicenseRef-openEHR-unstated, whose text in LICENSES/ records the
measurement and says plainly that it is not a licence. If you lift a file out
of that tree, you are doing so without a stated permission from the copyright
holder, and that is your call to make rather than one this repository has made
for you. The tree is here because it is the only independent source of paired
ADL 1.4 and ADL 2 forms of the same archetype: generating the pairs with this
project’s own converter would validate that converter against its own output.
Whether it stays committed is tracked on the issue this correction came from.
Two positions worth stating explicitly, because both are the kind of thing a compliance review finds and a summary table hides:
- The MPL election. Three BMM reference models in the archie corpus
(
cen_EN13606_0.95.bmm,cen_ts14796_0.90.bmm,openehr_ehr_extract_999.bmm) are offered by their authors under a tri-license: MPL 1.1, GPL 2.0, or LGPL 2.1. This project takes them under MPL 1.1, a file-scoped weak copyleft. The election is recorded in that corpus’sPROVENANCE.md, so no GPL or LGPL obligation attaches to anything here. - One upstream contradiction, not resolved by us.
crates/openehr-term/assets/schema/PropertyUnitData.xsdcarries an ADL Designer / ADL2-tools header offering it under the GNU Affero General Public License, inside an upstream repository whose ownLICENSEis CC-BY-SA 3.0. Both cannot be right, the contradiction is upstream’s, and re-licensing someone else’s file is not ours to do, so it is declared at the more restrictive of the two readings, the one the file’s own text asserts. No obligation reaches a consumer: the terminology schemas are excluded from the published crate by that crate’sincludelist, so the file ships in nothing.
Machine-readable licensing (REUSE 3.3)
The PROVENANCE.md arrangement above is accurate, and it stays. What it does
not do is survive a file leaving this repository: someone who lifts a single
archetype out of a test corpus takes a CC-BY-SA file bearing no marking they
copied. People build on this project and redistribute it, so downstream
file-level redistribution is the expected case.
So licensing is also published in the machine-readable form the REUSE Specification 3.3 defines:
LICENSES/holds the full text of every license any file in the tree is offered under, named by SPDX identifier:BUSL-1.1,MIT,Apache-2.0,CC-BY-SA-3.0,CC-BY-SA-4.0,CC-BY-4.0,CC-BY-3.0,MPL-1.1,AGPL-3.0-only— plusLicenseRef-openEHR-unstated, which is not a licence but the record that one tree carries none.REUSE.tomldeclares, by glob, which files are offered under which, including the two positions above, represented rather than flattened.- Every first-party source file carries the header inside itself: an
SPDX-FileCopyrightTextline and anSPDX-License-Identifierline stating the same positionREUSE.tomldeclares for it, so a file copied out of this repository takes its licensing along. Rust files of the five generated spec crates, ofopenehr-itsand ofopenehr-querystateApache-2.0, and every other first-party Rust, shell, SQL and YAML file, theopenehr-adlandopenehr-sdtsources included, statesBUSL-1.1. A copied migration or script arrives licensed, which is the whole point.
The vendored trees are glob-declared rather than headered for a reason that is
not convenience: no vendored file may be edited, so a header sweep over
third-party material was never available. REUSE.toml is the mechanism that
makes the declaration complete without touching one. The generated spec-crate
sources are the mirror case: a hand-written header there would be erased by the
next code-generation run, so they receive theirs from the code generator, which
stamps every file it writes.
All of it is gated in CI, and by more than one check, because each one can only see part of the picture:
reuse lintproves the declarations are complete: every file carries licensing information and no license text is orphaned.- A second check fails the build if the set of licenses declared in
REUSE.toml, the texts present inLICENSES/, and the licenses named on this page ever stop agreeing. A license cannot enter the tree without this chapter acquiring it. - Two header checks fail the build if a first-party file loses its header or states a license other than the one declared for its path: one for Rust, one for shell, SQL and YAML.
- One check fails if the copyright holder is stated differently in
LICENSE,REUSE.toml, the file headers, this page, or an engine crate’sREADME.md. - One check refuses copyleft license text inside this project’s own source, which is a conflict no reviewer reliably catches by eye.
What none of them can check is whether the prose on this page is correct. No tool can judge that, which is why any change to what the repository redistributes updates this chapter in the same pull request.
The CC-BY-SA specification text and clinical models are redistributed
verbatim, with attribution: they are reference and test material, not part
of the compiled server. The FerroEHR binary you deploy is built from this
project’s own BUSL-1.1 code, plus the Apache-2.0 machine-readable inputs the
generated crates carry, plus the CC-BY-SA 3.0 openEHR terminology bundle
openehr-term compiles in (five languages, the external-terminology index,
and the property/unit data). No
copyleft obligation beyond those attribution-and-share-alike terms on the
verbatim data attaches to anything shipped.
Rust dependencies
All third-party Rust crates are pinned in the workspace manifest and
license-gated in CI with cargo deny, which checks licenses, security
advisories, bans, and sources on every change. The allow-list is permissive
(MIT, Apache-2.0, the BSD family, ISC, Zlib, BSL-1.0, Unicode-3.0, CC0-1.0,
MIT-0, CDLA-Permissive-2.0) and deliberately admits two file-scoped weak
copyleft licenses: MPL 2.0, and CDDL 1.0 as a single crate-scoped exception for
the flamegraph renderer the profiling instruments use. Obligations under both
attach to those crates’ own files, which are consumed unmodified. No strong
copyleft (GPL, LGPL, AGPL, SSPL) is admitted, and a new dependency carrying one
fails the build.
Trademarks and lineage
The trademarks of the FerroHEALTH family are on Licensing and trademarks in the FerroHEALTH book.
- openEHR® is the registered trademark of the openEHR Foundation. FerroEHR is an independent implementation of the openEHR specifications and is not affiliated with or endorsed by the openEHR Foundation.
- FerroEHR began as a fork of EHRbase, developed by vitasystems GmbH and the Peter L. Reichertz Institute, and records that lineage in the labelled import commit at the root of its history. EHRbase itself remains Apache-2.0; no code from it is present in this tree, and it is consulted as prior art only. FerroEHR is not affiliated with or endorsed by the EHRbase project.
Questions
If you need a clarification for a compliance review, open a
GitHub discussion or issue;
provenance questions can usually be answered by pointing at the exact
PROVENANCE.md and upstream pin.
Compliance overview
FerroEHR is software. It is not a controller, not a processor and not a certified organisation, so nothing on this page says that a deployment complies with anything. What this page does say is which technical controls the product ships today, which are planned and tracked in public, and which obligations stay with the organisation that runs it.
The page is written for three readers. A privacy officer wants the legal sources and the split of duties. A hospital CISO wants the controls and their status. A developer wants to know where the boundary between clinical and identifying data runs. Each of those three readings takes about ten minutes.
It is also written in two layers, because openEHR is not a national standard and FerroEHR is published for every country that runs it. The GDPR, the EDPB guidelines and the EHDS apply to every EU deployment and come first. After them come the national sections, one per jurisdiction, each on top of that same EU layer. The Netherlands is filled in first because that is where the project’s own deployments are, not because it is the default; the national law section says how to add another.
The documentation that belongs to the FerroHEALTH family or to its manufacturer, Cadasto B.V., lives in the FerroHEALTH book at https://ferrohealth.eu/docs/: the manufacturer, the security policy, the post-market procedure, the manufacturer side of the CRA, the intended purpose and the shared responsibility of the EHR system FerroEHR forms with FerroBRIDGE, licensing, and how the products fit together. This book keeps FerroEHR’s own: the pages below.
- What FerroEHR claims, and what it does not
- The pseudonymisation boundary
- GDPR
- EDPB Guidelines 01/2025 on pseudonymisation
- EHDS
- National law
- Where to go next
What FerroEHR claims, and what it does not
FerroEHR aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture, and an EHDS conformity self-assessment is on its roadmap.
It holds no certification, no declaration of conformity and no third-party assessment. No page on this site will tell you that FerroEHR is “GDPR compliant”, “NEN 7510 certified” or “EHDS conformant”, because none of those statements would be true of a piece of software on its own.
What the project does instead is publish the record. A shipped control has a feature page in this book and an issue in the tracker that delivered it. A planned control has an open issue and appears here as planned, with its number. The control matrix is generated from the tracker, so a control cannot sit on this site as “planned” after it has shipped, or as “shipped” before it has.
Warning
The legal texts linked here change, and some of them are not yet in force. The EHDS obligations phase in over several years on the dates its own final provisions carry, and NEN republishes its standards on its own cycle (NEN 7510 was reissued in December 2024). Check each publisher directly before you rely on a statement here: EUR-Lex for EU law, wetten.overheid.nl for Dutch law, gesetze-im-internet.de and recht.bund.de for German federal law, Fedlex for Swiss federal law, the EDPB for guidelines, and NEN for the 7510 family. This page is a summary for evaluators and deployers, not legal advice.
The exact texts these pages were written against are vendored in the repository under
docs/law/, each at a named consolidation with its digest and licence, and that directory’s README maps every act to the pages citing it. A statement here resolves to those bytes, and a publisher’s later amendment is a re-pin there, not a silent change of meaning.
The pseudonymisation boundary
A clinical record is identifying as soon as the record and the person can be put back together by whoever holds the database. Separating the two, and controlling who may rejoin them, is what GDPR Art. 4(5) calls pseudonymisation, and it is the control the EDPB Guidelines 01/2025 expect a supplier to describe rather than assert.
openEHR anticipated this. The Reference Model’s
PARTY_SELF and Referring to the Patient from the EHR
section names three schemes for pointing at the record subject, and calls the
one that never sets external_ref anywhere in the EHR “the most secure
approach”, because the link between record and patient is then held outside
the EHR. The second scheme sets it once, in
EHR_STATUS.subject,
whose own class description says the association “may be done elsewhere for
security reasons”. The reference itself is a
PARTY_REF,
described by the specification as an “identifier for parties in a demographic
or identity service”, so it carries a namespace, a party type and an id and no
demographic content of its own. The specification leaves the choice of scheme
to the implementation. Where the schemas, the database roles and the resolve
path live is FerroEHR’s own design, and no openEHR spec governs it.
What ships today
Clinical content and demographic parties live in separate PostgreSQL schemas,
ehr and demographic, each with its own archival tier and its own runtime
database roles. ferroehr_clinical and ferroehr_party, and a read-only twin
of each, are NOINHERIT, hold explicit grants on one domain only, and carry an
explicit revoke on the other and on linkage. The server refuses to start if that does not
hold: a self-check enumerates every table, view, sequence and function in each
domain and names the role and the object it can reach. The database refuses the
mix as well, in both directions, so a code path that missed the split fails as
a write error rather than leaking quietly.
The separation of schemas is unconditional. Pointing the demographic and
linkage pools at their own DSNs ([storage.party] url and
[storage.linkage] url) makes it a separation of credentials too, which is what
stops one leaked connection string from reaching more than one domain.
The controls that apply across both domains are the same: role- and attribute-based authorization, per-EHR access settings, the domain-separated database roles, and the audit trail. Both sides carry openEHR’s own provenance, because every write commits a contribution and its audit in the same transaction, which is the versioning discipline the Change Control Package defines.
The map that rejoins the two is separated as well. linkage is a third schema
under a fifth role, barred from both domains it joins and both of them from it,
holding identifiers and a validity period and no attribute. The one crossing
runs in the application over two pools and writes an access record. On the
clinical side the subject reference is an opaque pseudonym once
[privacy] subject_namespaces is declared, enforced by the write path and by a
database trigger, and the identifier scanner refuses a national identifier
anywhere in a clinical body.
flowchart LR
client["API client"] --> server["FerroEHR server"]
server -->|ferroehr_clinical| ehr[("clinical schema:<br/>versions and nodes")]
server -->|ferroehr_party| demo[("party schema:<br/>parties and sealed identifiers")]
server -->|ferroehr_linkage| link[("linkage schema:<br/>party to EHR resolve map")]
server -->|audit writer| audit[("audit schema:<br/>ATNA record repository")]
Secondary use
Research data leaves through FerroBRIDGE, which loads the OMOP CDM in batches over AQL and pseudonymises for research; the CDR keeps no read model of its own for it (#3160 closed as superseded). The secondary-use page records the batch contract, the marks that take an EHR out of a batch, and the data permit on the access record.
Cross-domain cohort queries shipped with v4.2.0 (#3159): a cohort
question that spans both domains is answered through
POST /query/cohort,
each step on its own credential with identifiers only crossing between them,
and a result set serving fewer distinct EHRs than the configured threshold is
withheld and marked suppressed. Secondary use reads the primary store under
those controls; size your access control, purpose limitation and risk
assessment on that. The threat model
states the residual risk at each boundary, and the DPIA page
carries the risk register and the shipped controls by issue number.
The deployment profile
A deployment declares what it may hold with the top-level deployment_profile
key (configuration).
production refuses to start while a separation is missing and not accepted
by name: separated credentials, separated clusters, a declared pseudonym
namespace, an audit trail with a durable sink, schema preparation on its own
credential. sandbox, the default, must not hold real personal data and says
so on the banner, in the log and on GET /rest/status. The profile is
FerroEHR’s own posture. GDPR Art. 4(5) asks that the additional information be
“kept separately and … subject to technical and organisational measures”, not
that it sit on a separate server, so one cluster with separated schemas and
roles is a defensible reading; two clusters close the bridges no grant can,
the superuser, an instance-wide point-in-time recovery, a single compromise,
and the profile exists so that choice is made deliberately.
GDPR
Regulation (EU) 2016/679 places its duties on the controller and the processor. A CDR can only supply the technical measures those duties are met with. These are the articles a repository actually touches.
| What the article asks for | What FerroEHR ships | Tracker | What the deploying organisation must do |
|---|---|---|---|
| Art. 4(5) pseudonymisation: identifying data kept separately, under technical measures | Clinical, demographic and linkage data in three schemas under non-overlapping NOINHERIT roles, enforced by grants, by a boot-time self-check in both directions and by a check on every table; separated credentials and clusters are asserted by the production deployment profile. The patient-to-EHR cross-reference lives outside the CDR by default, in an IHE patient identity source asked over PIXm ([ehr_index]), which mints the pseudonym; with the opt-in provider = "local" it lives in the linkage schema under its own role. FerroEHR never resolves a pseudonym back to a person | shipped, #3153, #3158, #3226, #3380 | Run the production profile with the separations it asserts, run the identity source under its own controls, and hold any additional information the CDR never sees to the same standard |
| Art. 5(1)(f) integrity and confidentiality | TLS 1.3 with optional mutual authentication, authentication and authorization, per-version digest signing, a tamper-evident audit chain | shipped | Terminate TLS correctly, run the identity provider, hold the keys |
| Art. 5(1)(e) storage limitation: data kept in identifying form no longer than the purposes need | A retention register of the period per content category and jurisdiction with the citation it rests on, a per-EHR anchor, and a view of what has run out. The repository deletes no clinical content on a timer: an openEHR record is indelible, so the register produces a list | shipped, #3346 | Choose the periods, which follow from the law you run under and not from the software, and decide record by record what to do with the list |
| Art. 5(2) accountability: being able to demonstrate compliance | An audit trail of every access, retrievable over ITI-81, plus openEHR’s own contribution and audit chain on every write | shipped | Keep the records, define retention, be able to produce them |
| Art. 9 special categories of data | Object-level EHR_ACCESS settings, RBAC, ABAC, and the domain-separated database roles | shipped | Establish the Art. 9(2) condition and the national derogation that permits the processing |
| Art. 12(3) action on a rights request within one month, by electronic means where the request came electronically | Every right in the rows below is served by an API call, so the record is read, exported, corrected or deleted in the run that answers the request | shipped | Start the clock at receipt, verify the requester’s identity, and use the two-month extension only with the reasons 12(3) asks for |
| Art. 15(1) and 15(3) access, and a copy of the data undergoing processing | The full record over the REST API in canonical openEHR JSON or XML, and EHR Extract export for a whole record; the recipients 15(1)(c) asks about come from the per-patient trail search | shipped | Supply what the software cannot know: the purposes of 15(1)(a) and the storage period of 15(1)(d) are your configuration and your retention schedule. Authenticate the requester and build the patient-facing route |
| Art. 16 rectification, and completion by a supplementary statement | A correction commits a new version while the prior one stays in the append-only history, which is the supplementary statement the article allows for incomplete data | shipped | Decide what is inaccurate or incomplete, and reconcile the correction with the record-keeping duty your national law imposes |
| Art. 17(1) erasure, subject to the grounds in 17(3)(b) to (d) | DELETE {base}/admin/ehr/{ehr_id} (physical deletion) removes, in one transaction, the EHR and everything it owns over both storage tiers: compositions, EHR_STATUS, item tags, folder memberships, contributions, every historical version, the restriction and retention marks and the pending change events. The cross-reference row naming the subject is erased in the linkage domain, the externalized multimedia blobs no surviving version references are deleted from the object store, and an erasure tombstone on the change-event stream tells each consumer to delete what it derived (Art. 19). What stays is the audit trail naming the ehr_id, because Art. 17(3)(b) withholds erasure where processing is necessary for compliance with a legal obligation and the access-logging periods are that obligation | shipped, #3347 | Decide whether a 17(3) ground refuses the request, a legal obligation, public health, or research under Art. 89(1), and record that decision. Backups, replicas and copies outside the CDR are reached by your own rotation, not by the call |
| Art. 18 restriction of processing, which Art. 4(3) defines as marking stored data to limit its future processing | A restriction register at two grains, a whole EHR or one versioned object in it: a marked object stays in storage and every path that would process it stops, the point read, the versioned read and the revision history with 403, AQL at every scope, the EHR Extract, the event stream and any write. The register keeps the request and stamps it lifted, so the sequence 18(3) turns on survives. On a write the restriction is the last gate: content validation answers 422 and the preconditions outside the commit transaction answer 409, because the mark is read inside that transaction so a concurrent lift is serialised with the write, so a refusal status names the gate that stopped the write rather than the state of the mark | shipped, #3324 | Record the 18(1) ground on the request, hold the restriction in the systems around the CDR, and tell the subject before you lift it (18(3)) |
| Art. 19 communicating a rectification, erasure or restriction to each recipient, and naming those recipients to the subject on request | Every read and export is an access record naming the agent, the patient, the action, the outcome and the time, so the recipient list is answerable from the trail | shipped | Send the communications. The trail records who received data; it notifies nobody. How far back the recipient list reaches is your retention_days: the regulation sets no retention period for an access log |
| Art. 20(1) and (2) portability in a structured, commonly used and machine-readable format, and direct transmission where technically feasible | Canonical openEHR JSON and XML, the simplified FLAT and STRUCTURED formats, and the EHR Extract, which another openEHR system imports directly | shipped | Check the trigger before answering: 20(1)(a) reaches processing based on consent or on a contract, and 20(3) excludes processing for a task carried out in the public interest, which is the basis much care runs on |
| Art. 21(1) objection, and 21(6) objection to research processing under Art. 89(1) | A research objection per EHR: while it stands, the record leaves every full-population query, every export and the event stream, the three surfaces a secondary-use consumer reads the repository through, while a query naming the ehr_id and a read for care are untouched. The public-interest override 21(6) admits is recorded beside it and says on whose authority | shipped, #3325 | Weigh the compelling legitimate grounds 21(1) asks for, record the outcome, and carry the objection into the systems outside the CDR |
| Art. 25 data protection by design and by default | Deny-by-default authorization, an EHR_ACCESS default that can be set to restricted, audit on by default. The two defaults that favour compatibility, the open per-EHR access default and the open audit fail mode, stop a production deployment booting: each is a named gap (open_ehr_access_default, audit_fails_open) that the deployment either closes or accepts by name in deployment_accepts, which is then stated on every boot and on /management/status | shipped, #3323 | Choose the restrictive settings, or record the decision to keep a compatibility default and carry it in deployment_accepts |
| Art. 28(3)(e) to (h) what a processor’s contract must let it do: assist with the rights, assist with Arts. 32 to 36, delete or return the data at the end of service, and make audit information available | For a vendor operating a deployment: the rights operations in the rows above for (e), the trail and the controls documented in this book for (f), EHR Extract export beside admin physical deletion for (g), and GET /ferroehr/rest/extensions/v1/admin/config with the trail as the evidence (h) asks for | shipped | Conclude the contract Art. 28(3) requires with whoever operates the deployment; no software supplies it. Cadasto B.V. is the manufacturer of each release: for a self-hosted deployment it processes no personal data on your behalf and is not your processor; where Cadasto B.V. hosts the deployment for you it is your processor, and the contract is with it (shared responsibility). Either way, the one service a deployment reaches by default is Cadasto B.V.’s collector for the usage report, which Cadasto B.V. receives as controller for its own purposes |
| Art. 30 records of processing activities, and 30(1)(f) the envisaged time limits for erasure of the different categories of data | The effective configuration as a redacted JSON tree at GET /ferroehr/rest/extensions/v1/admin/config, this book as a description of what the software does, and the retention register as the machine-readable answer to the time limits per category 30(1)(f) asks for | shipped, #3346 | Write and maintain the record itself; the software cannot know your purposes or recipients |
| Art. 32 security of processing | The controls listed in Security and the residual risk in the threat model | shipped | Assess whether they are appropriate to your risk, and supply everything below the application |
| Art. 33(3)(a) the categories and approximate number of subjects and of records a breach touched, and Art. 34(3)(a) the measures that remove the duty to tell patients | The trail records reads, writes and refusals per patient and per agent, so whose records an incident reached, and how many, is countable from it | shipped | Assess and notify inside the deadlines. 34(3)(a) lifts the duty to inform patients only where the protection measures “were applied to the personal data affected”, so check what was in fact protected: the application seals national identifiers in the demographic domain and nothing else, and encryption of clinical content at rest belongs to the database and the disk. The regulation’s text says nothing about encryption at rest, so the measure and its strength are your choice to make and to defend |
| Art. 35 data protection impact assessment | A DPIA page with the processing description, the data categories per schema, the roles, the retention including the Dutch access-log floor, a risk register and the shipped controls by issue, beside records of processing pre-filled with what the software does and a go-live checklist | shipped, #3161 | Run the DPIA; it is the controller’s, and no supplier document replaces it |
| Art. 89(1) research safeguards: where the purpose can be met by processing that no longer identifies anyone, it must be met that way | Cohort queries across the pseudonymisation boundary answer a research question as an aggregate, withheld when fewer distinct EHRs than the configured threshold match | shipped, #3159. Pseudonymisation for research happens in FerroBRIDGE, which loads the research database; the CDR gates what leaves with the restriction and objection marks and records the purpose and the data permit of every batch (Secondary use), #3379 | Decide whether the purpose can be met without identification and take that route where it can |
EDPB Guidelines 01/2025 on pseudonymisation
The EDPB guidelines ask for something more specific than “we pseudonymise”: a named pseudonymisation domain, a stated attacker, and additional information kept where that attacker cannot reach it.
| What the guidelines ask for | What FerroEHR ships | Tracker | What the deploying organisation must do |
|---|---|---|---|
| A pseudonymisation domain stated explicitly | The clinical and demographic domains are separate schemas with their own roles, and the server refuses to boot if a role reaches across | shipped, #3153 | State the domain for your deployment, including the parts outside FerroEHR |
| The additional information held separately from the pseudonymised data | The party-to-EHR map lives in its own linkage schema under its own NOINHERIT role, revoked from both domains it joins and reached by no other pool; it is temporal, so a merge or split closes a row rather than deleting it, and it is backed up under its own key and its own job | shipped, #3158, #3157 | Hold any mapping outside the CDR to the same standard, and give the linkage backup the narrowest audience |
| A written attacker model, including the insider holding a credential | The threat model names actors, boundaries and the risk surviving each control | shipped | Extend it with the actors your environment adds: operators, backups, the network |
| Resolution of a pseudonym recorded and controlled | Every resolution, merge and split is a linkage-domain access record, refused when it cannot be recorded under fail_mode = "closed"; a cohort query names its cohort by a digest of its predicates and never by their values | shipped, #3155, #3235, #3159 | Restrict who may resolve, and review the trail |
EHDS
Regulation (EU) 2025/327 splits into chapters that reach a CDR differently. Chapter III is the one that speaks to an EHR system as a product, and its obligations apply from a date in the regulation’s own final provisions rather than today.
| Chapter | What FerroEHR ships | Tracker | What the deploying organisation must do |
|---|---|---|---|
| Chapter II, primary use, including the patient’s access to their data and to a record of who accessed it | An access trail of every read, write and refusal, searchable by patient and by agent, read for one patient through a subject-scoped grant, with an emergency mark on an access declared in the patient’s vital interests (Art. 11(5)), and the Art. 8 restriction of access by health professionals, under which restricted data reads as absent unless the configured emergency role declares an emergency | shipped | Build the patient-facing access route on the subject-scoped grant and authenticate the person. Record a restriction on the person’s instruction through the admin route, configure authz.rbac.emergency_access_role, and have callers declare an agreed emergency code when they override it |
| Chapter III, EHR systems: a European interoperability software component and a European logging software component, with published technical documentation | The EHDS readiness page maps every Annex II requirement to a status with its evidence, the technical documentation page maps the Annex III elements to the documentation tree, and the access trail records the logging component’s elements (audit) | readiness shipped, #3168, #3169, #3170, #3171; the interoperability component is FerroBRIDGE’s, and the exchange format waits on implementing acts under Article 15(1) | Configure the deployment as the instructions for use say. The conformity assessment and the EU declaration are Cadasto B.V.’s as manufacturer of each release; a self-hosted deployment of an unmodified release does not make you the manufacturer |
| Chapter IV, secondary use, including Art. 66(3) (data in pseudonymised format, the means to reverse it held only by the access body or a trusted third party) | The batch surface FerroBRIDGE loads the OMOP CDM from, with the restriction and objection marks taking an EHR out of every batch and the purpose and data permit on every access record; cohort queries across the pseudonymisation boundary with small-cell suppression. Pseudonymisation for research happens in FerroBRIDGE; the CDR gates the data | shipped, #3159, #3379 | Deal with the health data access body; a CDR is not a data-holder’s permit process. Have the secondary-use caller declare its purpose and permit |
National law
Everything above this line applies to every EU deployment. Everything below it is one country’s law on top of it, and a deployment reads only its own section plus the EU layer.
Three jurisdictions are filled in today: the Netherlands, Germany and
Switzerland, each vendored under docs/law/ and read against the text. The
product side of the split is plural too: the write-path
identifier scanner ships a named rule per
national identifier — Finland, the United Kingdom, the Netherlands, Norway and
Sweden — each transcribing the checksum its own issuing register publishes,
and a deployment selects the ones its content can carry. Denmark and Belgium
are named there too, with the reason each is deliberately absent.
Adding a jurisdiction takes three things, and none of them is a change to
how the scanner works: the national acts that sit on top of the GDPR, as a
section in the shape of the Dutch one below (provision, what the product
ships, tracker status, what the organisation must do); the national security
and logging standards, in the shape of the NEN section; and, where the country
issues a personal identifier with a published algorithm, a rule in
app/ferroehr/src/privacy/detect.rs citing the register that defines it. Open
a regulation request
with the official source and the provisions that reach a repository, and the
project vendors the text and records a status per provision, the way the acts
below are handled. A checksum transcribed from a secondary source is refused,
because a rule that guesses tells an operator their data was scanned when it
was not.
The Netherlands: UAVG and Wabvpz
Two Dutch acts sit on top of the GDPR for a care provider. The UAVG is the national implementation act; the Wabvpz governs the burgerservicenummer in care and the patient’s electronic access to their record.
| Provision | What FerroEHR ships | Tracker | What the deploying organisation must do |
|---|---|---|---|
| UAVG Art. 30, exceptions for health data | Access control at the record and the attribute level, and an audit trail of who used it | shipped | Establish that your processing falls inside the exception, per role and per purpose |
| UAVG Art. 46, processing a national identification number | A national identifier is sealed at rest in the demographic domain under authenticated encryption with the instance’s identifier-protection key, looked up through a keyed digest, resolved only through a SECURITY DEFINER function on the demographic role, and every resolution, hit or miss, is a recorded linkage access that never carries the value | shipped, #3155 | Hold the statutory authorisation before a BSN enters the store, and restrict who may resolve |
| Wabvpz Art. 4 to 9, use and verification of the BSN by care providers | Nothing specific: FerroEHR performs no BSN verification and consults no index | not planned | Verify identity and the BSN in your own systems before data reaches the CDR |
| Wabvpz Art. 15d, electronic access and copy for the patient | The full record over the openEHR REST API, and EHR Extract export for a whole record | shipped | Build the patient-facing route and authenticate the patient |
| Wabvpz Art. 15e, a record of who made data available and who consulted it | The ATNA trail records reads, writes and refusals with the agent, the patient, the action and the outcome, and answers a per-patient search | shipped | Turn the trail into something a patient can read, and set retention |
The Netherlands: NEN 7510, NEN 7512 and NEN 7513
The NEN 7510 family governs information security in Dutch healthcare. NEN 7510 is a management-system standard, which no product can be certified against. NEN 7512 governs what exchanging parties promise each other. NEN 7513 is the one that states requirements a piece of software meets directly.
| Standard | What FerroEHR ships | Tracker | What the deploying organisation must do |
|---|---|---|---|
| NEN 7510-1 and 7510-2, the management system and its controls | Technical controls an ISMS can point at, documented per control with their residual risk | shipped | Run the ISMS and hold the certification; a product cannot be certified against a management-system standard |
| NEN 7512, the trust basis for data exchange | Mutually authenticated TLS (IHE ITI-19), OAuth2 and OIDC with an enterprise identity provider, signed and verifiable releases | shipped | Agree the trust basis with each counterparty and operate the certificate estate |
| NEN 7513, logging actions on electronic patient records | An IHE ATNA trail that records every operation including refusals, in FHIR AuditEvent and DICOM PS3.15 form, hash-chained in the database and retrievable per patient | shipped | Map the recorded fields onto the standard’s own list, set retention, and review the trail |
Germany: BDSG, SGB V, GDNG and § 203 StGB
Four federal acts sit on top of the GDPR for a German care provider. The BDSG carries the national health-data ground and the research derogation; SGB V §§ 341 to 355 define the elektronische Patientenakte (ePA) and §§ 360 to 363 the telematics infrastructure, as rewritten by the Digital-Gesetz; the GDNG governs secondary use; and § 203 StGB is the professional secrecy every access by a non-clinician is measured against. State law (the Landeskrankenhausgesetze and the state data protection acts) is not covered here; a hospital reads its own state’s law beside this section.
FerroEHR is not an ePA, holds no telematics-infrastructure component, and performs no electronic health professional card (HBA) or institution card (SMC-B) authentication. Where a provision below binds the ePA operator, the health insurer or gematik, the row says so.
| Provision | What FerroEHR ships | Tracker | What the deploying organisation must do |
|---|---|---|---|
| BDSG § 22 Abs. 1 Nr. 1 lit. b and Abs. 2, the health-care ground for special categories and the measures named for it: traceability of who entered, changed or removed data, access restriction inside the controller and its processors, pseudonymisation, encryption | An append-only version history where every write commits a contribution and its audit in one transaction, an access trail of every read, write and refusal, deny-by-default authorization with read-only roles, the three-schema pseudonymisation boundary, TLS 1.3 and national identifiers sealed at rest | shipped, #3153, #3155 | Establish that the processing is by, or under the responsibility of, persons bound by professional secrecy, as lit. b requires; the Abs. 2 list is a menu of measures the law names, and the duty is to choose appropriate and specific ones |
| BDSG § 27 Abs. 3, research: identifying characteristics stored separately and rejoined only as the research purpose requires, anonymised as soon as possible | Clinical, demographic and linkage data in separate schemas under separate roles; cohort queries that cross the boundary on identifiers only, with small-cell suppression | shipped, #3158, #3159; research pseudonyms are made in FerroBRIDGE, which loads the research database (Secondary use) | Decide when the research purpose no longer needs the link and anonymise; hold the balancing test Abs. 1 asks for |
| BDSG § 35 Abs. 2 and 3, restriction of processing in place of erasure where erasure would harm the subject or a retention period bars it | The same restriction register the GDPR Art. 18 row above describes, at whole-EHR or single-object grain, with national as the ground a member-state rule like this one is recorded under | shipped, #3324 | Decide per request whether Art. 17 or the restriction applies, record which of Abs. 2 or Abs. 3 it rests on, and notify the subject of a restriction |
| SGB V § 339 Abs. 3 and 5, § 352, § 361, access to the ePA and to prescriptions only with a professional credential, a verifiable log of who accessed which data and who authorised a delegated access, and a closed per-role matrix of read and write scopes | Role- and attribute-based authorization over the roles the identity provider asserts, and an access trail naming the agent, its roles, the patient, the action and the outcome, refusals included | shipped | Bind the identity provider to the HBA and SMC-B credentials; these sections bind access to the ePA itself, which the CDR is not, and the CDR encodes the § 352 matrix only as far as the roles reach it |
| SGB V §§ 346 to 348, the duty to write treatment data into the patient’s ePA, conditioned on the provider’s system holding it in semantically and syntactically interoperable form, with discharge letters, laboratory and imaging reports named outright | Every record is structured against an openEHR template and served over the REST API and as an EHR Extract, so a hospital’s data meets the condition that triggers the duty | shipped | Run the transport into the ePA: the connector, the telematics infrastructure and the information objects § 355 prescribes are outside the CDR |
| SGB V § 347 Abs. 1, 4 and 6, § 348 Abs. 3 to 6, § 353 Abs. 3 and 5, every objection, consent and refusal ground recorded verifiably in the treatment documentation; genetic results only by the responsible physician on explicit consent | A consent, objection or refusal recorded as a composition is versioned, attributed and audited like every other entry; FerroEHR ships no dedicated consent register and no per-category export gate | shipped, as versioned content | Model the consent and objection records in your templates, and gate the ePA transfer on them in the transport |
| SGB V § 309, the telematics-infrastructure access log: attempted accesses as well as successful ones, who accessed which data, three years’ retention and deletion on expiry, person-identifiable from 2030 | The trail records attempts and refusals with the agent and the object touched; [audit.store] retention_days and the retention reaper implement the period, and [audit.store] sgb_v_309_controller turns Abs. 3 into a boot-checked ceiling that refuses any horizon above the three-year period, keep-forever included | shipped, #3346 | This section binds the controllers of TI applications under § 307; a CDR outside the TI is not one. Set the declaration only if you are one, set the retention you owe, and review the trail |
| SGB V § 355, interoperability of ePA content: international standards, the BfArM terminologies, a versioned terminology server | openEHR archetypes and templates as the content model, terminology bindings resolved at commit time against any FHIR R4B terminology server through [terminology.server], which is the interface gematik’s terminology server exposes | shipped | Point [terminology.server] url at the server gematik runs and adopt the information objects the KBV publishes; the text names no serialization, and neither does this page |
| SGB V § 360 Abs. 16, electronic prescription transport outside the telematics infrastructure is prohibited | Nothing, and nothing planned: the CDR carries no prescription transport | not planned | Run prescriptions through the TI |
| GDNG § 6, a provider’s own patient data reprocessed for quality assurance, research and statistics: pseudonymised, anonymised as soon as possible, under a rights-and-roles concept with logging, deleted after thirty years at the latest, and disclosable to the subject by kind, scope and concrete purpose | Per-domain roles that keep a research credential off the identifying data, cohort queries named by a digest of their predicates and withheld under a small-cell threshold, an access record per query carrying the deployment’s declared purpose and legal_basis, and the retention register as where the thirty-year limit for the repository’s own copies is written down and read back | shipped, #3159, #3346; research pseudonyms are made in FerroBRIDGE, which loads the research database (Secondary use) | Write the rights-and-roles concept, publish the purposes, run the thirty-year clock against the register, and answer subjects’ requests from the trail |
| SGB V § 290, the Krankenversichertennummer every insured person’s record carries | The identifier scanner’s de-kvnr rule refuses the number’s unchangeable part in clinical content, transcribed from the GKV-Spitzenverband’s Richtlinie; no access-log retention floor is registered for Germany, because the BDSG sets none for a hospital and SGB V § 309 binds the telematics-infrastructure controllers | shipped, #3318 | Keep the number out of the clinical record as the scanner asks, and hold it sealed in the demographic domain |
| § 203 StGB Abs. 3 and 4, disclosure to persons who keep the systems running is lawful only as far as their work requires it, and the professional is liable for not binding them to secrecy | Operations reach the CDR through separate surfaces (management, admin, clinical), every admin read of clinical content is an access record naming the caller, the database roles hold one domain each, and secrets are mounted rather than baked | shipped | Bind every operator and subcontractor to secrecy in writing, and give support a route that needs no standing read of clinical content |
Switzerland: DSG, DSV, EPDG and its ordinances
Switzerland is not an EU member state, so a Swiss deployment processes under the Datenschutzgesetz (DSG) and its ordinance, the DSV, in force since 1 September 2023, and the GDPR does not apply to it. The EPDG with the EPDV and the EPDV-EDI govern the elektronisches Patientendossier (EPD), a retrieval layer run by certified communities over records that stay where they are. Cantonal health and data protection law is not covered here. The German text is the authentic one; Fedlex’s English translations of the DSG and DSV are vendored beside it and bind nobody.
FerroEHR is not an EPD community and holds no certification. Where a provision binds a community or the identity issuers, the row says so.
| Provision | What FerroEHR ships | Tracker | What the deploying organisation must do |
|---|---|---|---|
| DSG Art. 7, data protection by design and by default: processing arranged from the planning stage so the Art. 6 principles hold, and defaults that limit processing to the minimum | Deny-by-default authorization, tenancy that fails closed, audit on by default, and a production deployment profile that refuses to start while a separation is missing | shipped | Choose the restrictive settings. Two defaults favour compatibility instead: the per-EHR access default is open, and the audit fail mode is open |
| DSG Art. 8 and DSV Art. 2 and 3, the minimum security measures: need-to-know access control, storage and transport control, restoration, malfunction reporting, system security, entry control, disclosure control, breach detection | Role- and attribute-based authorization and per-EHR EHR_ACCESS; one database role per domain; TLS 1.3 with optional mutual authentication; health and readiness endpoints; a locked, advisory-gated dependency set and signed releases; every write attributed and versioned; an access trail naming who read what, refusals included | shipped | Run the backup and restore path, patch on the release cadence, and detect breaches from the trail and the metrics; the DSV names the controls, the deployment supplies everything below the application |
| DSV Art. 4 Abs. 1, 4 and 5, logging for large-scale processing of sensitive data: storing, changing, disclosing, deleting, destroying and reading, each entry with the actor, the kind, the date, the time and the recipient, kept at least one year separately from the processing system and readable only by the oversight roles | The trail records every operation including reads and refusals with the agent, the patient, the action, the outcome and the time, in a dedicated audit schema retrievable by an admin caller only, with forwarding sinks (syslog, IHE ITI-20) that put a copy outside the system; [audit.store] retention_days sets the period. The separation Abs. 5 asks for is the deployment’s DSN choice, and the server reports on it: the audit pool’s own cluster is read at boot like every other domain’s, so an audit database sharing a cluster with a pseudonymisation domain is a shared_cluster gap on /management/status rather than an invisible co-location | shipped for the trail and the posture reading; the separation itself is the deployment’s, #3398 | Forward the trail to a repository outside the CDR, set the retention at a year or more, and restrict who may read it; the one-year floor is enforced at boot once a Swiss identifier rule is active, #3319 |
| DSG Art. 12 and DSV Art. 24, the register of processing activities, from which large-scale processing of sensitive data has no small-organisation exemption | The effective configuration as a redacted JSON tree at GET /ferroehr/rest/extensions/v1/admin/config, records of processing pre-filled with what the software does, and the retention and security-measure inputs Art. 12 Abs. 2 lit. e and f ask for | shipped | Write and maintain the register; the software cannot know your purposes or recipients |
| DSG Art. 22 Abs. 2 lit. a, a data protection impact assessment before large-scale processing of sensitive data | The DPIA page with the processing description, the data categories per schema, the roles, the retention, a risk register and the shipped controls by issue | shipped, #3161 | Run the assessment; it is the controller’s. Art. 22 Abs. 5 lets a private controller skip it for a system certified under Art. 13, and FerroEHR holds no such certification |
| DSG Art. 24, notifying the EDÖB of a breach as soon as possible, with its kind, consequences and measures | The evidence a notification is written from: the trail with refusals attributed to the caller, the hash chain that shows tampering, the boot-time domain self-check | shipped | Detect, assess and notify; nothing in the software sends a notification |
| DSG Art. 25 Abs. 2, 3 and 7, DSV Art. 16 and 18, the right of access within 30 days: the data themselves, the purpose, the retention, the provenance and the recipients, in an understandable form, health data deliverable through a designated health professional | The full record over the openEHR REST API and as an EHR Extract, a per-patient search of the trail that answers who received the data, and the retention register, which is where the Abs. 2 lit. d answer, the retention period or the criteria that fix it, is read from rather than recalled | shipped, #3346 | Identify the requester (DSV Art. 16 Abs. 5), render the answer in an understandable form, route it to the patient or the designated professional, and meet the 30 days |
| DSG Art. 28 and DSV Art. 21, data portability in a common electronic format that another controller can reuse | Canonical openEHR JSON and XML, the simplified FLAT and STRUCTURED formats, and the EHR Extract, all published open formats | shipped | Decide which data are subject-provided or observed and which are derived (DSV Art. 20 Abs. 2), and export accordingly |
| DSG Art. 6 Abs. 4 and Art. 31 Abs. 2 lit. e, destruction or anonymisation once the purpose lapses, and research on the condition that data are anonymised as soon as the purpose allows, with measures against identifiability meanwhile | Clinical, demographic and linkage data in separate schemas under separate roles, cohort queries with small-cell suppression, admin deletion and archival of a record, and the retention register that says when a purpose has lapsed and lists what is due | shipped, #3158, #3159, #3346; research pseudonyms are made in FerroBRIDGE, which loads the research database (Secondary use) | Set the retention the cantonal health law requires, decide when anonymisation is possible, and hold the research ground |
| EPDG Art. 9 and 10, EPDV Art. 1 to 4, 10 and 12, EPDV-EDI Anhang 5, the EPD: three confidentiality levels and grantable access rights, emergency access with notification of the patient, every processing logged and the logs kept ten years, EPD data stored separately and encrypted, data stores in Switzerland, and the IHE profiles a community speaks (ATNA with Record Audit Event ITI-20 and Authenticate Node ITI-19, CH:ATC with Retrieve ATNA Audit Event ITI-81, IUA, XUA, XDS, MHD) | What a community integration consumes: IHE ATNA audit events over ITI-20 with ITI-19 mutual TLS and ITI-81 retrieval, the record in published formats, a self-hosted deployment with no dependency outside the operator’s control, and the retention register for Art. 10’s twenty-year destruction, with the per-object hold Abs. 2 lit. b asks for | shipped, #3346; an access declared with an emergency purpose is marked in the access log, but FerroEHR lifts no restriction for it and notifies no one; it encrypts national identifiers only, so encryption at rest is the database’s and the disk’s | These provisions bind the certified community, which the CDR is not. Feed the EPD through a community’s access point, place the data stores in Switzerland, and carry the confidentiality level on each document you contribute |
| EPDV Art. 5 and 7, EPDV-EDI Anhang 1, AHVV Art. 133, the patient identification number (eighteen digits with a published check digit, entered manually only under a check-digit control, never a source of inference about the patient) and the AHV number it is issued from | The identifier scanner’s ch-epd-pid and ch-ahvn13 rules refuse either number in clinical content, transcribed from the ordinance’s annex and the BSV’s Wegleitung; the access-log retention floor of DSV Art. 4 Abs. 5 is enforced once a Swiss rule is active | shipped, #3319 | Treat the number as opaque, and keep the AHV number out of clinical content |
Where to go next
- Control matrix: the machine-generated status of every declared control, straight from the tracker.
- Shared responsibility: what a FerroEHR deployment sends out by default, and where FerroEHR documents its side of each obligation; the tables, obligation by obligation, are in the FerroHEALTH book.
- Complaints, incidents and vulnerabilities: how to report about FerroEHR, FerroEHR’s registers, and what happens when a release is withdrawn.
- Intended purpose: FerroEHR’s part of the manufacturer’s statement: its essential functions, the data it is designed to process, the environment it assumes and the misuse it foresees.
- Cyber Resilience Act: FerroEHR’s CRA documents and product facts, and the class I question.
- CRA risk assessment: the cybersecurity risks, and how each point of CRA Annex I applies, with its evidence and its open work.
- Hazard log: the patient-safety hazards of the access log, each with its control and the test that shows it.
- Claims review: every public claim read against EHDS Art. 28, release by release.
- Information sheet and instructions for use: what accompanies each release: the manufacturer, the version, the data categories, the standards, an EU deployment’s configuration, maintenance and the known limitations. They are the EHR system’s, FerroEHR with FerroBRIDGE, and live in the FerroHEALTH book; each release attaches the revision it pins with its own versions appended.
- Security: how each control is configured.
- Threat model: what survives each control.
- Audit trail: what is recorded, in which formats, and how to read it back.
- Data protection impact assessment: the technical description, the risk register, and the shipped controls by issue number.
- Records of processing: an Art. 30 template pre-filled with what the software does.
- Go-live checklist: what to verify before a deployment holds real patient data.
Shared responsibility
Which obligation under EU and national health-data law the software can carry, and which stays with the organisation that deploys it, is drawn once for the EHR system FerroEHR and FerroBRIDGE form together. The tables, one per law (the GDPR, the EHDS, the CRA, and the national sections for the Netherlands, Germany and Switzerland), and the account of when Cadasto B.V. is also your processor, are published in the FerroHEALTH book: The EHDS EHR system: shared responsibility.
This page keeps what is particular to a FerroEHR deployment.
What a FerroEHR deployment sends out by default
The one thing a FerroEHR deployment sends out by default is the usage report: a random instance id, the version, the licence grant type and coarse performance figures, with no patient data. Cadasto B.V. receives it as controller for its own purposes, and its collector also stores the IP address each report comes from. The usage report page says how to turn it off.
Where FerroEHR documents its side
The middle column of each table in the FerroHEALTH book, what the software provides, points at FerroEHR’s own pages:
- Compliance overview: the legal sources, and what FerroEHR ships, act by act and country by country, with the tracker issue for each control.
- Control matrix: the live status of every declared control, generated from the tracker.
- Security and the threat model: how each control is configured, and the risk that survives it.
- Audit trail: what the access log records and how to read it back.
- Retention, restriction and objection: the retention register, the restriction of processing and the research objection.
- Data protection impact assessment, records of processing and the go-live checklist: the companion guidance for the controller’s own documents.
- Intended purpose: the security environment FerroEHR assumes the deployment provides.
None of these pages tells you whether your deployment satisfies an obligation. That depends on your legal basis, your organisation, your infrastructure and your operating practice, none of which a supplier can see.
Retention, restriction and objection
Three marks sit beside the clinical content, and none of them is an openEHR concept. The specifications say what a repository must keep and how versions relate to each other; they say nothing about a period after which data should go, about limiting the processing of a record that stays, or about a patient who does not want their data used for research. The law says all three, so FerroEHR carries them as its own extension and this page is where they are written down.
Nothing here deletes anything. A record in an openEHR repository is indelible by design — a deletion commits a new version whose content is removed, and the history stays — so a retention period in FerroEHR produces a list, and acting on that list is the controller’s decision, taken record by record and carried out through physical deletion.
The retention register
Two tables and a view in the clinical domain, all driven through the admin API.
retention_policy is the register: one row per content category per
jurisdiction, carrying the period, what the period is measured from, and the
legal citation the period rests on.
| Column | Meaning |
|---|---|
kind | The content category: COMPOSITION, EHR_STATUS, FOLDER, or EHR for the whole record |
category | Optional: the EHDS category the period is keyed on, named as [audit.categories] names it: a priority category by its code (Patient-Summaries, Electronic-Prescriptions, Electronic-Dispensations, Medical-Imaging, Laboratory-Reports, Discharge-Reports) or as <system>|<code>, a declared national category as its <system>|<code>, or none; stored as the <system>|<code> token; empty keys the period on every object of the kind |
origin | Optional: the origin the period is keyed on, the id of an origin [origins] registers; empty keys the period on every origin |
jurisdiction | ISO 3166-1 alpha-2, the same key the identifier scanner rules use |
period | How long content of that category is kept |
anchor | What the period is measured from: last_commit, death, or majority |
source | The citation the period comes from, quoted as the act spells it |
retention_anchor is the per-EHR half: the jurisdiction whose periods apply,
the anchor instant once the deployment knows it, and any hold that suspends
disposal for the whole record with the reason it was placed.
retention_due joins the two and lists what has run out. Each row names the
EHR, the kind, the priority category and the origin where the period is keyed
on one, the citation, when the period expired, and two counts: the objects that are due,
and the objects exempted by a per-object hold.
Ship the register empty and declare the periods your organisation is subject to. The periods are not the software’s to choose — they follow from the law the deployment runs under, and two of the provisions in the compliance overview ask for exactly this register: GDPR Art. 30(1)(f) wants the envisaged time limits for erasure per category of data in the record of processing activities, and Swiss DSG Art. 25 Abs. 2 lit. d gives the subject a right to be told the retention period or the criteria that fix it.
Declaring a period
PUT /ferroehr/rest/extensions/v1/admin/retention/policy
Content-Type: application/json
{
"kind": "EHR",
"jurisdiction": "CH",
"period": "20 years",
"anchor": "last_commit",
"source": "EPDV Art. 10 Abs. 1 lit. d"
}
204 on success, replacing any earlier period for that kind, category,
origin and jurisdiction. An unknown kind or category, an unregistered origin,
an unknown anchor rule or a
non-positive period is 400. GET /ferroehr/rest/extensions/v1/admin/retention/policy reads the
whole register back, which is what an access answer and an audit both cite.
Keying a period on a priority category
EHDS Annex II 3.4 asks the storing components to support retention periods
“that take into account the origins and categories of electronic health data”.
Add "category": "Laboratory-Reports" to the body above and the period covers only
the objects your [audit.categories]
map places in that category. The server mirrors the map into the clinical
domain at every boot, so the due list reads the map in force. An object’s
category comes from its latest version that carries content: the template id
first, then the root archetype id; every kind other than COMPOSITION reads as
none. An object the map cannot classify is listed under the longest period
configured for its kind, so a missing map entry can only keep content longer. A
category row with no object is left out of the due list, and a period without a
category lists every object of its kind.
Keying a period on an origin
Every version records its origins at commit: each FEEDER_AUDIT
originating-system id the body carries (RM common, FEEDER_AUDIT_DETAILS), or,
for a body that carries none, the origin of the client that committed it. The
ids a sending system writes are free text, so you declare the origins your
deployment recognises in [origins], each by the id its data carry and with the
clients whose direct commits come from it:
[[origins.registered]]
id = "lab.example" # the FEEDER_AUDIT system_id the lab's feed writes
clients = ["lab-gateway"] # a Basic user, or an access token's client_id (else azp)
A body without a FEEDER_AUDIT committed by lab-gateway is then stamped
lab.example; a direct commit by any other caller keeps this server’s
system_id. Add "origin": "lab.example" to a period and it covers only the
objects whose latest version carrying content came from that origin. A period
names only a registered id, so a misspelt origin is refused with 400
instead of keying a period that reaches nothing. A period without an origin
lists every object of its kind, and an origin row with no object is left out of
the due list. Ids compare exactly. Origin and category combine: a period keyed
on both lists the objects of that category from that origin.
Anchoring an EHR and holding it
PUT /ferroehr/rest/extensions/v1/admin/retention/anchor
Content-Type: application/json
{
"ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
"jurisdiction": "CH",
"anchored_at": "2026-01-31T00:00:00Z"
}
Leave anchored_at out until the anchor event is known; nothing is due without
it. Add hold_at and hold_ground together to suspend disposal for the whole
record: a hold with no stated reason is refused with 400, because a hold
nobody can account for is not a record of anything.
A hold at object grain is a separate route, and it is the shape Swiss EPDV Art. 10 Abs. 2 lit. b asks for, where the patient may ask that named data be exempted from the destruction the same article otherwise requires:
POST /ferroehr/rest/extensions/v1/admin/retention/hold
Content-Type: application/json
{ "vo_id": "df58b2ee-30bd-4b2c-9b7d-3a0f8e5c6d21", "held": true }
Reading what is due
GET /ferroehr/rest/extensions/v1/admin/retention/due?limit=100
Oldest first. Every row carries objects_due and objects_held, so a
controller acting on the list disposes of the first and leaves the second where
they are. An EHR under a whole-record hold does not appear at all.
Restriction of processing
GDPR Art. 4(3) defines restriction as marking stored data to limit its future processing, and Art. 18(2) says what is left once the mark is set: storage, and nothing else without the subject’s consent or one of the named exceptions. FerroEHR implements that literally, at two grains — a whole EHR, or one versioned object inside it.
A restricted object stays in storage untouched, and every path that would process it stops:
- a point read, a versioned read and a revision history answer 403 with a body naming the restriction — distinct from the authorization 403, because the same caller with the same rights is refused until the restriction is lifted;
- AQL results omit it, at every scope, so naming the
ehr_iddoes not make the content answerable; - an EHR Extract omits a restricted object, and refuses outright for a restricted EHR;
- the change-event stream neither emits nor delivers an event about it;
- a write to it is refused, and nothing already stored changes.
The restriction refusal is the last gate on a write. The checks that need no
transaction run first: a body the template refuses is 422, and a write to an
EHR whose is_modifiable is false, or a second directory for one EHR, is
409. Only the commit transaction itself reads the mark and answers 403.
The order is deliberate. Validation stays outside the transaction so the
transaction is short, and the mark is read inside it so a concurrent lift either
commits before the write and is seen, or waits for it. A malformed write to a
restricted object therefore answers 422 rather than 403: the request is refused
either way and nothing stored changes, but the status names the gate that
stopped the write, not whether the restriction is in force.
GET /ferroehr/rest/extensions/v1/admin/restriction?ehr_id=… answers that.
POST /ferroehr/rest/extensions/v1/admin/restriction
Content-Type: application/json
{
"ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
"vo_id": "df58b2ee-30bd-4b2c-9b7d-3a0f8e5c6d21",
"ground": "gdpr-18-1-a",
"note": "accuracy contested on 2026-09-15"
}
Omit vo_id to restrict the whole record. ground is one of the four
Art. 18(1) points — gdpr-18-1-a through gdpr-18-1-d — or national for a
member-state rule that goes beyond them, which is how German BDSG § 35 Abs. 2
and Abs. 3 are carried.
POST /ferroehr/rest/extensions/v1/admin/restriction/lift with the same grain lifts it. The register
keeps the request and stamps it lifted rather than removing it, because
Art. 18(3) makes the sequence itself the obligation: the subject is informed
before the restriction is lifted, and a register that forgot the request
could not show that anyone was. GET /ferroehr/rest/extensions/v1/admin/restriction?ehr_id=… reads
the whole sequence back. Lifting a whole-record restriction leaves an
object-scoped one standing on its own.
Objection to research processing
GDPR Art. 21(6) gives the subject a right to object to processing for scientific or historical research or statistical purposes under Art. 89(1), unless the processing is necessary for a task carried out for reasons of public interest. That is narrower than Art. 18: it reaches research, not care.
While an objection stands, the EHR is absent from every full-population AQL
query, from every export, and from the change-event stream — the three surfaces
a secondary-use consumer reads this repository through. A query that names the
ehr_id and a read of the record by the treating clinician are untouched.
POST /ferroehr/rest/extensions/v1/admin/research-objection
Content-Type: application/json
{ "ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398", "objected": true }
Send "ground": "…" beside "objected": true to record the public-interest
ground the article admits for an override: the EHR returns to the research
population and the ground says on whose authority. Send "objected": false to
withdraw the objection entirely; a withdrawal carries no ground, because there
is nothing left to override.
The audit trail, and the access-log ceiling
Setting and lifting any of these marks is an access record of its own, naming the EHR, the object where the act was object-scoped, and which register moved. These are the acts a supervisory authority asks about after the fact, so they are in the trail beside the reads and writes.
The trail has a retention question of its own, and it runs the other way: the law sets a floor below which an access log may not be reaped (three years from each date of access in every EU Member State under EHDS Art. 9(2), five years in the Netherlands, one year in Switzerland), and one national rule sets a ceiling. SGB V § 309 Abs. 1 asks the controllers of a German telematics-infrastructure application to keep access logs for the three-year limitation period, and Abs. 3 requires deletion without delay once it has run. That provision binds the controllers § 307 names, which no software can infer, so the deployment declares it:
[audit.store]
retention_years = 3
sgb_v_309_controller = true
With the declaration in place, the server refuses at boot any horizon above the
ceiling, including retention_days = 0 (keep forever), and refuses a
configuration whose floors and ceilings contradict each other outright, rather
than silently preferring one.
Germany is a Member State, so the EHDS floor of three years applies there as
well. Both rules are three calendar years, which is why the horizon above is
stated in years: retention_years = 3 meets the floor and the ceiling exactly,
and the reaper deletes what is older than the same calendar date three years
ago. A day count cannot: three calendar years span 1095 or 1096 days, so a
floor in days has to be 1096 and a ceiling 1095, and with
sgb_v_309_controller = true the server refuses every retention_days value
and names retention_years = 3 as the fix.
Retention of the trail itself is described in
Audit.
What this page does not claim
The registers record decisions and make them checkable. They do not make a deployment compliant with any of the provisions named above: the periods, the grounds, the notifications, the weighing of an objection and the decision to dispose of a record are all the controller’s, and the shared responsibility page says which side of the line each one falls on.
Secondary use
Research data leaves FerroEHR through FerroBRIDGE, which loads the OMOP Common Data Model in batches over AQL. FerroEHR holds the record and the marks that decide what may leave; FerroBRIDGE runs the batch, pseudonymises for research and maps to OMOP. Every edge between the two is a published specification: FerroBRIDGE reads through the openEHR REST API, and FerroEHR keeps no outbox or read model for it.
This page is the contract between the two: what FerroBRIDGE’s batch asks, what FerroEHR answers, and what each side records.
The batch query
FerroBRIDGE’s etl run sends one AQL query per batch through
POST /query/aql (ITS-REST 1.1.0, execute_ad_hoc_query):
SELECT e/ehr_id/value AS ehr_id, v/uid/value AS version_uid,
vo/uid/value AS versioned_object_uid, c AS composition
FROM EHR e CONTAINS VERSIONED_OBJECT vo
CONTAINS VERSION v[LATEST_VERSION] CONTAINS COMPOSITION c
WHERE v/commit_audit/time_committed/value > $since
ORDER BY v/commit_audit/time_committed/value, v/uid/value
$sinceis the instant of the last row the previous run loaded (etl run --since). The answer holds every composition whose latest version was committed after it: a new composition, and a new version of an older one.- The order is the commit instant, then the version uid. Every version of
one contribution carries the same
time_committed(RM commonmaster06-change_control_package.adoc§Contributions), so the instant alone leaves ties. UnderLATEST_VERSIONeach versioned object answers one version, so the uid makes the order total, the same order astime_committed,vo_idand version number. - Paging uses the
offsetandfetchmembers of the request. With a total order, consecutive windows concatenate to the whole answer, with no row skipped or repeated. - The plan runs the
time_committedpredicate as a range scan on a BRIN index over the commit instant, so a batch reads the versions committed after$since, not the whole store.
What leaves the batch
The batch is a population query: it names no ehr_id. So the marks on
Retention, restriction and objection take an EHR out of it:
- an EHR or object under a restriction of processing (GDPR Art. 18);
- an EHR under a research objection (GDPR Art. 21(6)), unless the controller recorded the public-interest override;
- an EHR whose
EHR_STATUS.is_queryableis false (RM ehr master04 §EHR Status).
The rows carry what the query projects. The query above projects the
ehr_id, the uids and the composition, and no identifier of the subject: the
subject’s identifiers live in EHR_STATUS and in the demographic domain,
which the batch does not read.
The integration test secondary_use_batch in app/ferroehr/tests/it/ holds
this contract. It commits versions before and after an instant, restricts one
EHR and records an objection on another. It then checks that the batch answers
exactly the later versions of the unmarked EHR, in order, that its offset
windows make up the whole answer, and that no row carries the subject’s
identifier.
Removing what was erased
ITS-REST 1.1.0 defines no change notification, so FerroBRIDGE learns of an
erasure by asking. Each run reconciles every composition its watermarks name
against GET /ehr/{ehr_id}/composition/{versioned_object_uid}. An answer
that the composition is gone removes its rows from the CDM. A restriction or
an objection set after a row was loaded takes the EHR out of later batches;
removing rows already loaded is FerroBRIDGE’s side of the contract.
The access record
Every batch, export and cohort query is recorded in the
access log with the purpose of use the caller declared
([audit] purpose_header) and the data permit it acts under
([audit] permit_header, default x-data-permit). A health data access body
issues the permit (EHDS Art. 68). The record keeps the permit’s identifier, 1 to
128 characters of letters, digits and . _ : / # -. A value of any
other form is recorded as absent, so a header cannot carry free text or
personal data into the trail. The permit joins the tamper chain’s digest with
the rest of the record.
A deployment that serves secondary use sets both headers on FerroBRIDGE’s
requests, and agrees the purpose codes in [audit] purpose_codes.
Where pseudonymisation happens
FerroBRIDGE pseudonymises for research, as the trusted party between the holder and the researcher (EDPB Guidelines 01/2025 on pseudonymisation, §§ 116 to 120 and Example 5). FerroEHR gates the data: the marks above decide what leaves, the access record says who took it under which permit, and cohort queries answer an aggregate question without releasing the rows.
What this page does not claim
FerroEHR is not a health data access body and runs no secure processing environment. Issuing a data permit, checking the request against it, and releasing data to a researcher are the access body’s work (EHDS Chapter IV). FerroEHR records the permit the caller names; it does not check that the permit exists or covers the data.
Control matrix
FerroEHR is software. It is not a controller, not a processor and not a certified organisation, so this page makes no compliance claim on anyone’s behalf. It lists the technical controls the product ships or plans, and the article or clause each one is designed to support. Whether a deployment satisfies a legal obligation depends on how the deploying organisation runs it.
Every row comes from the tracker. A control is declared on the issue that delivers it, as a line in the issue body:
Control: <legal source> <article or clause>
The short name resolves to an official publisher URL from a registry inside the generator, so a legal citation on this page is never free text. An issue may declare several controls, one per line. The Control column carries the issue title, unless the body also states the control on its own line, which the generator prefers so that an issue titled as the defect it fixed does not read its defect as the control:
Control-text: <the control, stated as what the product does>
How this page is built
scripts/render/control-matrix.sh queries the tracker with the GitHub CLI,
joins each declared control to its legal source and to its current state, and
writes this file. The site deploy renders it from the tracker on every push
to main, which is what keeps a shipped control from sitting here as
“planned”. A CI job renders it for every pull request and fails the build
when the pull request does not carry the rows it changes.
- Shipped: the issue is closed as completed. The merged pull request that closed it is linked in the last column. The one pull request that closes a control regenerates this page as it will read after the merge, so the page never lags a shipped control.
- Planned: the issue is open. Whether work has started is the issue’s column on the public roadmap board, which this page does not copy: a status that lives in two places disagrees the day one of them moves.
- Not planned: the issue was closed without the control being built. The row stays visible so the record does not quietly lose it.
The page carries no generation timestamp and no build commit. Both change on every run or every push while the tracker has not moved, which would make the CI staleness check fail on days when nothing was wrong. When this page was last regenerated, and from which commit, is the file’s own git history.
Controls
| Legal source | Applies to | Article or clause | Control | Issue | Status | Closing PR |
|---|---|---|---|---|---|---|
| GDPR | EU | Art. 15(1) | A natural person cannot obtain their own access log: the only retrieval is admin-gated over the whole repository | #3240 | Shipped | #3263 |
| GDPR | EU | Art. 17(1) | Physical deletion of a party removes its externalised multimedia blobs | #3180 | Shipped | #3201 |
| GDPR | EU | Art. 18 | A restriction register at whole-EHR and single-object grain, with the marked object refused on every read, query, export, event and write path while its stored rows stay untouched | #3324 | Shipped | #3410 |
| GDPR | EU | Art. 21(6) | A per-EHR research objection that removes the record from population queries, exports and the event stream while leaving reads for care untouched, with the controller’s public-interest override recorded beside it | #3325 | Shipped | #3410 |
| GDPR | EU | Art. 25(1) | Contributor rules for personal data handling and a PR guard for privacy-boundary changes | #3167 | Shipped | #3172 |
| GDPR | EU | Art. 25(2) | Refuse identifying data on the clinical side and constrain the subject reference to a pseudonym namespace | #3154 | Shipped | #3187 |
| GDPR | EU | Art. 25(2) | The compliance layer assumes the Netherlands; it must be jurisdiction-pluggable | #3185 | Shipped | #3187 |
| GDPR | EU | Art. 25(2) | The identifier scanner does not run on verbatim-replay writes (EHR-Extract import, admin load), and the book says every clinical write is scanned | #3237 | Shipped | #3251 |
| GDPR | EU | Art. 25(2) | No database constraint holds the subject-reference shape: the CHECK #3154 promised was never built | #3241 | Shipped | #3265 |
| GDPR | EU | Art. 30(1)(f) | A retention register of the period per content category and jurisdiction with its legal citation, per-EHR anchors, whole-record and per-object holds, a view of what has run out, and a boot-checked ceiling on access-log retention | #3346 | Shipped | #3410 |
| GDPR | EU | Art. 32(1) | Deploy artifacts provision the demographic role split | #3179 | Shipped | #3193 |
| GDPR | EU | Art. 32(1) | Row-level security on demographic.national_identifier | #3219 | Shipped | #3223 |
| GDPR | EU | Art. 32(1) | Schema preparation runs on its own credential, never the clinical runtime role | #3224 | Shipped | #3229 |
| GDPR | EU | Art. 32(1) | A declared deployment profile: production refuses the postures it cannot prove, research says so in red | #3226 | Shipped | #3265 |
| GDPR | EU | Art. 32(1)(a) | Split demographic parties into a demographic schema with a non-overlapping runtime role | #3153 | Shipped | #3182 |
| GDPR | EU | Art. 32(1)(a) | National identifiers in the demographic schema: encrypted storage, keyed lookup, audited resolution | #3155 | Shipped | #3189 |
| GDPR | EU | Art. 32(1)(c) | Separate encryption keys and per-schema backup handling for the clinical and demographic domains | #3157 | Shipped | #3198 |
| GDPR | EU | Art. 32(1)(c) | The linkage schema is backed up and its boundary is probed | #3220 | Shipped | — |
| GDPR | EU | Art. 32(1)(d) | The storage-parity sweep and node rebuild cover the demographic domain | #3178 | Shipped | #3184 |
| GDPR | EU | Art. 32(1)(d) | The two-DSN credential separation is exercised by test | #3222 | Shipped | #3231 |
| GDPR | EU | Art. 4(5) | Split demographic parties into a demographic schema with a non-overlapping runtime role | #3153 | Shipped | #3182 |
| GDPR | EU | Art. 4(5) | Refuse identifying data on the clinical side and constrain the subject reference to a pseudonym namespace | #3154 | Shipped | #3187 |
| GDPR | EU | Art. 4(5) | Separate encryption keys and per-schema backup handling for the clinical and demographic domains | #3157 | Shipped | #3198 |
| GDPR | EU | Art. 4(5) | Linkage service: the party to EHR resolve map as its own schema and role | #3158 | Shipped | — |
| GDPR | EU | Art. 4(5) | The subject pseudonym is opt-in: nothing mints it, and an empty subject_namespaces is silent | #3232 | Shipped | #3266 |
| GDPR | EU | Art. 4(5) | The patient-to-EHR cross-reference in an IHE patient identity source asked over PIXm by default, the in-instance map an explicit opt-in, and no identity logic in the CDR | #3380 | Shipped | #4113 |
| GDPR | EU | Art. 5(1)(b) | Cross-domain cohort queries with a demographic predicate and a clinical selection | #3159 | Shipped | #3275 |
| GDPR | EU | Art. 5(1)(e) | Audit retention has no jurisdictional floor: retention_days=1 erases the access log daily while the chain still verifies | #3242 | Shipped | #3266 |
| GDPR | EU | Art. 5(1)(e) | A retention register of the period per content category and jurisdiction with its legal citation, per-EHR anchors, whole-record and per-object holds, a view of what has run out, and a boot-checked ceiling on access-log retention | #3346 | Shipped | #3410 |
| GDPR | EU | Art. 89(1) | Secondary use leaves through FerroBRIDGE to OMOP in batches over AQL, with the restriction, objection and retention marks kept in the CDR | #3379 | Shipped | #4122 |
| EHDS | EU | Annex II 3.2 | European logging software component: map EHDS logging requirements onto the access event model and ATNA trail | #3170 | Shipped | #3205 |
| EHDS | EU | Annex II 3.2 | Auditing off is one info-level line, and fail_mode=open is the silent default: make both visible where an operator looks | #3238 | Shipped | #3260 |
| EHDS | EU | Annex II 3.2 | The split clinical role holds no grant on the audit schema, so a separated deployment cannot write its own access log | #3267 | Shipped | #3270 |
| EHDS | EU | Annex II 3.2(a) | The access log records no accessing organisation (EHDS Annex II 3.2(a)) | #3204 | Shipped | #3213 |
| EHDS | EU | Art. 9 | A natural person cannot obtain their own access log: the only retrieval is admin-gated over the whole repository | #3240 | Shipped | #3263 |
| EHDS | EU | Chapter IV | Secondary use leaves through FerroBRIDGE to OMOP in batches over AQL, with the restriction, objection and retention marks kept in the CDR | #3379 | Shipped | #4122 |
| EDPB 01/2025 | EU | pseudonymisation domain | Split demographic parties into a demographic schema with a non-overlapping runtime role | #3153 | Shipped | #3182 |
| EDPB 01/2025 | EU | pseudonymisation domain | Linkage service: the party to EHR resolve map as its own schema and role | #3158 | Shipped | — |
| EDPB 01/2025 | EU | pseudonymisation domain | Cross-domain cohort queries with a demographic predicate and a clinical selection | #3159 | Shipped | #3275 |
| UAVG | NL | Art. 46 | Refuse identifying data on the clinical side and constrain the subject reference to a pseudonym namespace | #3154 | Shipped | #3187 |
| UAVG | NL | Art. 46 | National identifiers in the demographic schema: encrypted storage, keyed lookup, audited resolution | #3155 | Shipped | #3189 |
| Wabvpz | NL | Art. 15e | A natural person cannot obtain their own access log: the only retrieval is admin-gated over the whole repository | #3240 | Shipped | #3263 |
| NEN 7513 | NL | actor role | The access record carries no actor role or authorisation basis, which NEN 7513 requires and the RBAC layer already holds | #3239 | Shipped | #3262 |
| NEN 7513 | NL | event content | Per-domain access logging for reads and queries aligned with NEN 7513 and EHDS Art. 9 | #3156 | Shipped | #3188 |
| NEN 7513 | NL | event content | Domain-level access events discard their EmitOutcome, so fail_mode=closed does not cover them | #3235 | Shipped | #3246 |
| NEN 7513 | NL | retention | Audit retention has no jurisdictional floor: retention_days=1 erases the access log daily while the chain still verifies | #3242 | Shipped | #3266 |
| BDSG | DE | § 35 Abs. 2 and 3 | A restriction register at whole-EHR and single-object grain, with the marked object refused on every read, query, export, event and write path while its stored rows stay untouched | #3324 | Shipped | #3410 |
| SGB V | DE | § 309 Abs. 3 | A retention register of the period per content category and jurisdiction with its legal citation, per-EHR anchors, whole-record and per-object holds, a view of what has run out, and a boot-checked ceiling on access-log retention | #3346 | Shipped | #3410 |
| GDNG | DE | § 6 Abs. 1 | A retention register of the period per content category and jurisdiction with its legal citation, per-EHR anchors, whole-record and per-object holds, a view of what has run out, and a boot-checked ceiling on access-log retention | #3346 | Shipped | #3410 |
| DSG | CH | Art. 25 Abs. 2 lit. d | A retention register of the period per content category and jurisdiction with its legal citation, per-EHR anchors, whole-record and per-object holds, a view of what has run out, and a boot-checked ceiling on access-log retention | #3346 | Shipped | #3410 |
| EPDV | CH | Art. 10 | A retention register of the period per content category and jurisdiction with its legal citation, per-EHR anchors, whole-record and per-object holds, a view of what has run out, and a boot-checked ceiling on access-log retention | #3346 | Shipped | #3410 |
Legal sources
The short names above resolve to these publishers. The linked text is the authority; nothing on this page restates it.
EU and INT apply to every deployment. A two-letter country code is
national law or a national standard, and applies to a deployment in that
country: FerroEHR is an openEHR CDR, openEHR is not a Dutch standard, and a
deployment elsewhere answers to its own equivalents rather than to these.
Adding a jurisdiction is a registry entry plus the controls that cite it.
Cyber Resilience Act
The manufacturer’s side of the Cyber Resilience Act (CRA, Regulation (EU) 2024/2847) is the same for every FerroHEALTH product: who the manufacturer is, which duties apply from when, the reporting of actively exploited vulnerabilities and severe incidents, the support-period policy, how conformity is assessed through the EHDS, a deployment Cadasto B.V. hosts as a service, who the CRA binds in a self-hosted deployment, and the questions for counsel. That account is published in the FerroHEALTH book: The CRA.
Each FerroHEALTH product is a product with digital elements of its own, with its own risk assessment and its own Annex II information. This page keeps FerroEHR’s.
Warning
No conformity assessment has been carried out, no EU declaration of conformity exists and FerroEHR carries no CE marking. The CRA text this page cites is vendored at
docs/law/eu/cra/text.html(EUR-Lex).
The manufacturer and the product
Cadasto B.V. is the manufacturer of each tagged FerroEHR release
(The manufacturer). Each
tagged release is one product: the ferroehr binaries, the ferroehr,
ferroehr-viewer and ferroehr-postgres container images, the Helm chart and
the source archive. The nine openehr-* crates published on crates.io are
products of their own; the information for their integrators is on
Rust crates.
Vulnerabilities in FerroEHR are reported as
SECURITY.md
says.
FerroEHR’s CRA documents
- CRA risk assessment: the cybersecurity risks of FerroEHR, and how each point of CRA Annex I applies to it, with its evidence and its open work.
- CRA information and instructions to the user: the Annex II information for FerroEHR, point by point. The notes of each GitHub release link the copy of that page frozen for the release, and the release lane refuses to publish a release whose notes do not.
- Technical documentation: the single set for the EHDS and the CRA (CRA Art. 31(3) as EHDS Art. 104(2) replaces it): the EHDS Annex III elements of the EHR system in the FerroHEALTH book, at the revision each release pins, and FerroEHR’s CRA Annex VII points in the repository.
- Hazard log: the patient-safety hazards of the access log, the EHDS part of the risk assessment.
FerroEHR’s product facts under the CRA
- Not free and open-source software. FerroEHR is published under the Business Source License 1.1, whose Additional Use Grant allows production use for non-commercial purposes only, so it is not free and open-source software in the sense of CRA Art. 3(48) (licensing).
- Only the newest release is supported. Every earlier release stops
receiving fixes when a new one is published, and the remedy is to upgrade,
which is free. Cadasto B.V. commits to keep publishing security fixes for at
least five years after each release (Art 13(8)) and ships them only in the
newest release (Art 13(10)). The end month of that commitment is printed in
each release’s notes, and the statement is
SECURITY.md§ Supported versions. The reasoning is in the technical documentation (cra-annex-vii-4-support-period.md). - The conformity route. Cadasto B.V. declares FerroEHR, with FerroBRIDGE, as an EHR system under the EHDS, so the CRA requirements are assessed through the EHDS procedure (CRA Art. 32(5a), inserted by EHDS Art. 104(3)). The status per EHDS requirement is on EHDS readiness.
Default product, or important product of class I
Cadasto B.V. reads FerroEHR as a default product: its core functionality is storing, versioning and querying health records (intended purpose), and none of the categories of CRA Annex III names that function. The competing reading is Annex III class I point 1, “identity management systems and privileged access management software and hardware, including authentication and access control readers”: the technical description in Implementing Regulation (EU) 2025/2392 Annex I covers products “that provide mechanisms for authentication or authorisation”, and FerroEHR authenticates and authorises every request to the records it holds. Class I point 7, security information and event management, describes products that “collect data from multiple sources”; FerroEHR’s access log records FerroEHR’s own accesses.
For a class I product whose manufacturer has not applied harmonised standards, common specifications or a certification scheme in full, CRA Art. 32(2) requires a third-party procedure. This is a question for counsel, and nothing in this repository answers it.
CRA information and instructions to the user
The Cyber Resilience Act (CRA, Regulation (EU) 2024/2847) has each product “accompanied by the information and instructions to the user set out in Annex II, in paper or electronic form” (CRA Art. 13(18)). This page carries that information for FerroEHR, point by point in the order of Annex II. Each section quotes what the point asks and gives FerroEHR’s information for it, with the source the information is kept in.
Annex II opens: “At minimum, the product with digital elements shall be
accompanied by:”. The product is each tagged FerroEHR release, as Cadasto B.V.
places it on the market: the ferroehr binaries, the ferroehr,
ferroehr-viewer and ferroehr-postgres container images, the Helm chart and
the source archive (the manufacturer and the product).
- Which copy to read
- 1. The manufacturer
- 2. The single point of contact for vulnerabilities
- 3. Name, type and unique identification
- 4. Intended purpose, security environment, essential functionalities and security properties
- 5. Circumstances that may lead to significant cybersecurity risks
- 6. The EU declaration of conformity
- 7. Technical security support and the end of the support period
- 8. Detailed instructions
- 9. The software bill of materials
- Related
Warning
The quotations are from the Official Journal text vendored in the repository at
docs/law/eu/cra/text.html(EUR-Lex). No conformity assessment has been carried out, no EU declaration of conformity exists and FerroEHR carries no CE marking. This page gives the information Annex II lists; it does not say that FerroEHR, or a deployment of it, meets the Regulation.
Which copy to read
This book is frozen with every release, so the copy of this page that belongs
to release vX.Y.Z is
https://ferroehr.eu/docs/vX.Y.Z/compliance/cra-user-information.html. The
notes of each GitHub release link that copy, and the release lane refuses to
publish a release whose notes do not. The copy at /docs/dev/ describes the
code on main, which is not a release.
CRA Art. 13(18) has information provided online kept “accessible,
user-friendly and available online for at least 10 years after the product
with digital elements has been placed on the market or for the support
period, whichever is longer”. A frozen book version is never rebuilt or
deleted, and each release tag, which carries the page’s source, is protected
against deletion (SECURITY.md).
The information is written in English. Art. 13(18) asks for “a language which can be easily understood by users and market surveillance authorities”. No translation is published; a market surveillance authority that needs one asks the single point of contact below.
1. The manufacturer
“the name, registered trade name or registered trademark of the manufacturer, and the postal address, the email address or other digital contact as well as, where available, the website at which the manufacturer can be contacted;”
| Manufacturer | Cadasto B.V. |
| Postal address | Comeniusstraat 2d, 1817 MS Alkmaar, The Netherlands |
| info@cadasto.com | |
| Website | https://www.cadasto.com/contact/ |
The source of these details is app/ferroehr/src/manufacturer.rs, the one
place they are written in the code. The running server reads them from there
for its startup banner, ferroehr --version and GET /management/info, the
viewer shows them on every screen, and the container images carry them in the
eu.ferroehr.image.manufacturer label
(the manufacturer and the deployment report).
The information sheet repeats them.
Cadasto B.V. is the manufacturer of every FerroHEALTH product, and the
FerroHEALTH book describes it once for all of them:
The manufacturer.
2. The single point of contact for vulnerabilities
“the single point of contact where information about vulnerabilities of the product with digital elements can be reported and received, and where the manufacturer’s policy on coordinated vulnerability disclosure can be found;”
- Report a vulnerability to info@cadasto.com, with “FerroEHR vulnerability” in the subject, or through GitHub private vulnerability reporting. Both reach the same people and the same procedure. Do not open a public issue.
- Receive information about vulnerabilities from the GitHub security
advisories of the repository, published with the release that fixes each
one and readable by machine through
GET https://api.github.com/repos/FerroHEALTH/FerroEHR/security-advisories. - The coordinated vulnerability disclosure policy is the manufacturer’s,
shared by every FerroHEALTH product: the response times, the agreed
disclosure date, the safe harbour, the credit and what each advisory
carries are on
Security in the FerroHEALTH
book. What is particular to FerroEHR, the reporting routes and the scope, is
SECURITY.md§ Reporting a vulnerability, also published assecurity.txtunder RFC 9116.
3. Name, type and unique identification
“name and type and any additional information enabling the unique identification of the product with digital elements;”
- Name: FerroEHR.
- Type: software. A clinical data repository server that stores, versions and queries openEHR health records over the openEHR REST API and AQL.
- Version: the release tag
vX.Y.Z. The running server prints it inferroehr --versionand returns it asserver_versiononGET /ferroehr/rest/status;ferroehr reportadds the commit and the build date (ferroehr report). - The artefacts of a version: the release assets carry a checksum and a
signed build provenance each, and the container images are identified by
digest, with the version and the commit in their
org.opencontainers.image.versionandorg.opencontainers.image.revisionlabels (verifying releases). - Release date: the date on the release’s heading in the
changelog,
## [X.Y.Z] - YYYY-MM-DD(information sheet, (b)).
4. Intended purpose, security environment, essential functionalities and security properties
“the intended purpose of the product with digital elements, including the security environment provided by the manufacturer, as well as the product’s essential functionalities and information about the security properties;”
- Intended purpose: FerroEHR stores, versions and queries the structured health records of one healthcare provider, serves them to that provider’s clinical applications through the openEHR REST API and AQL, and records every access to them. FerroEHR’s part of the statement, with the data it is designed to process, is the intended purpose; the statement for the EHR system FerroEHR forms with FerroBRIDGE, with its users, is in the FerroHEALTH book (intended purpose).
- Security environment: what FerroEHR relies on and does not provide itself: TLS termination, an OpenID Connect identity provider, protection of the database, a hardened platform and off-box copies of the access log (the security environment FerroEHR assumes).
- Essential functionalities: listed in the intended-purpose statement (what FerroEHR is for).
- Security properties: each point of CRA Annex I Part I(2), with how FerroEHR implements it and the evidence, is in the CRA risk assessment. The controls themselves are described on Security, and the risk that remains at each trust boundary in the threat model.
5. Circumstances that may lead to significant cybersecurity risks
“any known or foreseeable circumstance, related to the use of the product with digital elements in accordance with its intended purpose or under conditions of reasonably foreseeable misuse, which may lead to significant cybersecurity risks;”
- Use in accordance with the intended purpose: the risk register of the CRA risk assessment rates each risk with the shipped controls, and the limitations you should know name the ones that matter before you rely on FerroEHR, among them the shipped defaults that favour a first boot and the reach of anyone with a database connection.
- Reasonably foreseeable misuse: the
foreseeable misuse table
lists each case (real patient data under the
sandboxprofile, authentication off, a plaintext listener, several organisations in one instance, a release run after a newer one is published, and others), what FerroEHR does against it, and what remains. - What is not defended against: the threat model lists it.
6. The EU declaration of conformity
“where applicable, the internet address at which the EU declaration of conformity can be accessed;”
No EU declaration of conformity has been drawn up, so there is no address to give. The declaration and the CE marking apply from 11 December 2027 (CRA Art. 71(2)). For FerroEHR, which Cadasto B.V. declares with FerroBRIDGE as an EHR system under the EHDS, the CRA asks for one declaration covering both acts (CRA Art. 28(3)). When it exists, it is published as an asset of each GitHub release it covers and as a page of the book frozen for that release, and this section gives its address. The record of the declaration is Annex III 6 of the EHR system’s technical documentation in the FerroHEALTH book.
7. Technical security support and the end of the support period
“the type of technical security support offered by the manufacturer and the end-date of the support period during which users can expect vulnerabilities to be handled and to receive security updates;”
- Only the newest release is supported. When a new release is published, every earlier release stops receiving fixes: there are no maintenance branches, no long-term-support line and no backports. If you are not on the newest release, upgrade; upgrading is free. Under the Cyber Resilience Act, Cadasto B.V. commits to keep publishing security fixes for at least five years after each release (Art 13(8)) and ships them only in the newest release (Art 13(10)).
- The end date, month and year, is the end of that five-year commitment
for a release: the date until which Cadasto B.V. keeps shipping fixed
releases. It is printed in the “Support period” section of the release’s
notes, and the running server states it on its boot banner, in
ferroehr --version, undersupportinGET /management/infoand assupport.statusinGET /management/status(how long the version you pinned is supported). A release itself is supported only while it is the newest. - The type of support: Cadasto B.V. handles vulnerabilities in FerroEHR, and the security update ships in the newest release, as a security-only patch where that is technically feasible. Every fixed vulnerability gets a GitHub security advisory with its severity, the affected and fixed versions, and what to do.
- The source:
SECURITY.md§ Supported versions, which also covers the Helm chart and theopenehr-*crates, and the reasoning in the technical documentation (cra-annex-vii-4-support-period.md).
8. Detailed instructions
“detailed instructions or an internet address referring to such detailed instructions and information on:”
The detailed instructions are the
instructions for use
of the EHR system, which the FerroHEALTH book carries and which ride each
GitHub release as ferroehr-vX.Y.Z-instructions-for-use.md, at the revision
the release pins and with its versions appended. Each point below names
the part that answers it.
8(a) Secure use from commissioning on
“the necessary measures during initial commissioning and throughout the lifetime of the product with digital elements to ensure its secure use;”
- Commissioning: check what you pulled
(verifying releases), install
(installing), set the deployment
profile to
productionand configure the access log, authentication and the pseudonymisation boundary (configuring a deployment in the European Union), then walk the go-live checklist before the instance holds real patient data. - Throughout its lifetime: the maintenance tasks and how often each falls due, from applying security updates to testing backups and acting on an access-log chain finding (maintenance, and how often).
8(b) How changes to the product can affect the security of data
“how changes to the product with digital elements can affect the security of data;”
- Configuration. The shipped defaults favour a first boot: the
sandboxprofile, TLS off, the open per-EHR access default and the access log failing open. Theproductionprofile refuses to start while one of the separations it checks is open and not accepted by name, and a gap you accept by name is stated on every boot and onGET /management/status(deployment_profile). A configuration change that opens such a gap shows there. - Integrations. Every outbound integration is off by default. Turning one on sends data to another system, and the outbound data flows table says what each one sends and where.
- Upgrades. A release can carry database migrations. They are append-only, and a rolling upgrade stays compatible with the previous schema for the window in which both versions run; take a backup first (upgrades).
- Changes to the source. A binary or image built from modified source is not a Cadasto B.V. release: its build provenance and signatures do not verify, and the risk assessment of the release does not describe it. Whether an organisation that modifies the source and puts the result into service becomes a manufacturer itself is a question for counsel (the CRA, the manufacturer side).
8(c) Installing security-relevant updates
“how security-relevant updates can be installed;”
Subscribe to the release feed
(https://github.com/FerroHEALTH/FerroEHR/releases.atom) and poll the
security advisories; the server does not look for newer releases itself.
Verify the new release (verifying releases), take
a backup, then deploy it by digest and let it apply its migrations
(upgrades). On Kubernetes that is a
helm upgrade to the chart version that ships the new release
(Kubernetes & Helm, upgrades).
8(d) Secure decommissioning, and removing user data
“the secure decommissioning of the product with digital elements, including information on how user data can be securely removed;”
Decommissioning takes an instance out of
service: what to export and keep first, ferroehr db erase with its dry run
and confirmation, which removes the multimedia blobs and every schema with all
data and settings, and what the erase cannot reach and you remove yourself
(backups, WAL archives, replicas, the chart’s Secrets and claims, the database
roles, configuration files, the data held by systems the instance sent to, and
the physical media). The command and the page are the deliverables of
#3642. To start again
from an empty instance of the same release, follow
returning to the original state.
8(e) Turning off automatic installation of security updates
“how the default setting enabling the automatic installation of security updates, as required by Part I, point (2)(c), of Annex I, can be turned off;”
FerroEHR installs no update by itself, so there is no such default setting to turn off. Annex I Part I(2)(c) asks for automatic security updates “where applicable”, and the risk assessment records why the limb does not apply to FerroEHR: it is a clinical server run under its operator’s change control, an upgrade can carry a database migration the operator schedules and backs up for, an update installed on the manufacturer’s schedule would restart the service at a moment no clinician or operator chose, and a channel into the deployment for the manufacturer would be an inbound path into a system holding special-category data (the justification). The operator installs each update as described in 8(c).
8(f) Information for an integrator
“where the product with digital elements is intended for integration into other products with digital elements, the information necessary for the integrator to comply with the essential cybersecurity requirements set out in Annex I and the documentation requirements set out in Annex VII.”
The FerroEHR server, its images and its chart are not intended for integration into another product with digital elements. Other software connects to a running instance over the openEHR REST API, as FerroBRIDGE does.
The nine openehr-* crates published on crates.io are libraries intended for
integration into other products, and each is a product of its own. The
information for their integrators is on
Rust crates § Information for integrators.
9. The software bill of materials
“If the manufacturer decides to make available the software bill of materials to the user, information on where the software bill of materials can be accessed.”
Cadasto B.V. makes the software bills of materials available. Each release
publishes a CycloneDX SBOM per server binary, an SPDX SBOM of the source tree,
an SBOM on each container image, a CycloneDX SBOM per openehr-* crate and an
SBOM attested on the Helm chart. Where each one is and how to verify it is in
the SBOMs, one per published artefact.
Related
- Cyber Resilience Act: the manufacturer, what applies from when, and the questions for counsel.
- Instructions for use and the information sheet: the documents that accompany each release under the EHDS.
- Complaints, incidents and vulnerabilities: the reporting channels and procedures.
Complaints, incidents and vulnerabilities
Cadasto B.V. is the manufacturer of every FerroHEALTH product, FerroEHR included, and runs one post-market procedure for all of them: complaints, serious incidents under the EHDS, actively exploited vulnerabilities and severe incidents under the CRA, corrective action, withdrawal and recall, security advisories, vulnerabilities in integrated components, cooperation with the authorities and the cessation of operations. That procedure, with its quotations from the regulations, is published in the FerroHEALTH book: Post-market. The manufacturer’s name, address and single point of contact are on The manufacturer.
This page keeps what is particular to FerroEHR.
Reporting about FerroEHR
- A vulnerability, including one you have seen exploited: report it
privately through
GitHub private vulnerability reporting on the FerroEHR repository, or
by email to info@cadasto.com with “FerroEHR
vulnerability” in the subject, as
SECURITY.mdsays. If you have seen it exploited, say so. - Anything that harmed a person, or could have: write to info@cadasto.com with “FerroEHR incident” in the subject.
- Any other complaint: write to info@cadasto.com with “FerroEHR complaint” in the subject, or open a GitHub issue when the report can be public.
Say which version you run (ferroehr --version, or the image tag) and how it
is deployed. Never send patient data: describe the case, or build a synthetic
one. A deployment attaches the JSON document ferroehr report writes: it
names the version, commit, features, specification pins, migration level,
deployment posture and redacted configuration, and carries no credential and
no patient data (the command).
FerroEHR’s registers
The registers of EHDS Art. 30(1)(o) and the upstream register of CRA Art. 13(6) are kept per product. FerroEHR’s are public, in its repository:
docs/registers/complaints.tsv: every complaint about FerroEHR, whatever the channel, with the versions it concerns, how it was classified, the record of the work on it and its outcome.docs/registers/non-conforming-versions.tsv: every finding that released FerroEHR versions do not conform, with the requirement missed, whether it was a serious incident or notified under the CRA, the corrective action, and when the authorities and the users were told.docs/registers/upstream-reports.tsv: every vulnerability reported to the maintainer of a component FerroEHR integrates, with the channel, the upstream reference, whether a fix was shared and the outcome.
No row names the person who complained or carries patient data, and the upstream register names components, never people. A vulnerability enters the registers when its advisory is published.
Possible serious incidents in FerroEHR
The manufacturer treats these FerroEHR events as possible serious incidents under EHDS Art. 2(2)(r): a committed version lost or attributed to the wrong EHR; a read or an AQL result that returns another patient’s data; data under a restriction of processing served on a path that should refuse it; an access the audit trail did not record; a signature verification that accepts a tampered version.
A withdrawn FerroEHR release
A published FerroEHR release is immutable, so a non-conforming version is
never changed or deleted. The fix ships in a new patch release. A withdrawn
version’s floating image tags (<major>.<minor> and latest of the server,
viewer and PostgreSQL images) move to the correcting release, and the version
is listed as unsupported in
SECURITY.md § Supported versions. Its own version tag, its
image digests and its chart version stay published, so a deployment pinned to
any of them keeps running the withdrawn version until the deployment moves.
Security advisories for FerroEHR are GitHub security advisories on the
FerroEHR repository, and the ### Security entry of its changelog carries
each advisory’s GHSA- identifier.
The maintainers’ step-by-step checklist for FerroEHR, with the release tooling
it runs, is
docs/post-market.md.
The technical documentation readiness page shows
what of the documentation an authority may ask for exists today.
Intended purpose
Cadasto B.V. states the intended purpose of the EHR system that FerroEHR and FerroBRIDGE form together, under the EHDS (Regulation (EU) 2025/327) and the Cyber Resilience Act (CRA, Regulation (EU) 2024/2847), once for both products: the statement and its version, who uses the system, how it is operated (self-hosted, or hosted by Cadasto B.V.), and what the intended purpose excludes, the medical-device purposes of the MDR among them. That statement is published in the FerroHEALTH book: The EHDS EHR system: intended purpose.
This page keeps FerroEHR’s part of it: its essential functions, the data it is designed to process, the environment it runs in and relies on, and the use and misuse that can be foreseen for it (CRA Annex II point 4, EHDS Annex III 1(b)). The CRA risk assessment, the hazard log and the claims review are written against it.
- What FerroEHR is for
- The data FerroEHR is designed to process
- The operational environment
- Reasonably foreseeable use and misuse
- What the intended purpose excludes
Warning
The quotations are from the Official Journal texts vendored in the repository under
docs/law/eu/. This page says what the manufacturer intends. It does not say that FerroEHR, or a deployment of it, meets either regulation: no conformity assessment has been carried out and no EU declaration of conformity exists.
What FerroEHR is for
FerroEHR stores, versions and queries the structured health records of one healthcare provider, and serves them to the software that provider’s health professionals and patients use. It holds each record as openEHR compositions with their full version history, and serves them through the openEHR REST API (ITS-REST Release-1.1.0) and the Archetype Query Language (AQL 1.1). It has no clinical user interface of its own: health professionals reach it through the clinical applications the provider connects to it.
FerroEHR records every access to the records it holds in its access log, the European logging software component of EHDS Art. 2(2)(o).
Its essential functions, in the sense of CRA Annex II point 4:
- storing EHRs, compositions, EHR status and folders with an append-only version history and contribution-atomic commits (storage);
- answering AQL queries over the stored records (querying);
- validating every write against the operational template it claims and the Reference Model invariants (templates and validation);
- keeping the identity of the record subject apart from the clinical record, in a separate demographic domain and a separate linkage domain (the pseudonymisation boundary);
- authenticating every caller and deciding every request (authentication, authorization);
- recording every access, refusals included, and serving the log back through IHE ITI-81 (audit trail);
- restriction of processing, the research objection and the retention register (retention, restriction and objection);
- physical deletion, whole-repository dump and load, and EHR Extract export (admin APIs).
The security properties that go with these functions are on the Security page; the risk that survives each control is in the threat model.
The data FerroEHR is designed to process
EHDS Annex III 1(b) and Art. 38(2)(d) ask for “the categories of personal electronic health data that the EHR system has been designed to process”.
Clinical content, in the clinical domain: whatever the operational
templates the deploying organisation loads describe. This includes the
priority categories of EHDS Art. 14(1): “(a) patient summaries; (b) electronic
prescriptions; (c) electronic dispensations; (d) medical imaging studies and
related imaging reports; (e) medical test results, including laboratory and
other diagnostic results and related reports; and (f) discharge reports”, and
any category a Member State adds in national law. Which of them an instance
holds depends on the templates it loads, and the deployment declares that in
the [audit.categories]
map, which classifies every access record. FerroEHR ships no such map. Images
are held as DV_MULTIMEDIA attachments, optionally offloaded to an
S3-compatible store (S3 multimedia), or as
references to an imaging archive; FerroEHR implements no DICOM image transfer.
Identities, in the demographic domain: parties (persons, organisations, roles) and their identifiers, with national identifiers optionally sealed under AES-256-GCM (the sealed identifier).
The link between the two, in the linkage domain: which party an EHR belongs to.
The access log: who accessed which record, when, from which origin and in which priority category. It names health professionals and patients.
Operating data: the configuration, metrics, traces and logs, which carry no identified data (telemetry), and the daily usage report, which carries instance facts and coarse performance aggregates and no patient data.
The operational environment
Each organisation gets its own FerroEHR instance, whether it runs FerroEHR
itself or Cadasto B.V. hosts it; the two operating models are described in the
FerroHEALTH book.
The public sandbox at sandbox.ferroehr.eu is a demonstration holding demo
data, wiped nightly.
- One instance per organisation. FerroEHR is single-tenant: several organisations are served by several instances, each with its own database and its own database roles (one instance, one organisation).
- Runtime. The distroless, non-root container image on Kubernetes through the Helm chart, on Docker Compose, or the binary built from source, on Linux (amd64 or arm64) (installation).
- Database. A PostgreSQL 18 cluster the deploying organisation provisions; the Helm chart deploys none.
- Profile.
deployment_profile = "production", which refuses to start while a separation is open and not accepted by name (go-live checklist). The shipped default issandbox, which must not hold real personal data and says so on the banner, in the log and onGET /rest/status. - Expected time in use. Longer than five years, which is why Cadasto B.V.
commits to keep publishing security fixes for at least five years after
each release, shipped only in the newest release
(
SECURITY.md).
The security environment FerroEHR assumes
These are the parts of the environment the manufacturer designs FerroEHR to rely on and does not provide itself (CRA Annex II point 4). The threat model names each as a trusted or semi-trusted actor.
- TLS termination. Every connection to the API is encrypted, either by the
server’s own listener (
[server.tls], TLS 1.3 by default, mutual TLS available) or by an ingress or proxy in front of it. The shipped default leaves[server.tls]unset; the hop behind a proxy is the deploying organisation’s, and the server cannot see whether it is encrypted (B1 network and protocol, server configuration). - An identity provider. Users and their credentials live in the deploying organisation’s OpenID Connect provider, which issues the tokens FerroEHR verifies. FerroEHR issues no credentials and manages no identity lifecycle, and a compromised identity provider defeats every control below it (enterprise identity providers, authentication). Basic authentication against a configured list is for service accounts and development.
- Database protection. The database is reached only by FerroEHR’s own credentials, one per domain and each least-privilege, over a network path restricted to the server, with TLS to the database, encryption at rest at the volume layer, and backups taken per domain. Anyone with a database connection is past every API control (B6 the database, database roles, TLS and database security, backup and recovery).
- The platform. A hardened cluster or host, operators the organisation trusts, and network policy around the instance (cluster hardening: the cluster, images, the workload, network and policy, secrets, detection and response).
- Off-box copies. A forwarding sink for the access log that the server’s identity cannot rewrite, and a monitored backup (B7 the audit trail).
Reasonably foreseeable use and misuse
The CRA distinguishes “reasonably foreseeable use”, use that “is not necessarily the intended purpose” but “is likely to result from reasonably foreseeable human behaviour or technical operations or interactions” (Art. 3(24)), from “reasonably foreseeable misuse”, use “not in accordance with its intended purpose, but which may result from reasonably foreseeable human behaviour or interaction with other systems” (Art. 3(25)).
Foreseeable uses the manufacturer accepts: a teaching or research instance holding synthetic or pseudonymised data; population queries over a pseudonymised cohort, which FerroEHR serves with small-cell suppression (cohort queries); the store behind a patient-facing service of the provider; a test system for an application vendor.
Foreseeable misuse, and what FerroEHR does about it:
| Misuse | What FerroEHR does | What remains |
|---|---|---|
Real patient data in an instance running the shipped sandbox profile | states the profile on the banner, in the log, on GET /rest/status and in the viewer | nothing refuses it; the Helm chart and the Compose files declare sandbox explicitly, and the chart’s values say what a deployment holding patient data sets |
| The quickstart Compose file in production, with its development user and RBAC off | the README and the Compose file call these development defaults; production refuses RBAC off unless rbac_off is accepted by name | nothing refuses it under sandbox; the go-live checklist is the control |
auth.enabled = false on a reachable network | production refuses it at boot unless auth_off is accepted by name (#3638) | an accepted gap is stated on every boot; the sandbox profile does not refuse it |
| A plaintext listener on a routable address with no TLS ingress in front | production refuses it at boot unless plaintext_listener is accepted by name; a loopback bind is exempt (#3638); a separate management listener (management.port), which binds every interface in plain HTTP, opens the same gap (#3668) | a deployment behind a TLS-terminating ingress accepts plaintext_listener, and nothing checks that the ingress exists or that the management port is unreachable off the host |
| Several organisations in one instance | no tenant model exists to misconfigure; one instance per organisation is documented | nothing in the software can detect it (B5) |
| Real patient data on the public sandbox | the sandbox is labelled as demo data and wiped nightly | nothing refuses it |
| The access log switched off, or failing open, in production | production refuses audit_off and audit_fails_open unless each is accepted by name | an accepted gap is stated on every boot, and the organisation owns it |
| The access log shipped over plaintext syslog | the syslog feed sends over RFC 5425 TLS only, through ihe-atna (#3887) | none for the feed; the repository’s own TLS configuration is the deployment’s |
Change events published to a plaintext amqp:// broker in production | production refuses an enabled change-event outbox on an amqp:// URL without tls = true unless plaintext_broker is accepted by name (#3676) | an accepted gap is stated on every boot; nothing checks that the broker path stays on a trusted segment |
| Reads and writes straight against the database | the domain roles limit what each credential reaches | such access bypasses authorisation and the access log (B6) |
| Structural validation taken for clinical correctness | validation checks structure, invariants and terminology bindings only | clinical governance is the organisation’s (threat model) |
| openEHR conformance results, or these pages, taken for regulatory conformity | the compliance overview says FerroEHR holds no certification or declaration | none |
| A release run after a newer one is published | only the newest release is supported (SECURITY.md), and the release feed and advisories announce each new one; the end month of the five-year commitment is in every release’s notes and the running server states it on the boot banner, in ferroehr --version, on GET /management/info and GET /management/status, and logs a warning at boot and daily once it has passed (#3639) | nothing refuses to run; the server does not look for newer releases |
What the intended purpose excludes
The exclusions that hold for the whole EHR system, the medical-device purposes of the MDR first, are in the FerroHEALTH statement. For FerroEHR they mean:
- No diagnosis or advice. FerroEHR stores and returns records as written; it computes no diagnosis, score, alert or treatment advice.
- Hosting for several organisations. One instance serves one organisation.
- Identity management. FerroEHR verifies identities that another system issues.
- The interoperability component. Exchange in the European electronic health record exchange format is FerroBRIDGE’s part of the EHR system.
CRA risk assessment
This page is the cybersecurity risk assessment Cadasto B.V., the manufacturer of each tagged FerroEHR release, keeps under the Cyber Resilience Act (Regulation (EU) 2024/2847). CRA Art. 13(2) has the manufacturer “undertake an assessment of the cybersecurity risks associated with a product with digital elements and take the outcome of that assessment into account during the planning, design, development, production, delivery and maintenance phases”. Art. 13(3) says what the assessment holds: “an analysis of cybersecurity risks based on the intended purpose and reasonably foreseeable use, as well as the conditions of use”, and an indication of “whether and, if so in what manner, the security requirements set out in Part I, point (2), of Annex I are applicable to the relevant product with digital elements and how those requirements are implemented”, and of how Part I point (1) and the vulnerability handling of Part II are applied. Where a requirement does not apply, Art. 13(4) asks for “a clear justification to that effect” in the technical documentation. The assessment is point 3 of the technical documentation of CRA Annex VII.
The page is versioned with the release: this book is frozen with every release
at /docs/vX.Y.Z/, so each release’s documentation carries the assessment
that applied to it. The statuses below are those of revision 2 of the
assessment, 2026-10-07 (the table at the end lists the revisions).
- Scope and dates
- The basis: intended purpose, use and environment
- How the risks were analysed
- Annex I Part I(1): the level of cybersecurity
- Annex I Part I(2), point by point
- (a) No known exploitable vulnerabilities
- (b) Secure by default, and reset to the original state
- (c) Security updates, automatic updates, notification and postponement
- (d) Protection from unauthorised access, and reporting it
- (e) Confidentiality of stored, transmitted and processed data
- (f) Integrity, and reporting corruptions
- (g) Data minimisation
- (h) Availability, also after an incident
- (i) Minimal impact on other devices and networks
- (j) Limited attack surface
- (k) Exploitation mitigation
- (l) Recording and monitoring, with an opt-out
- (m) Secure removal of all data and settings, and secure transfer
- Requirements and limbs that do not apply
- Outbound data flows (Annex I Part I(2)(g))
- Annex I Part II: vulnerability handling
- Security-relevant paths
- Revisions
Warning
Every quotation is from the Official Journal text vendored in the repository at
docs/law/eu/cra/text.html(EUR-Lex); none of its three corrigenda touches Article 13 or the Annexes cited here. EHDS Art. 104 amends CRA Art. 13(4) and inserts Art. 32(5a); those changes are read fromdocs/law/eu/ehds/text.html. No conformity assessment has been carried out and no EU declaration of conformity exists. A status on this page describes the evidence the repository holds, never conformity.
Scope and dates
- The products assessed. Each tagged release: the
ferroehrbinaries, theferroehr,ferroehr-viewerandferroehr-postgresimages, the Helm chart and the source archive. The nineopenehr-*crates published on crates.io under Apache-2.0 are products of their own; this page covers them only where a row says so. - Who the requirements bind. Annex I Part I sets properties of the product, which the manufacturer ensures (CRA Art. 6(a), Art. 13(1)). Part II sets the manufacturer’s processes; its chapeau reads “Manufacturers of products with digital elements shall:”. A deploying organisation that runs an unmodified release is the addressee of neither.
- When. Annex I, Art. 13 and Art. 32 apply from 11 December 2027; Art. 14 from 11 September 2026 (CRA Art. 71(2)). The assessment runs now so that the gaps are closed before that date.
- The EHDS route. Cadasto B.V. declares FerroEHR with FerroBRIDGE as an EHR system. For such a product, CRA Art. 32(5a), inserted by EHDS Art. 104(3), routes the Annex I conformity through the EHDS Chapter III procedure, and CRA Art. 13(4) as EHDS Art. 104(1) replaces it lets this assessment “be part of the risk assessment required by those Union legal acts”. The hazard log is that EHDS-side assessment for the logging component, and the technical documentation carries both.
The basis: intended purpose, use and environment
The intended-purpose statement is the basis Art. 13(3)
asks for. In one paragraph: FerroEHR stores, versions and queries the
structured health records of one healthcare provider, serves them to that
provider’s clinical applications through the openEHR REST API and AQL, and
records every access. It runs one instance per organisation, self-hosted by
the organisation or hosted by Cadasto B.V. as a service, on a PostgreSQL 18
cluster provisioned for that instance, behind TLS, with users in the
organisation’s identity provider. Its foreseeable misuse (the sandbox
profile holding real data, authentication off, a plaintext listener, several
organisations in one instance) is listed on that page with what the product
does against each.
The assets are those of the threat model: the clinical payload, the demographic parties and their identifiers, the EHR-to-subject link, the access log, the version history, the signing keys, the credentials, instance separation and availability.
The expected time in use is longer than five years, so Cadasto B.V.
commits to keep publishing security fixes for at least five years after each
release, shipped only in the newest release, which is the one release
supported (CRA Art. 13(8) and (10);
SECURITY.md
carries the reasoning, which is Annex VII point 4).
How the risks were analysed
CRA Art. 3(37) defines a cybersecurity risk as “the potential for loss or disruption caused by an incident”, “expressed as a combination of the magnitude of such loss or disruption and the likelihood of occurrence of the incident”. The analysis starts from the threat model, which walks eight trust boundaries (B1 to B8) and states the control and the residual risk at each. The register below rates each risk on the intended purpose and the shipped controls: the magnitude by the asset it reaches, the likelihood by what an actor needs to cause it. The ratings are the manufacturer’s judgement, on three levels (high, medium, low).
| # | Risk | Magnitude | Likelihood with the shipped controls | Controls | Annex I Part I(2) |
|---|---|---|---|---|---|
| C1 | An unauthenticated caller reads or writes clinical data | high | low under the intended purpose; high when auth.enabled = false is misused under sandbox or accepted by name under production | authentication on by default with a boot refusal when no mechanism is configured; production refuses auth.enabled = false unless auth_off is accepted by name; per-request authorisation | (b), (d) |
| C2 | An authenticated caller reaches EHRs outside their remit | high | medium: the shipped EHR_ACCESS default is open, which production refuses unless accepted by name | RBAC, ABAC, per-EHR EHR_ACCESS; production refuses the role gate off (rbac_off) and an open EHR_ACCESS default (open_ehr_access_default) unless each is accepted by name; every access recorded | (d), (l) |
| C3 | Data in transit is read or altered on the network | high | low behind a TLS ingress or with the server’s TLS; high on a plaintext listener | rustls TLS 1.3 and mutual TLS, off by default; production refuses a plaintext main listener on a routable address, a separate plain-HTTP management listener, and an AMQP integration on a plaintext amqp:// broker, unless plaintext_listener or plaintext_broker is accepted by name; the syslog audit feed sends over TLS only (ihe-atna); a boot warning on a plaintext routable bind | (e) |
| C4 | Someone with a database connection reads, rewrites or deletes data, bypassing the API | high | depends on the deploying organisation’s database protection | least-privilege roles per domain; append-only versions; version signatures verified on read; the hash-chained access log | (e), (f) |
| C5 | An access happens and the log does not show it, or the log is altered | high | low with fail_mode = "closed" and an off-box sink; medium with the shipped fail_mode = "open" | refusals always recorded; production refuses audit off and audit failing open unless accepted; the hash chain | (f), (l) |
| C6 | A national identifier or identifying content leaks into the clinical record, telemetry or a report | high | low | the identifier scanner in strict mode; log sanitising; reports with no identifiers | (e), (g) |
| C7 | A malformed or hostile payload crashes or corrupts the server | medium | low | memory-safe Rust with unsafe forbidden; overflow checks in release; fuzzed parsers; caught panics | (j), (k) |
| C8 | The service is made unavailable | high (clinical availability) | medium: an authorised caller holding resources is not defended against | admission limit with 503 shedding; rate limits; body, header and statement limits; replicas and a disruption budget | (h) |
| C9 | A vulnerable component ships in a release | high | medium | cargo deny advisories gate; image scan before tagging; weekly re-scan; Dependabot; VEX for argued findings | (a) |
| C10 | A tampered artefact reaches a deployment | high | low | SLSA Build L3 provenance; SBOM attestations; signed tags; immutable releases; a signed chart | (f) |
| C11 | Data stays recoverable after the organisation removes it | medium | medium: the erase removes every schema and blob, and backups, WAL, replicas and the media stay the organisation’s to remove | physical delete of EHRs; ferroehr db erase for all data and settings; the decommissioning page | (m) |
| C12 | An outbound integration sends more than its purpose needs, or to the wrong place | medium | low: every integration is off by default | the flows table below | (g), (i) |
Annex I Part I(1): the level of cybersecurity
“Products with digital elements shall be designed, developed and produced in such a way that they ensure an appropriate level of cybersecurity based on the risks.” The manufacturer applies it through the register above: each risk has a control, and each control is traced to a point of Part I(2) below. The text names no measures, so the level is set against the risks of a clinical data repository holding special-category data, where disclosure cannot be undone and unavailability is a patient-safety matter.
Annex I Part I(2), point by point
The chapeau reads: “On the basis of the cybersecurity risk assessment referred to in Article 13(2) and where applicable, products with digital elements shall:”. Each entry gives the requirement, whether it applies, how FerroEHR implements it, the evidence, the status (met, partial or gap, describing the evidence only) and the open work.
(a) No known exploitable vulnerabilities
“be made available on the market without known exploitable vulnerabilities”
- Applies: yes.
- How:
cargo denyfails the build on any RustSec advisory and on yanked crates, with each accepted advisory dated and reasoned indeny.toml. Each of the three images is scanned by Trivy by its pushed digest, on both platforms, before it is tagged, so only a passing digest gets a tag, and the published images are re-scanned weekly. Every shipped binary, in the release tarballs and in the images, is built withcargo auditable, so it carries its Rust dependency list and an image scan sees the crate graph. Every release also scans each image and server binary with no severity floor and unfixed findings included, joins every finding with its OpenVEX statement, and is refused, before any image is tagged, while a finding has none; the joined record and the raw reports are release assets. The Rust advisory statements are checked against the resolved dependency graph, and an operating-system statement that argues from what a binary loads carries that linkage as data, which the release lane reads off both Rust binaries on both architectures and refuses a contradiction of (#3694). - Evidence:
deny.toml;trivy.yaml;.github/workflows/scan-and-tag.yml;.github/workflows/image-scan.yml;security/vex/;scripts/checks/vex-reachability.sh;scripts/checks/vex-coverage.sh;scripts/checks/vex-linkage.sh;.github/workflows/release.yml(vulnerability-record); the exploitability record. - Status: partial. Every finding of the full scan carries a judgement or the
release does not ship, and the scan reads the Rust dependencies of the
server and viewer binaries from the binaries themselves (#3674, #3675). The
CRA does not define “known”. Eight OS findings are judged
affectedand have no upstream fix: CVE-2026-8674 (a long resolver search domain) in all three images; three libxml2 and two glibc JISX0213 findings in theferroehr-postgresimage that a database login reaches through PostgreSQL’s XML functions, which FerroEHR itself never calls (#3673); and the krb5 CVE-2026-107778 inferroehr-postgres, reached only through a GSSAPI configuration FerroEHR does not ship. Each carries an action statement insecurity/vex/. Re-checked for 4.3.9 on 2026-10-15: the seven earlier statements are stillaffected, CVE-2026-107778 joined them, and #3708 is open, so the status stays partial. - Open: the eight
affectedfindings (#3708), until Debian ships fixes that a rebuild picks up; each such rebuild is a security-only patch with its advisory.
(b) Secure by default, and reset to the original state
“be made available on the market with a secure by default configuration, unless otherwise agreed between manufacturer and business user in relation to a tailor-made product with digital elements, including the possibility to reset the product to its original state”
- Applies: yes. The tailor-made carve-out does not: a public release is not a tailor-made product agreed with a business user.
- How: authentication on, with a boot refusal when no mechanism is
configured; RBAC on; the admin and management surfaces off; every
integration off; Swagger UI
private; the rate limiter on; version signing on; the identifier scannerstrict. Weak settings are refused at boot: analg: nonetoken, a symmetric algorithm, a non-HTTPS issuer, an Argon2id hash below the floor. The chart runs non-root with a read-only root filesystem, all capabilities dropped,RuntimeDefaultseccomp and an ingress NetworkPolicy, and its validation fails a render that loses any of them. - Evidence:
app/ferroehr/assets/ferroehr.default.toml;app/ferroehr/src/config/auth.rs;app/ferroehr/src/config/deployment.rs;deploy/helm/ferroehr/values.yaml(config.deployment_profile);docker-compose.yml;deploy/helm/validate.sh;scripts/deploy-probes/reset.sh; the workload; returning to the original state. - Why
sandboxis the default: the binary, the chart and the Compose files all ship and declaredeployment_profile = "sandbox". Aproductiondefault would refuse to start until the deployment has separate database credentials per domain, a declared subject namespace, a closed audit fail mode and a schema-preparation credential of its own, none of which a first boot, an evaluation or the quickstart has. An installation that cannot start invites accepting every gap by name only to make it start, which records a choice nobody made deliberately. Asandboxmakes no such choice for the operator and stays loud instead: it names every open separation on the boot banner, in the log and on/management/status, andGET /ferroehr/rest/statusreports the profile, so a sandbox cannot pass for a production instance. It also means no existing deployment changes behaviour by upgrading. The instructions for a deployment holding patient data are to setproduction, which refuses every open separation that is not accepted by name. - Which shipped defaults a production holder keeps: these are secure as
shipped and
productionraises nothing about them: authentication on, RBAC on, the admin API and every management endpoint off, Swagger UIprivate, the rate limiter on, version signing on indigestmode with read-time verificationstrict, the identifier scannerstrictover every shipped rule, and the audit trail on with the local store andretention_days = 0, which the server refuses at boot where a jurisdiction in force sets a ceiling. These must change, andproductionrefuses them:authz.rbac.ehr_access_default = "open",audit.fail_mode = "open", an emptyprivacy.subject_namespaces, one database credential for every domain, anddb.migrate = "apply"on the runtime credential. Three may be accepted by name when the deployment closes them outside the server:plaintext_listener, when a TLS-terminating ingress fronts the port,plaintext_broker, when the path to the AMQP broker stays on a trusted segment, andshared_cluster, when one PostgreSQL cluster holds the domains in separate schemas under separate roles. The usage report is on by default; it carries no health data (outbound data flows). - Reset: the book documents returning an instance to its original state,
configuration and database, for Docker Compose, Kubernetes and a single
binary. The deployment probe harnesses run the Compose and the Helm
procedures and read the outcome back: the database volume or the database is
gone, an EHR written before the reset is not found, the reinstalled server
migrates the empty database (
P-K8S-RESET-*), and the Compose server reports the shipped configuration (P-RESET-*). - Status: partial. The shipped profile is
sandbox, so a deployment holding patient data is secure only after it setsproductionand closes the five refused defaults above. The single-binary reset is documented and not exercised by a probe; its database step is the one the Helm probe runs. - Open: none for the reset.
(c) Security updates, automatic updates, notification and postponement
“ensure that vulnerabilities can be addressed through security updates, including, where applicable, through automatic security updates that are installed within an appropriate timeframe enabled as a default setting, with a clear and easy-to-use opt-out mechanism, through the notification of available updates to users, and the option to temporarily postpone them”
- Applies: the security-update limb, yes. The automatic-update limb, no: see the justification. The notification limb, yes: the sentence does not place it under “where applicable”, and this assessment reads it as applying. Postponement is inherent, because the deploying organisation decides when to upgrade.
- How: a fix ships in the next tagged release, as a security-only patch
unless a recorded reason makes that infeasible; there are no backports
(CRA Art. 13(10)). Users learn of it through the GitHub release, its notes,
the security advisory and the
### Securitychangelog section; the release feed (https://github.com/FerroHEALTH/FerroEHR/releases.atom) and the advisory listing (GET https://api.github.com/repos/FerroHEALTH/FerroEHR/security-advisories) are the subscription routeSECURITY.mdnames. The running server states the end month of the commitment for its release (five years from the month of its release date) on the boot banner, inferroehr --version, onGET /management/infoand onGET /management/statusassupport.status, and once that month has passed it logs a warning at boot and once a day (Art. 13(19)). - No in-product update check. The server does not contact anyone to learn of a newer release. The point names “the notification of available updates to users” and is silent on whether the product itself must deliver it. FerroEHR has no end-user interface of its own: its users are the deploying organisation’s operators and the applications that call its API, and an update reaches a deployment only through the operators’ change control. The notice goes to them through the feed and the advisory listing. A check from the server would add an outbound connection from every instance holding special-category data, would fail wherever the organisation blocks egress (as the chart’s egress policy lets it), and would reach the operators only as a log line.
- Evidence:
SECURITY.md;app/ferroehr/src/support.rs;app/ferroehr/src/banner.rs(banner_states_the_support_period); how long a version is supported; upgrades. - Status: met for the security-update and notification limbs, with the notice delivered outside the product as stated above.
- Open: none.
(d) Protection from unauthorised access, and reporting it
“ensure protection from unauthorised access by appropriate control mechanisms, including but not limited to authentication, identity or access management systems, and report on possible unauthorised access”
- Applies: yes. The list is open (“including but not limited to”); the duty is appropriate mechanisms.
- How: Basic (Argon2id) and bearer authentication through the
ferrohealth-smartcrate: asymmetric signature algorithms only, the RFC 9068 profile by default, and each issuer’s metadata and keys read at boot. Every route carries a SMART on openEHR scope requirement, and apatient/grant reaches only the EHR of the token’s launch context. Then RBAC, Cedar or remote ABAC, and per-EHREHR_ACCESS; an ABAC block that is enabled but cannot be built aborts boot. A restrictedEHR_ACCESSdefault holds with the role gate off, and is refused at boot with authentication off. With a patient claim configured, the ABAC patient scope applies to the EHR row and to every versioned object on every query plan and on the direct EHR, composition and status routes. An EHR that records no subject is refused to a patient caller, and so is one whose subject id another namespace also holds, unless[authz.abac] patient_namespacenames the namespace the claim is issued in. Every refused access (401,403) is recorded in the access log and counted in theauth_failuresmetric. - Evidence:
app/ferroehr-rest/src/extensions/access/;app/ferroehr-rest/src/system_log/middleware.rs;app/ferroehr/src/telemetry/metrics.rs; authentication, authorization;app/ferroehr/src/config/deployment.rs(production_refuses_auth_off_unless_accepted_by_name,production_refuses_rbac_off_and_a_restricted_default_stays_closed); deployment profile. - Status: met. Reporting is met.
productionrefusesauth.enabled = falseat boot unlessauth_offis accepted by name, and[authz.rbac] enabled = falseunlessrbac_offis; an accepted gap is stated on every boot. The shippedsandboxprofile accepts both, which (b) records. - Open: none beyond (b).
(e) Confidentiality of stored, transmitted and processed data
“protect the confidentiality of stored, transmitted or otherwise processed data, personal or other, such as by encrypting relevant data at rest or in transit by state of the art mechanisms, and by using other technical means”
- Applies: yes. The means are a menu (“such as”); application-level encryption at rest of the clinical payload is not used, with the justification.
- How: in transit, rustls with TLS 1.3 by default and mutual TLS, or a
TLS-terminating ingress; integrations offer TLS (
amqps, syslog over TLS, HTTPS-only S3 by default); a renewed server certificate is served without a restart, onSIGHUPand every[server.tls] reload_interval_s. At rest, protected national identifiers sealed under AES-256-GCM with a keyed lookup digest, per party version, inside the party commit; a write response serves the sealed form, an import or admin load carrying a protected value in plaintext is refused, and a dump carries the sealed rows as ciphertext. Other technical means: three pseudonymisation domains on separate credentials, checked byproduction; secrets that never render; log sanitising. - Evidence:
[server.tls]inferroehr.default.toml; B1, B6, the sealed identifier, TLS and database security. - Status: partial. The shipped transport default is plaintext, the AMQP
and syslog integrations default to plaintext when switched on, and
identifier sealing is off by default.
productionrefuses a plaintext main listener on a routable address unlessplaintext_listeneris accepted by name, and a loopback bind is exempt; a deployment behind a TLS-terminating ingress accepts the gap by name, because the ingress carries the encryption (app/ferroehr/src/config/deployment.rs,production_refuses_a_routable_plaintext_listener_and_exempts_loopback). The separate management listener (management.port) binds every interface in plain HTTP, so setting it opens the same gap (production_refuses_a_plain_management_listener_on_its_own_port). The syslog audit feed sends over RFC 5425 TLS only, throughihe-atna(app/ferroehr/tests/it/system_log_tls_roundtrip.rs,tls_octet_counted_frame_round_trips). An enabled change-event outbox publishing to a plainamqp://broker opensplaintext_broker(#3676,production_refuses_a_plaintext_amqp_broker_unless_accepted_by_name). An enabled audit FHIR feed on anhttp://URL opensplaintext_audit_feed, and an enabled multimedia store withallow_httpopensplaintext_object_store(#3689,production_refuses_a_plaintext_audit_feed_and_object_store_unless_accepted). - Open: none.
(f) Integrity, and reporting corruptions
“protect the integrity of stored, transmitted or otherwise processed data, personal or other, commands, programs and configuration against any manipulation or modification not authorised by the user, and report on corruptions”
- Applies: yes, to data, programs and configuration. The configuration limb is split with the deploying organisation, with the justification.
- How: data: immutable versions, each signed (digest or OpenPGP) and
verified on read in
strictmode by default; the access log is hash-chained in the database over every column of a record, the render policy its FHIR document is rendered under is checked against its digest, andUPDATE,DELETEandTRUNCATEon it are refused. The runtime roles read the migration bookkeeping of their schema and cannot write it, and the server refuses to start while one can. Programs: SLSA Build L3 provenance and SBOM attestations, cosign on the chart, signed tags, immutable releases, a read-only root filesystem. Configuration: unknown keys and unknownFERROEHR__variables are refused at boot. - Evidence:
app/ferroehr/src/versioning/integrity.rs;app/ferroehr/migrations/audit/0003_tamper_chain.sql;app/ferroehr/migrations/audit/0011_chain_digest_v2.sql;app/ferroehr/src/system_log/chain_check.rs;app/ferroehr/tests/it/audit_chain.rs(the_chain_check_reports_a_tampered_record_on_the_health_surface,the_current_digest_covers_every_column,every_hashed_column_is_tamper_evident);app/ferroehr/migrations/clinical/0014_bookkeeping_read_only.sqland its party and linkage twins;app/ferroehr/src/config/strict.rs; tamper evidence; verifying releases. - Status: met. A signature failure on read is refused and logged. The
server verifies the access-log chain one minute after boot and then every
[audit.store] verify_interval_seconds(default86400;0turns it off). A finding is logged atERROR, counted inatna_audit_chain_findingswithkinddamageorunverifiable, and shown by theaudit_chainindicator onGET /health/readiness(DEGRADEDwhen damaged or unverifiable), which never flips readiness. Each replica runs its own check. A deployment that sets the interval to0reports a corruption only when an operator runsaudit.verify_audit_chain(). - Open: none.
(g) Data minimisation
“process only data, personal or other, that are adequate, relevant and limited to what is necessary in relation to the intended purpose of the product with digital elements (data minimisation)”
- Applies: yes, measured against the intended purpose.
- How: the clinical record carries pseudonymous subject references; the
identifier scanner refuses national identifiers in clinical content; the
identity, the link and the access log sit in their own domains; telemetry
carries no identified data; the access-log category classification never
reaches spans, logs or metrics;
ferroehr reportidentifies no person. - Evidence:
app/ferroehr/src/privacy/;app/ferroehr/src/telemetry/log_sanitize.rs; privacy and data minimisation; the pseudonymisation boundary. - Status: met for the processing the intended purpose needs; each outbound flow is justified in the flows table.
(h) Availability, also after an incident
“protect the availability of essential and basic functions, also after an incident, including through resilience and mitigation measures against denial-of-service attacks”
- Applies: yes. The measures named are an open list.
- How: an admission limit sheds load with
503andRetry-After; per principal and per address rate limits, on by default; body limits, header read timeouts, HTTP/2 stream caps, statement and query timeouts and a result-row cap; panics caught; two replicas, a disruption budget and topology spread in the chart; backup and point-in-time recovery documented. - Evidence:
app/ferroehr-rest/src/overload.rs;app/ferroehr-rest/src/rate_limit.rs;app/ferroehr-rest/src/limits.rs; request limits; backup and recovery. - Status: met. The residual risk is stated in the threat model: an authorised caller holding resources is not defended against, and the limiter is per process.
(i) Minimal impact on other devices and networks
“minimise the negative impact by the products themselves or connected devices on the availability of services provided by other devices or networks”
- Applies: yes.
- How: every outbound integration is off by default and bounded when on (retry limits, batch sizes, poll intervals, connect and request timeouts). The usage report sends at most one start report per ten minutes per instance and one daily report, each at most 64 KiB. A default-deny egress policy is available in the chart.
- Evidence:
ferroehr.default.toml;deploy/helm/ferroehr/values.yaml(networkPolicy.egress); usage report. - Status: met.
(j) Limited attack surface
“be designed, developed and produced to limit attack surfaces, including external interfaces”
- Applies: yes.
- How: optional surfaces are off by default (admin, management, SMART,
multimedia, events, the terminology API); Swagger UI is
private; optional integrations are compiled behind features; the runtime images are distrolessnonrootwith no shell; the chart installs an ingress NetworkPolicy. - Evidence:
docker/Dockerfile;docker/viewer/Dockerfile; operational surfaces. - Status: met.
(k) Exploitation mitigation
“be designed, developed and produced to reduce the impact of an incident using appropriate exploitation mitigation mechanisms and techniques”
- Applies: yes.
- How: Rust with
unsafe_code = "forbid"across the workspace andoverflow-checks = truein release builds; a non-root UID, no shell, a read-only root filesystem, all capabilities dropped,RuntimeDefaultseccomp and user namespaces; database credentials split so one leaked DSN reaches one domain. - Evidence:
Cargo.toml([workspace.lints.rust],[profile.release]); the workload; secrets, detection and response. - Status: met.
(l) Recording and monitoring, with an opt-out
“provide security related information by recording and monitoring relevant internal activity, including the access to or modification of data, services or functions, with an opt-out mechanism for the user”
- Applies: yes.
- How: the access log records every API access, every record a read or a
query served, and every refusal, in DICOM PS3.15 and FHIR
AuditEventform, with syslog and FHIR-feed sinks and ITI-81 retrieval; metrics and traces cover the rest. The opt-out is[audit] enabledand[audit.store] enabled;productionrefuses audit off unless accepted by name. - Evidence:
app/ferroehr/src/system_log/;app/ferroehr-rest/src/system_log/; audit trail; observability. - Status: met. A deploying organisation may owe the log under the EHDS or national law, so whether it may use the opt-out is outside the CRA.
(m) Secure removal of all data and settings, and secure transfer
“provide the possibility for users to securely and easily remove on a permanent basis all data and settings and, where such data can be transferred to other products or systems, ensure that this is done in a secure manner”
- Applies: yes.
- How:
ferroehr db erase --confirm <instance>removes all data and settings in one operation: every multimedia blob, then the five schemas with every domain, the access log, the stored settings and the instance id. Run without--confirmit erases nothing and prints what it would remove and the value that confirms it.DELETE {base}/admin/ehr/{ehr_id}andDELETE {base}/admin/ehr/allremove single EHRs with every version, the linkage row and orphaned multimedia; template and stored-query deletes exist. Transfer runs over the authenticated API and/ferroehr/rest/extensions/v1/admin/dump, over TLS when the deployment enables it. - Evidence:
app/ferroehr/src/decommission.rs;app/ferroehr/tests/it/decommission.rs; decommissioning; physical deletion; dump and load. - Status: met, shipped in #3642. The decommissioning page lists what the erase cannot reach and the deploying organisation removes: backups, WAL archives and replicas, the chart’s Secrets and claims, the database roles, configuration files, and the data held by systems the instance sent to. Sanitising the physical media is the organisation’s. A single party has no physical delete over REST, because the openEHR Admin API defines none; the page says so and names the alternatives.
Requirements and limbs that do not apply
CRA Art. 13(4): “Where certain essential cybersecurity requirements are not applicable to the product with digital elements, the manufacturer shall include a clear justification to that effect in that technical documentation.”
Automatic security updates (Annex I Part I(2)(c) and Part II(7))
Part I(2)(c) asks for automatic security updates “where applicable”, and Part II(7) for distribution “where applicable for security updates, in an automatic manner”. Neither applies to FerroEHR:
- FerroEHR is a clinical server run under its operator’s change control: the deploying organisation for a self-hosted instance, Cadasto B.V. for an instance it hosts as a service. An upgrade can carry a database migration that the operator schedules, backs up for and verifies, so in both cases the operator applies an update, never the product by itself.
- A clinical data repository is a system whose unavailability is a patient-safety matter (threat model, asset “Availability”). An update the manufacturer installs on its own schedule would restart the service and migrate the schema at a moment no clinician or operator chose.
- The operator controls the host, the cluster, the image registry it pulls from and the network. FerroEHR has no channel of its own into a deployment, and adding one would be an inbound path into a system holding special-category data, against (j). Where Cadasto B.V. hosts an instance, it applies updates as that instance’s operator under its own change control, not through the product.
- Updates are distributed securely by other means: signed, immutable releases
with provenance, digest-pinned images, the moving
<major>.<minor>tags an organisation can follow with its own tooling, and the advisory that tells it to.
So the automatic-installation limb and the opt-out that goes with it do not apply, and CRA Annex II point 8(e) (how to turn automatic installation off) has nothing to describe. The notification limb still applies (#3639).
Application-level encryption at rest (Annex I Part I(2)(e))
Part I(2)(e) names encryption at rest as one of the means (“such as”). FerroEHR does not encrypt the clinical payload before it reaches PostgreSQL:
- The payload is stored as canonical openEHR JSON that AQL queries inside; encrypting those fields in the application would make them unqueryable, which defeats the intended purpose.
- Encryption at rest is provided where the data is stored: at the volume or disk layer, which the deploying organisation provisions (TLS and database security).
- The fields whose disclosure re-identifies a person directly, the protected national identifiers, are sealed in the application (AES-256-GCM), because they are looked up by digest and never queried by content.
- Confidentiality at rest is further carried by “other technical means”: the separated domains and credentials, and least-privilege roles.
Configuration integrity (Annex I Part I(2)(f))
Part I(2)(f) lists “configuration” among what must be protected against
modification “not authorised by the user”. FerroEHR protects the configuration
it reads: unknown keys and unknown FERROEHR__ variables stop the boot, secrets
are read from mounted files, and the effective configuration is printed
redacted on demand. It does not sign or checksum its configuration file. The
file lives on the deploying organisation’s host or in its Kubernetes secrets
and config maps, under that organisation’s access control, and whoever can
change it there is, by the organisation’s own decision, a user authorised to
configure the product. Protecting it against other modification is the
platform’s job (secrets, detection and response).
Outbound data flows (Annex I Part I(2)(g))
Every connection FerroEHR opens on its own initiative, with what it carries and why. The database connections are the product’s own storage and are not listed.
| Flow | Default | Carries | Purpose, against the intended purpose |
|---|---|---|---|
Usage report to report.ferropulse.eu | on | instance id, version, commit, specification profile, licence grant type, deployment method, uptime, database health, PostgreSQL major version, CPU and memory ranges, and daily performance aggregates (fields) | Not part of the clinical purpose: it serves the manufacturer’s view of which versions, licence types and deployment sizes run. It carries no health data and no data from a request; the collector refuses an unknown member; the body is at most 64 KiB; ferroehr usage-report --print shows it before it is sent; one switch turns it off. Cadasto B.V. decided on 2026-10-06 to keep it on by default and accepts the residual risk, after an adjudication that read ePrivacy Art. 5(3) as requiring prior consent (privacy notice) |
Authorization-server metadata and keys from each [auth] issuer | when an issuer is configured | the issuer’s well-known URLs; no request data | verifying the tokens the provider’s identity provider issues |
Token introspection ([auth] mode = "introspection") | off | each bearer token presented to FerroEHR, with the client credential of [auth.introspection.client] or the token of [auth.introspection.bearer] | asking the issuer whether an opaque access token is active (RFC 7662) |
Patient identity source over PIXm ([ehr_index] provider = "ihe") | on (the default provider) | the subject identifier and its assigning authority as ITI-83 specifies, with the client credential of [ehr_index.oauth] when set | resolving a subject to the pseudonym its EHR carries; the source is never asked to resolve a pseudonym back |
Client-credentials token requests ([audit.fhir_feed.oauth], [authz.abac.remote.oauth], [terminology.server.oauth]) | off | the product’s own client credential and the requested scope; no request data | authenticating the product to the deployment’s audit feed, policy service and terminology server |
Remote policy decision point ([authz.abac.remote]) | off | the attributes of the request being decided | the authorisation decision the deployment delegates to its own policy service |
Terminology server ([terminology.server]) | off | codes and value-set references to validate, expand or subsume | validating terminology bindings on write and expanding TERMINOLOGY() in AQL |
Change events over AMQP ([events]) | off | commit notifications, free of PHI by default | telling the provider’s other systems that a record changed |
Multimedia to S3 ([multimedia]) | off | DV_MULTIMEDIA bytes above a size threshold, keyed by content hash | storing large attachments of the record outside the database |
Access log to syslog ([audit.syslog]) and to a FHIR feed ([audit.fhir_feed]) | off | access records | an off-box copy of the access log, which the threat model asks for |
Traces and metrics over OTLP (telemetry.otlp.endpoint) | off | spans and metrics, with identified data sanitised out | operating the instance |
Each integration except the usage report carries only what its purpose in the provider’s deployment needs, and only to a destination the deploying organisation configures.
Annex I Part II: vulnerability handling
Part II binds Cadasto B.V. These are processes, carried out partly through the
repository’s tooling; none is a property of the software a deployment runs.
The manufacturer’s policy is the same for every FerroHEALTH product and is
published in the FerroHEALTH book
(security,
post-market); the steps bound
to FerroEHR’s repository are docs/post-market.md.
| Point | How the manufacturer applies it | Status | Tracker |
|---|---|---|---|
| (1) Identify components; SBOM | an SBOM per published artefact: CycloneDX of the cargo graph per binary, SPDX per image, SPDX of the repository, CycloneDX per openehr-* crate attested against its .crate archive, and CycloneDX per chart version naming its images by digest, explained in the SBOMs; vulnerabilities in VEX, deny.toml, the changelog and each release’s exploitability record | met | shipped, #3643 |
| (2) Remediate without delay; security updates apart from functionality | acknowledgement within about 5 and assessment within about 10 business days; a fixed release within 7, 30 or 90 days of the confirmed assessment for a Critical, High or Medium vulnerability and with the next release for a Low one; a fix ships as a security-only patch unless the release notes record why that is infeasible (the FerroHEALTH security policy, linked from SECURITY.md) | met as policy from this change on | shipped, #3644 |
| (3) Regular security tests and reviews | daily fuzzing of seven parsers, CodeQL, OpenSSF Scorecard, clippy with the reliability lints, cargo deny, image scans, conformance runs, the deploy probe | met | none |
| (4) Disclose fixed vulnerabilities | a GitHub security advisory for every fixed vulnerability, with CVSS, affected and fixed versions and remediation, and the criteria for a delay (the FerroHEALTH security policy, SECURITY.md, docs/post-market.md); no advisory has been published yet | partial: the commitment is written, the record starts with the next fix | shipped, #3645 |
| (5) Coordinated vulnerability disclosure | the FerroHEALTH security policy, with FerroEHR’s reporting routes and scope in SECURITY.md and security.txt | met | none |
| (6) Share vulnerability information; a contact address | GitHub private reporting and info@cadasto.com; component vulnerabilities reported upstream and recorded in a register | met as policy from this change on | shipped, #3646, #3648 |
| (7) Distribute updates securely | provenance, attestations, signed tags, immutable releases, digest-pinned and scanned images, crates.io Trusted Publishing; the automatic limb does not apply (above) | met | none |
| (8) Disseminate updates without delay, free of charge, with advisories | public, immutable releases and moving tags; advisories under (4); whether “free of charge” holds for every commercial licence is a point for counsel | partial | shipped, #3645; counsel’s answer open, #3647 |
Security-relevant paths
These are the paths of the repository that hold an asset or a control of the
register above. A release that changes any of them since the previous release
is refused unless this page gains a row in its Revisions table:
scripts/checks/cra-risk-assessment.sh --since <previous tag> runs in the
release lane, and the same script refuses a declared path that no longer
exists, so the list cannot rot. CRA Art. 13(14) asks for procedures that keep
each product of a series in conformity as its design changes.
app/ferroehr-rest/src/extensions/accessauthentication and the RBAC/ABAC enforcement pointapp/ferroehr-rest/src/smartSMART scope enforcementapp/ferroehr/src/config/auth.rsthe authentication configurationapp/ferroehr/src/config/authz.rsthe authorization configurationapp/ferroehr/src/config/deployment.rsthe deployment profiles and their refusalsapp/ferroehr/src/system_logthe access log and the audit chainapp/ferroehr-rest/src/system_logthe audit middlewareapp/ferroehr/migrations/auditthe audit storeapp/ferroehr/src/versioning/signatureversion signingapp/ferroehr/src/versioning/integrity.rsthe integrity check of stored versionsapp/ferroehr/src/privacythe pseudonymisation boundaryapp/ferroehr/src/decommission.rsthe erase of all data and settingsdocker/Dockerfilethe server imagedocker/viewer/Dockerfilethe viewer imagedocker/postgres/Dockerfilethe database imagedeploy/helm/ferroehr/templatesthe chart’s security contexts and policiesdeploy/helm/ferroehr/values.yamlthe chart’s shipped defaults
Revisions
CRA Art. 13(3) has the assessment “updated as appropriate during a support period”, and Art. 13(7) has the manufacturer update it “where applicable” as vulnerabilities and third-party information arrive. It is revised:
- with every release whose changes touch an asset or a control in the register (the security-relevant paths, gated at release, #3649);
- when a vulnerability or an incident shows a risk the register does not name;
- when the intended purpose changes.
| Version | Date | Change |
|---|---|---|
| 1 | 2026-10-06 | First assessment, from the CRA Annex I audit of #3611 |
| 2 | 2026-10-07 | (a): the per-release exploitability record (#3636), the viewer image scanned before it is tagged (#3674) and every release binary and image binary built with cargo auditable (#3675); (b): the declared profile in the chart and the Compose files, the reasons for sandbox and the shipped defaults a production holder keeps, and the reset procedure (#3637); Part II(1): SBOMs for the crates and the chart (#3643); the declared security-relevant paths and their release gate (#3649); (m): the erase command and the decommissioning page (#3642); (e): a plaintext amqp:// broker refused under production (#3676); (e): a plain http:// audit FHIR feed and a multimedia store with allow_http refused under production (#3689); (b): the Helm reset run by the Kubernetes probe harness (#3677) |
| 3 | 2026-10-08 | Part II: the manufacturer’s vulnerability-handling and post-market policy is cited from the FerroHEALTH book, where it moved for every FerroHEALTH product; the measures are unchanged (#3701) |
| 4 | 2026-10-08 | Reviewed for release 4.3.6 against the security-relevant paths changed since 4.3.5: the outbound FHIR emitter and its AMQP flow are gone with the in-tree connector (#3080), so the outbound-flow table names change events alone on AMQP; (a): the ferroehr-postgres image drops GnuPG and gosu, and every operating-system finding of the three images carries an OpenVEX judgement in security/vex/ (#3673) |
| 5 | 2026-10-15 | Reviewed for release 4.3.9 against the security-relevant paths changed since 4.3.6. (d): the ABAC patient scope covers queries that read only the EHR row and every query plan, the direct routes refuse a subject-less or ambiguous EHR to a patient caller, and [authz.abac] patient_namespace names the claim’s namespace (#3771); ehr_access_default = "restricted" holds with the role gate off, is refused at boot with authentication off, and the role gate off is the rbac_off gap production refuses (#3742); the viewer’s Basic sign-in follows only a same-origin next. (e): protected national identifiers sealed per party version inside the commit, write responses serving the sealed form, plaintext protected values refused on import and admin load (#3772); the server certificate re-read every reload_interval_secs and EC keys in SEC1 or PKCS#8 (#3765, #3761); the OIDC 503 and a JWK Set parse error no longer quote the provider’s URLs or key material (#3666); the CDR and the viewer verify an identity provider against the platform trust store (#3767). (f): digest version 2 of the audit chain over every column (#3686); the runtime roles read-only on the migration bookkeeping, checked at boot in every database a pool reaches; version-signing errors carry their cause once (#3684). (a): rustls 0.23.46 for RUSTSEC-2026-0346 to -0349, and the release lane checks the linkage the OS VEX statements rest on (#3694); the seven affected findings of #3708 are unchanged. The chart: in-chart clients follow the server’s TLS (#3714), FerroBRIDGE as an opt-in, cluster-internal workload (#3713), client_auth: required refused at render (#3760), an Ingress in front of a TLS listener sending TLS to it (#3766), and the migration Job under its own hook ServiceAccount. No risk added to the register |
| 6 | 2026-10-19 | Reviewed for release 5.0.0 against the security-relevant paths changed since 4.3.11: in app/ferroehr/src/privacy/detect.rs the Finnish personal identity code rule cites Valtioneuvoston asetus väestötietojärjestelmästä 128/2010, 2 §, in place of the agency’s web page (#3840); the detection and its control-character table are unchanged. No risk added to the register |
| 7 | 2026-10-20 | Reviewed for release 5.1.0 against the security-relevant paths changed since 5.0.0. (d): authentication and the SMART on openEHR scope check move onto the shared ferrohealth-smart crate (#3872): only asymmetric algorithms verify a token, so the HMAC key is gone, every route carries a scope requirement, and a patient/ grant is confined to the launch context’s EHR, with the route parameter read from the full request path. The audit FHIR feed and the remote policy service authenticate with an OAuth 2.0 client-credentials token, and a policy service that cannot be reached with one fails the request closed (#3829), so the outbound-flow table names the token requests and the issuer metadata read at boot. (e): the chart mounts a database CA for sslmode=verify-full (database.caConfigMap), which the Kubernetes probe observed against four TLS clusters (#3788); a rotated mTLS client CA is re-read without a restart (#3769). No risk added to the register |
| 8 | 2026-10-20 | Reviewed for release 5.2.0 against the security-relevant paths changed since 5.1.0. (d): authentication moves to ferrohealth-smart 0.3.1 (#3875): the crate now caches verified Basic credentials under a keyed hash ([auth.basic] cache_ttl_seconds, default off), refuses a bearer value outside the RFC 6750 §2.1 b64token syntax as invalid_token, and refuses a blank sub, each of which FerroEHR held itself before; [auth] scopeless_subjects is honoured under the SMART on openEHR requirement. The EHDS Art. 8 restriction of access hides restricted data from every professional route, query and export, with an emergency override by a configured role and a declared emergency purpose (#3682). (e): a party’s protected national identifiers are read back only through an admin route that also needs authz.rbac.identifier_reader_role, and every read is an access record (#3776); an EHR extract never carries a sealed identifier between deployments (#3777). No risk added to the register |
| 9 | 2026-10-22 | Reviewed for release 5.3.0 against the security-relevant paths changed since 5.2.0. (l): the access log is written through FerroAUDIT’s ehds-logging and ihe-atna 0.2.4 (#3887, #3902): the record carries the token’s issuer, client and jti, the ITS-REST operation id and the outcome again, names each category by its HL7 Europe <system>|<code> token and says what the classification was read from (audit migration 0012); the syslog feed sends over RFC 5425 TLS only, so the UDP transport and its audit_syslog_udp deployment gap are gone. (d): authentication moves to ferrohealth-smart 0.4.0, and a Basic request the verified-credential cache admits writes no login record (#3895); the SMART launch route reads ehr_id with the one form decoder every query parameter now uses (#3891). (a): rustls-webpki 0.103.16 and the webpki roots 1.0.10 for RUSTSEC-2026-0381 to -0383 (GHSA-pmpg-vfwr-99qv). Both images build on Rust 1.99.1 with the builder digest re-resolved; the other paths changed only in tests. No risk added to the register |
| 10 | 2026-10-22 | Reviewed for release 5.3.2 against the security-relevant paths changed since 5.3.0; the v5.3.0 and v5.3.1 tags exist but their releases never published, so 5.3.2 is the first release since 5.2.0 and revision 9 covers the rest of its changes. (a): the distroless runtime base of both images is re-resolved to ca-certificates 20250419+deb13u1 for DSA-6572-1 (#3926, GHSA-pmpg-vfwr-99qv), the only package that differs from the previous base, which removes the compromised eMudhra (emSign) intermediates from the system trust store. No risk added to the register |
| 11 | 2026-10-24 | Reviewed for release 5.4.0 against the security-relevant paths changed since 5.3.3. (f): in [signing] mode = "pgp" an ECDSA or Ed448 signing key is refused at boot, so the constant-time defect of RUSTSEC-2026-0387 in crypto-bigint 0.5.5 is reached only over public keys and signatures, in verification (#3965, security/vex/rust-advisories.openvex.json); the signer takes the newest subkey that may sign, never a revoked or expired one, the primary key only when its self-signature grants signing, and no subkey while the primary key is revoked or expired (RFC 9580 §5.2.3.10, §5.2.3.13; #3967, #3969, #3971). (l): the audit drain closes its syslog connection with a TLS close_notify when it exits, so the records it sent last are not lost (#3959, ihe-atna 0.2.5). (a): the viewer image build no longer downloads the Tailwind standalone binary, one fewer third-party build input (#3932). No risk added to the register |
| 12 | 2026-10-24 | Reviewed for release 5.4.1 against the security-relevant paths changed since 5.4.0. (d): under SMART an authenticated request for a method the route does not serve is answered 405 with Allow before the scope check, and a request without a credential is still 401, so the scope check runs only on served methods (#3987). (e): the CDR asks one terminology server under [terminology.server] with one client credential, mounted by the chart as secrets.terminologyClientSecret; its token endpoint URL is masked in every rendering of the grant; terminology inputs travel in a POST body, so a code no longer appears in a URL; the per-server mutual-TLS certificates and pinned trust bundles are removed, and the server is verified against the platform trust store (#3085). The chart’s NetworkPolicy wording follows the renamed key without a rule change. No risk added to the register |
| 13 | 2026-10-25 | Reviewed for release 5.4.2 against the security-relevant paths changed since 5.4.1. (f): in [signing] mode = "pgp" a valid Certification Revocation of a Direct Key signature, created at or after it, cancels that signature, so neither its signing flag nor its key expiration lets the primary key sign (RFC 9580 §5.2.1.13, §5.2.3.10; #3972). (l): an access record whose patient lookup failed says unavailable instead of not-found, so a search by the patient’s identifier can tell a failed lookup from an EHR without a subject (Regulation (EU) 2025/327 Art 9(1); #3900); ihe-atna 0.2.7 answers the syslog connection’s liveness probe without a timer tick, which removes a millisecond wait per record from the audit drain (#3954). (d): ferrohealth-smart 0.5.0 refuses authorization-server metadata that repeats a member name (RFC 8259 §4; #4002). (a): utoipa-swagger-ui 10.0.2 takes the yanked zip 8.6.0 and its three extraction advisories out of the build (#3799; GHSA-vv5m-jxxh-4627, GHSA-4v9h-p7m9-rh26, GHSA-4684-h2q9-2vcp). No risk added to the register |
| 14 | 2026-10-27 | Reviewed for release 5.5.0 against the security-relevant paths changed since 5.4.2. (a): the zstd bindings move to zstd 0.14.3, zstd-safe 8.2.0 and zstd-sys 2.1.2, past four RUSTSEC advisories that response compression and decompression reach (#4095, GHSA-cwhv-87pq-3c3c); ferrohealth-smart 0.7.1 masks the userinfo of a grant-table URL no parser accepts (#4038, GHSA-vphj-3f8c-5hpx); the PostgreSQL image base is re-pinned to the 2026-10-21 respin of 18.6 (#4084). (d): opaque access tokens are checked by token introspection under [auth] mode = "introspection" (#4033); [auth] ca_file trusts a private issuer CA on every platform, with its chart value (#3909); the issuer metadata file is read through the shared parse (#4165); the caller’s roles come from ferrohealth-smart and the policy decision from ferrohealth-authz, with [authz.rbac] user_role retired (#4028, #4038); Basic credentials are cached for 300 seconds by default (#4088); a cohort query and an AQL query rooted at a demographic class apply the population gate (#4247). (g): the EHR index asks an IHE patient identity source over PIXm by default and a server without one refuses to start unless [ehr_index] provider = "local" is set, so subject identifiers leave the instance only to the source the deployment names (#3380, #4242); both new flows are in the outbound data flows table, and the chart’s usageReport.enabled=false now reaches FerroTERM, FerroBRIDGE and FerroPIX (#4270). (j): the server and the viewer run on the family HTTP stack of ferrohealth-server (#4137, #4139), the server reads only the configuration file it is named (#4087), and Template Data Document import and its routes are removed (#4199). (l): the audit trail takes a forwarded client address only from a [server] trusted_proxies peer (#4025); syslog frames are delivered from ihe-atna’s spool with retries (#4029); the ITI-81 retrieval is read and answered by ihe-atna and requires a date (#4030, #4040); the access record carries the data permit (#3379, #4120) and every access to one EHR names the EHR and the user (#4192); the chain check verifies from the last intact link (#4100, #4102). (m): each access record is deleted at its own retention end (#3899). No risk added to the register |
| 15 | 2026-10-28 | Reviewed for release candidate 5.6.0-rc1 against the security-relevant paths changed since 5.5.0. (h): ferrohealth-server 0.3.7 closes a plain-HTTP connection that sends no request header within [server] header_read_timeout_ms, counted from accept, and pauses accepting for up to a second after EMFILE or ENFILE, so silent connections from a client with no credentials no longer exhaust the file descriptors (#4283, GHSA-2mc2-h3jq-c5wg). (d): the patient-data gate and the SMART route table judge a route by the namespace it is served under, ITS-REST under /openehr/v1/, FHIR and IHE under /fhir/r4/ and the extensions under /extensions/v1/, so every moved route keeps its gate and its scope (#4330). (f), (l): the audit store renders each record’s AuditEvent from its columns under the render policy the record names, the chain digest covers every column, the search tokens included, in one version, each render policy and search token is checked against its digest, and audit migration 0017 refuses a store holding records of an earlier release (#4300, #4302, #4313); a deployment that declares no category map classifies through the map FerroEHR ships, and the production profile refuses one with no map at all as the no_category_map gap (#4298). (a): the server and viewer images build on the refreshed gcr.io/distroless/cc-debian13:nonroot digest. No risk added to the register |
| 16 | 2026-10-29 | Reviewed for release candidate 5.6.0-rc2 against the security-relevant paths changed since 5.5.0, after revision 15 reviewed 5.6.0-rc1, which was tagged and not published. (d): the ABAC request carries category and origin resource attributes on composition, contribution and query operations, which the Cedar schema declares and a remote PDP binding may name (#4299). (l): a body without FEEDER_AUDIT committed directly by a client the [origins] register names records that client’s origin instead of the server’s system_id (#4299). (m): a retention period may be keyed on a registered origin, and the due list lists such a period over the objects of that origin (#4299). (b): the Helm chart has no default deployment profile and refuses to render until config.deployment_profile names sandbox or production, so an installation no longer runs the sandbox posture by omission (#4294). (d): a [server.tls] table that sets client_ca_file under client_auth = "none" is refused at load on ferrohealth-server 0.3.9, since such a listener asks no client certificate (#4349, GHSA-52mf-qg39-4q4j). (l): the FHIR rendering of an unclassified access record carries the BALP entity:data slice (#4353). No risk added to the register |
| 17 | 2026-10-29 | Reviewed for release candidate 5.6.0-rc3 against the security-relevant paths changed since 5.6.0-rc2. (d): the authentication layer, the ABAC enforcement point and the EHR access gate answer their 401 and 403 as RFC 9457 problem details, and under the FHIR base as an OperationOutcome, with the same status and the same decision as before; the refusal names the request id as its instance (#4342). (l): FerroEHR takes ehds-logging and ihe-atna 0.3.2, and the FHIR AuditEvent names the object an access touched by its openEHR object id as entity.what.identifier (#4366); the audit store writes a drained batch, its search tokens and its token sets in one transaction, and the GIN index on search_tokens collects new entries in a 512 kB fast-update list (GIN fastupdate), set by audit migration 0019 with 0017 kept at its 5.6.0-rc2 text, so the chain and its digests are unchanged and an audit store that 5.6.0-rc2 migrated upgrades in place (#4375, #4424); the fail-closed 503 of the audit middleware is problem details (#4342). (h): ferrohealth-server 0.4.0 closes a kept-alive connection idle for [server] idle_timeout_ms (120000 by default), and [server] header_read_timeout_ms counts from the first byte of each later request head, so a silent connection is still closed after accept by the head bound and an idle one by the idle bound (#4417). (b): the chart runs FerroPIX 0.0.12 by digest with its chart 0.3.4, whose pods run without service links and in a user namespace by default (#4392). No risk added to the register |
Hazard log: the logging component
This page is the clinical safety hazard log of FerroEHR’s European logging software component, the part of the EHR system that “provides logging information related to access by health professionals or other individuals to priority categories of personal electronic health data” (EHDS Art. 2(2)(o), Regulation (EU) 2025/327). EHDS Annex II 1.1 asks that the harmonised software components “achieve the performance intended by its manufacturer” and be designed so that, “during normal conditions of use, they are suitable for their intended purpose and their use does not put at risk patient safety”.
The Regulation names no method or standard for showing that, so the form of this log is FerroEHR’s own design. For each hazard it states the cause, what could happen to a patient, the control, the test that shows the control holds, and what remains. “Normal conditions of use” are those of the intended-purpose statement. The log is the EHDS-side companion of the CRA risk assessment, and it is versioned with the release like every page of this book. The performance side of Annex II 1.1, the component’s stated performance and the test that measures it on every release, is on the logging component’s intended performance.
- The component and the surface it records
- H1: an access is not recorded
- H2: a record names the wrong person, organisation or patient
- H3: the category of the data is not recorded
- H4: a record misstates where the data came from
- H5: the log is lost, or reaped too early
- H6: a patient cannot read their log
- H7: the access log is altered
- H8: the log discloses more than it should
- H9: an emergency access to data restricted under EHDS Art. 8 is not marked
- When the log is reviewed
Warning
The quotations are from the Official Journal text vendored in the repository at
docs/law/eu/ehds/text.html(EUR-Lex). A control and a passing test show what the software does; they do not show that the component meets Annex II, which no conformity assessment has examined.
The component and the surface it records
The component is, in the code: the FerroHEALTH products’ shared crates
ehds-logging 0.2.7 (the Annex II 3.2 access record, the Art. 14(1)
categories and their classification, the Art. 9(2) retention floor and the
emergency mark) and ihe-atna 0.2.7 (the BALP and DICOM renderings and the
ATNA sender), from FerroAUDIT; ferroehr::system_log (the access-event model,
the audit store, what each rendering says and the sinks),
ferroehr-rest::system_log (the middleware that records every API access and
classifies it), the ITI-81 GET /fhir/r4/AuditEvent retrieval, and the
retention register in ferroehr::storage::marks. The middleware is installed
outermost on the API router, around the authentication layer, so it sees every
response of the openEHR REST surface, refusals included. What each record
holds, field by field against Annex II 3.2, is on the
audit trail page.
Test paths below are relative to the repository root. Each was read for this log; “no test” means none exists.
H1: an access is not recorded
Cause: a route the middleware does not classify; a record dropped when the emission queue is full or the store is unreachable; a domain-level access (resolving a subject to an EHR, an identifier resolution, an EHR Extract export) that skips the middleware; reads made straight against the database.
Effect: a patient, or the organisation investigating a breach, cannot learn that their record was read. Under EHDS Art. 9 a patient has a right to that information.
Control: every generated ITS-REST operation has an explicit audit
classification, and an unknown operation falls back to the audited default.
The extension routes that change a record carry one too: the physical delete
of a demographic party is recorded as a delete of that party, as the physical
EHR delete is of the EHR. A rejected access (401, 403) is always recorded. Under
[audit] fail_mode = "closed" a request whose record cannot be taken is
answered 503 and the domain-level operations withhold their result. An AQL
execution emits one record per EHR it served. deployment_profile = "production" refuses audit switched off, audit with no durable sink and
audit failing open, unless each is accepted by name.
Shown by:
app/ferroehr-rest/src/system_log/classify.rs:every_generated_operation_is_explicit,every_read_operation_produces_an_access_record,unrecognised_operation_fails_closed_to_default,representative_mappings;app/ferroehr-rest/tests/it/admin_party_delete_http.rs:an_admin_deletes_the_party_and_the_access_record_names_it;app/ferroehr-rest/tests/it/audit_e2e.rs: one test per resource family (for examplecomposition_get_emits_read_record,aql_execute_emits_execute_record),unauthenticated_request_emits_401_record,fail_closed_returns_503_when_channel_full,fail_closed_503_carries_openehr_error_body_and_retry_after,aql_execute_emits_one_access_record_per_served_ehr;app/ferroehr/tests/it/access_event_fail_closed.rs:a_linkage_resolution_is_withheld_when_its_record_is_rejected,a_subject_lookup_is_withheld_when_its_record_is_rejected,an_identifier_resolution_is_withheld_when_its_record_is_rejected,an_extract_export_is_withheld_when_its_record_is_rejected;app/ferroehr/src/config/deployment.rs:production_refuses_each_open_gap_and_names_the_remedy,production_refuses_the_open_access_default_and_the_open_audit_fail_mode;app/ferroehr/tests/it/legal_marks.rs:setting_and_lifting_a_mark_is_recorded_in_the_access_trail;app/ferroehr-rest/tests/it/audit_route_coverage.rs:every_route_writes_exactly_one_access_recordenumerates every route the assembled router mounts, the extension routes included, drives each one once, and asserts exactly one access record carrying its operation id. The routes it exempts (the health probes, the public status and discovery documents, the OpenAPI document and Swagger UI, theOPTIONSmanifest and the management surface) are listed in the test with the reason.
Remains: the shipped fail_mode is open, so under the sandbox
profile a full queue drops a record and the request succeeds
(fail_open_serves_request_when_channel_full pins that behaviour). Database
access outside the server is recorded nowhere.
H2: a record names the wrong person, organisation or patient
Cause: the actor taken from something other than the verified credential; an organisation guessed when the token carries none; the EHR taken from the wrong part of the request; one record written for an AQL page that served several patients.
Effect: an access is attributed to a professional who did not make it, or recorded against the wrong patient, so a patient is told of an access to someone else’s record and the real one goes unseen.
Control: the principal comes from the authenticated credential; a 401
records the caller as unknown and never fabricates one. The organisation is
read from the same token claim the ABAC layer reads, and stays empty when
there is no claim or the caller used Basic. The EHR comes from the resource
the dispatch resolved, or from the request path where none exists. An AQL
execution records each served EHR separately. With
[authz.professional] configured, a token that names no natural person is
refused on patient data unless it is a client token acting for a named
professional, and the record carries the acting mode (person or client)
and that professional; with [authz.assurance] configured, it carries the
assurance level the token maps to, and a token below the configured minimum is
refused with a step-up challenge. The DICOM message and the FHIR
AuditEvent give the client and this server the same data-flow role (DICOM
PS3.15 §A.5.2.7, IHE BALP): on a read the server is the source and the client
the destination, on a write or a query the client is the source. A FHIR
record of a delete carries the agent types the BALP Delete profile fixes
(the client as DCM 110150 Application, the server as custodian). The
FHIR AuditEvent names the object a read, create, update or delete touched
by its openEHR object id, as entity.what.identifier in the system
urn:openehr:object-id, and ITI-81 finds it by entity.identifier.
Shown by:
app/ferroehr/tests/it/audit_ehds_mapping.rs:element_a_the_accessing_organisation_is_recorded_and_rendered_in_fhir,element_b_the_person_who_accessed_is_recorded_and_rendered,element_b_the_professional_behind_a_client_is_recorded_and_rendered,the_assurance_level_is_recorded_and_reaches_the_fhir_rendering_as_a_level,nen_7513_the_actor_roles_are_recorded_and_rendered_in_both_formats;app/ferroehr/tests/it/audit_store.rs:access_fields_round_trip_on_both_write_paths,the_accessing_organisation_round_trips_on_both_write_paths,the_actor_roles_round_trip_on_both_write_paths,both_inserts_persist_the_authentication_columns;app/ferroehr-rest/tests/it/patient_data_assurance.rs:a_token_below_the_minimum_gets_the_step_up_challenge,a_client_token_without_acting_for_is_refused,a_client_acting_for_a_professional_passes_and_is_recorded;app/ferroehr-rest/src/system_log/middleware.rs:ehr_id_from_the_matched_route,object_id_from_template_paths,object_id_from_query_paths,object_id_from_demographic_paths;app/ferroehr-rest/tests/it/audit_e2e.rs:unauthenticated_request_emits_401_record,aql_execute_emits_one_access_record_per_served_ehr;app/ferroehr/src/system_log/fhir.rs:dicom_and_fhir_agree_on_the_data_flow_direction,delete_carries_the_agent_types_balp_delete_fixes,a_classified_read_names_its_object_by_identifier.
Remains: both blocks are off by default, and until the deployment
configures [authz.professional] a client application’s credential is
recorded as that application, not as the professional behind it. A shared
Basic account names the account; with either block configured, Basic is
refused on patient data. The DICOM format has no element for the assurance
level, so the syslog feed carries none; the FHIR record carries the level as
the BALP ihe-assuranceLevel extension, and the raw claim value stays in the
local store’s column.
H3: the category of the data is not recorded
Cause: a template or archetype missing from the deployment’s
[audit.categories] map; a record type the classifier does not reach.
Effect: a record cannot say whether the access reached a patient summary, a prescription or a test result, which Annex II 3.2(c) asks for and the patient’s information under EHDS Art. 9(2)(c) depends on.
Control: each access is classified when it happens by ehds-logging, by
template id, then root archetype id, then the query’s positive constraints,
every one of which must be mapped. A logical delete is
classified by the version it deleted, a VERSIONED_COMPOSITION read by the
composition it holds, a CONTRIBUTION read (with or without
Prefer: resolve_refs) by the versions it committed, and an EHR Extract
export or import by the versions it carried or landed. EHR, EHR_STATUS,
EHR_ACCESS and the directory are recorded none by resource kind. While
the deployment declares no template and no archetype, the map FerroEHR ships
for the published CKM templates and COMPOSITION archetypes of one priority
category applies, and deployment_profile = "production" refuses a
deployment that switches it off with nothing declared. An access the map
cannot classify is recorded unclassified, with the ids as evidence, and is
never refused. Each record carries the digest of the map that classified it.
Shown by:
app/ferroehr-rest/tests/it/audit_categories.rs:an_unmapped_template_is_unclassified_with_its_ids_as_evidence,a_multi_category_query_records_every_category_it_served,a_leaf_only_query_is_classified_from_its_constraints,a_zero_row_query_is_classified_from_its_constraints,a_not_contains_operand_never_counts,a_flat_write_is_classified_by_its_template,a_logical_delete_is_classified_by_the_version_it_deletes,a_versioned_composition_read_is_classified_by_its_composition,a_contribution_read_is_classified_by_its_versions,the_shipped_map_classifies_an_undeclared_deployment;app/ferroehr/tests/it/service_message_audit.rs:the_extract_records_carry_the_categories_of_the_data;app/ferroehr/tests/it/audit_ehds_mapping.rs:element_c_the_priority_category_is_recorded_and_rendered_in_fhir_only.
Remains: the shipped map covers only the published CKM templates and
archetypes whose data sit in one priority category, and a deployment’s own
map replaces it whole, so a map that misses a template the deployment uses
yields unclassified records. Records in the
demographic domain carry no classification, because no priority category
lives there. The Helm chart carries a deployment’s own map as
config.audit.categories, empty by default, so the shipped map applies.
H4: a record misstates where the data came from
Cause: a body’s FEEDER_AUDIT provenance ignored; an AQL page’s
origins taken from one row only.
Effect: a patient or a reviewer is told the wrong system created the data that was read (Annex II 3.2(e)).
Control: each committed version records the distinct originating systems
of its body; a body with none records the origin of the registered client that
committed it directly ([origins]), or this server; a read records the origins of
what it served, an AQL page the union, with the true count beside a capped
set. The FHIR AuditEvent of a read or a query carries the origins of the
data it served.
Shown by: app/ferroehr/tests/it/access_origins.rs:
a_body_without_feeder_audit_has_this_server_as_its_origin,
a_registered_client_stamps_its_origin_on_a_direct_commit,
feeder_audits_anywhere_in_the_body_count_once_each,
an_aql_page_records_the_union_of_the_origins_it_served,
an_unstamped_row_is_assessed_from_the_body_at_read;
app/ferroehr-rest/tests/it/audit_e2e.rs:
composition_get_records_the_origins_of_the_served_data;
app/ferroehr/src/system_log/fhir.rs:
query_record_carries_the_origins_of_the_served_data.
Remains: the DICOM rendering carries no origins, because PS3.15 defines no element for them; the local store and the FHIR export carry them.
H5: the log is lost, or reaped too early
Cause: a retention period shorter than the law requires; a reaper that deletes more than it should; the database that holds the local store lost; a forwarding sink down.
Effect: the information a patient has a right to for “at least three years from each date of access” (EHDS Art. 9(2)) no longer exists.
Control: each jurisdiction has a registered retention floor (three years
in every EU Member State, longer where national law sets more), and a
configured period below it stops the boot. The reaper deletes only records
past the horizon, in calendar years where the period is given in years, and
the chain stays verifiable after it runs. The forwarding outbox keeps
records until the sink takes them, and the audit drain closes its syslog
connection with a TLS close_notify when it exits, so the records it sent
last are not lost to a reset. production refuses audit with no
durable sink unless accepted by name.
Shown by:
app/ferroehr/src/config/mod.rs:a_retention_below_the_jurisdiction_floor_is_refused,the_ehds_floor_holds_in_every_member_state_and_a_longer_national_floor_wins,retention_years_meets_a_floor_in_years_exactly,a_ceiling_below_a_floor_is_refused;app/ferroehr/src/system_log/config.rs:the_ehds_floor_reaches_every_member_state_and_a_longer_national_floor_wins;app/ferroehr/tests/it/audit_store.rs:reap_deletes_only_rows_past_the_horizon,reap_years_deletes_only_rows_past_the_calendar_horizon;app/ferroehr/tests/it/audit_chain.rs:retention_reaping_leaves_a_verifiable_trail;app/ferroehr/tests/it/audit_feed.rs:arr_outage_leaves_rows_pending_then_delivers_on_recovery;app/ferroehr/tests/it/system_log_tls_roundtrip.rs:the_drain_closes_its_connection_with_close_notify_when_it_exits.
Remains: a local store shares the fate of its database, so backups and an off-box sink are the deploying organisation’s (B7).
H6: a patient cannot read their log
Cause: the log reachable only with an administrator credential; no retrieval scoped to one patient.
Effect: the information EHDS Art. 9 gives a patient cannot be produced.
Control: ITI-81 GET /fhir/r4/AuditEvent searches the log by patient,
agent, action and date, and a configured subject audit role reads one
subject’s records and nothing else. EHDS Art. 9(2) has the information
provided “through electronic health data access services”, which the Member
States establish (Art. 4(1)); FerroEHR serves the log to such a service and
has no patient-facing interface of its own.
Shown by: app/ferroehr-rest/tests/it/audit_iti81.rs:
searchset_bundle_with_filters_and_paging,
the_subject_audit_role_reads_one_subjects_log_and_nothing_else,
rbac_gates_the_audit_surface_to_admins.
Remains: the subject audit role is unset by default, so the log is admin-only until the deployment configures it.
H7: the access log is altered
Cause: a record changed or deleted in the database, by a compromised application role or by someone with direct access.
Effect: an access disappears, or appears to have been made by someone else, and the record a patient or a court relies on is false.
Control: records are hash-chained in the database, and a record’s digest
covers every column but the digest and the two forwarding stamps, its search
tokens included (audit migration 0017); each render policy a record names is
checked against its own digest, and each search token and token set against
its content; UPDATE, DELETE
and TRUNCATE on the log are refused; the application role cannot run DDL
or rewrite the log; audit.verify_audit_chain() names any record that
changed or went missing; the server runs that verification one minute after
boot and then every [audit.store] verify_interval_seconds (default one day),
logs a finding at ERROR, counts it in atna_audit_chain_findings and shows
it on the audit_chain readiness indicator; forwarding puts copies off the
box.
Shown by:
app/ferroehr/tests/it/audit_chain.rs:a_modified_audit_record_is_detected_and_named,a_deleted_audit_record_is_detected_in_the_middle_and_at_the_end,the_audit_table_refuses_mutation_deletion_and_truncation,the_application_role_cannot_run_ddl_or_rewrite_the_audit_trail,the_batched_write_path_is_chained_too,the_chain_check_reports_a_tampered_record_on_the_health_surface,the_audit_subsystem_schedules_the_chain_check,the_digest_covers_every_column,every_hashed_column_is_tamper_evident,a_changed_or_missing_render_policy_is_reported,a_changed_search_token_or_set_is_reported;app/ferroehr/tests/it/audit_store.rs:the_access_trail_refuses_rewriting_deletion_and_truncation.
Remains: the chain gives evidence of tampering and does not prevent it.
A deployment that sets verify_interval_seconds = 0 finds a broken chain only
when an operator runs the check. The indicator never flips readiness, so a
finding reaches someone only through an alert on the counter or the
indicator, which the deploying organisation sets up.
H8: the log discloses more than it should
Cause: the category classification or identifying content copied into request logs, spans or metrics; the log served to a caller with no right to it.
Effect: the log of who read a patient’s record becomes a disclosure of that record.
Control: the classification stays in the access log; telemetry carries no identified data; the ITI-81 surface is authorised like any other.
Shown by: app/ferroehr-rest/tests/it/audit_categories.rs:
the_classification_never_reaches_spans_logs_or_metrics;
app/ferroehr/tests/it/privacy_leak.rs:
the_atna_records_carry_the_opaque_pseudonym_and_nothing_else;
app/ferroehr-rest/tests/it/audit_iti81.rs:
rbac_gates_the_audit_surface_to_admins.
Remains: the log names patients and actions by design, so whoever holds ITI-81 access reads that.
H9: an emergency access to data restricted under EHDS Art. 8 is not marked
Cause: a natural person restricts the access of health professionals to some of their data (EHDS Art. 8), and a professional reaches that data anyway because the person’s vital interests require it (Art. 11(5)). Without a mark, the access record of that emergency looks like any other.
Effect: EHDS Art. 9(1) includes “access provided in accordance with Article 11(5)” in what a patient is told about, and Art. 11(5) asks that such cases be “logged in a clear and understandable format” and be “easily accessible for the data subject”. An unmarked emergency access hides that it happened.
Control: a deployment names the purpose-of-use codes its callers declare
for an emergency ([audit] emergency_purpose_codes, each also on
purpose_codes). An access declaring one carries the emergency mark in its
access record: the emergency_access column, and in the FHIR AuditEvent the
ehds-logging entity ehds-emergency-access, which states the mark in plain
words and names the purposes that marked it.
The person’s own subject-scoped ITI-81 retrieval serves that document. The
mark lifts no restriction: an object restricted under GDPR Art. 18 is refused
with an emergency purpose exactly as without one, and the refused attempt is
logged with the mark.
Shown by: app/ferroehr-rest/tests/it/audit_emergency_access.rs:
an_emergency_access_is_marked_and_shown_in_the_subjects_own_access_log,
an_emergency_purpose_does_not_lift_a_gdpr_restriction;
app/ferroehr/tests/it/audit_ehds_mapping.rs:
art_11_5_the_emergency_mark_reaches_the_fhir_rendering_only;
app/ferroehr/tests/it/audit_store.rs:
both_inserts_persist_the_emergency_mark_and_it_cannot_be_rewritten.
Remains: the Art. 8 restriction hides restricted data from every
professional route, query and export, and only a caller holding
authz.rbac.emergency_access_role that declares an emergency purpose reads it
(app/ferroehr-rest/tests/it/access_restriction_http.rs:
only_an_emergency_access_reads_restricted_data_and_is_marked). The mark rests
on the purpose the caller declares, and an emergency the caller does not
declare is not marked. The DICOM syslog rendering has no purpose element and
does not carry the mark.
When the log is reviewed
The log is reviewed in the release that changes any module of the component listed above, with the hazards, controls and test names checked against that release’s tree. It is also reviewed when an incident, a complaint or a vulnerability shows a hazard the log does not name. The technical documentation cites it as the EHDS-side risk assessment of the component.
| Version | Date | Change |
|---|---|---|
| 1 | 2026-10-06 | First log, nine hazards |
| 2 | 2026-10-07 | H9 names the EHDS Art. 8 restriction; the emergency mark and the subject-scoped retrieval are its control |
| 3 | 2026-10-08 | Reviewed for release 4.3.6 (changes since 4.3.5: the assurance level and the natural person, the emergency mark, the FHIR connector removed beside the ITI-81 route); every named test exists and asserts its control; no hazard added |
| 4 | 2026-10-15 | Reviewed for release 4.3.9 (changes since 4.3.6: digest version 2 of the audit chain over every column, audit migration 0011 (#3686); one data-flow direction for the DICOM and FHIR renderings (#3687) and the BALP Delete agent types (#3750); the physical party delete classified and its party named (#3683); every audit-store and retention-register checkout through the typed database acquire (#3749)); every named test exists and asserts its control; H1, H2 and H7 name the new tests, H7 the version 1 records; no hazard added |
| 5 | 2026-10-20 | Reviewed for release 5.1.0 (changes since 5.0.0: the audit FHIR feed authenticates with an OAuth 2.0 client-credentials token, and a token endpoint that refuses or cannot be reached is a transport error, so the records stay pending and are delivered on recovery as H5 states (#3829); the assurance and professional rules the access record carries move under [authz] (#3872)); every named test exists and asserts its control; no hazard added |
| 6 | 2026-10-20 | Reviewed for release 5.2.0 (changes since 5.1.0: the EHDS Art. 8 restriction of access lands, so H9’s control is the restriction with its emergency override and the mark on that access record (#3682); a read of a party’s protected identifiers is recorded per scheme (#3776); the access context carries whether a request enforces the restriction); every named test exists and asserts its control; no hazard added |
| 7 | 2026-10-20 | Reviewed for the move onto FerroAUDIT (#3887): the access record, its categories and their classification, the retention floor and the emergency mark come from ehds-logging 0.1.1, the BALP and DICOM renderings and the ATNA sender from ihe-atna 0.1.1; the syslog feed sends over TLS only; H2, H3 and H9 name the moved tests; every named test exists and asserts its control; no hazard added |
| 8 | 2026-10-22 | Reviewed for release 5.3.2 (changes since 5.2.0: the move onto FerroAUDIT of review 7 (#3887), then ehds-logging and ihe-atna 0.2.4 (#3902), so the record carries the token’s jti, the operation subtype, outcomeDesc and the optional DICOM elements again, a Basic user is recorded with no issuer, a retention without end or under three years is stated as such, and archetype ids are compared as openEHR BASE 1.3.0 asks with each id kept as the access named it (#3908); the ITI-81 search form-decodes its query string (#3891)); every named test exists and asserts its control; no hazard added |
| 9 | 2026-10-24 | Reviewed for release 5.4.0 (changes since 5.3.3: ehds-logging and ihe-atna 0.2.5, and the audit drain closes its syslog connection with a TLS close_notify when it exits, logging and counting a close that fails, so the records it sent last are not lost to a reset (#3959); H5 names its test); every named test exists and asserts its control; no hazard added |
| 10 | 2026-10-27 | Reviewed for release 5.5.0 (changes since 5.4.2: syslog frames are delivered from ihe-atna’s spool with retries, and [audit.fhir_feed] batch_size and max_retries are gone (#4029); the ITI-81 query is read and answered by ihe-atna’s retrieve and requires a date (#4030, #4040); each access record is deleted at its own retention end (#3899); the chain check verifies from the last intact link, and the FHIR feed owes only the records written while it is configured (#4100, #4102); the access record and its renderings carry the data permit (#3379, #4120); the source address comes from the family server stack, believing a forwarded address only from a trusted proxy (#4025); every access to one EHR names the EHR and the user, so H2 names ehr_id_from_the_matched_route, the test #4192 put in place of ehr_id_from_various_paths); every named test exists; no hazard added |
| 11 | 2026-10-28 | Reviewed for release candidate 5.6.0-rc1 (changes since 5.5.0: the store keeps each record’s facts in columns and renders its AuditEvent on read under the render policy the record names, the chain digest covers every column in one version, and audit migration 0017 refuses a store holding records of an earlier release, so no record keeps an older digest and H7 drops that remainder (#4300); the recorded_at indexes ascend (#4302); every ITI-81 parameter answers through search tokens the chain covers (#4313); the ITI-81 retrieval moves to /ferroehr/rest/fhir/r4/AuditEvent (#4330); a deployment that declares no category map records the published CKM priority-category templates and archetypes through the shipped map, so H3 names its test and its remainder no longer says FerroEHR ships none (#4298); H7 names the render-policy and search-token tests and the digest test #4300 renamed); every named test exists; no hazard added |
| 12 | 2026-10-29 | Reviewed for release candidate 5.6.0-rc2 (changes since the 5.6.0-rc1 review: a body without FEEDER_AUDIT committed directly by a client the [origins] register names records that client’s origin, so H4 names it and its test, and the retention register keys a period on the origin of the data (#4299); H3’s remainder said the binary ships no category map, which #4298 changed; the FHIR rendering of an access record ehds-logging does not classify carries the BALP entity:data slice its profile requires (#4353); the component’s intended performance is stated and measured (#4293), and its ATNA transactions are exercised against independent implementations (#4296)); every named test exists; no hazard added |
| 13 | 2026-10-29 | Reviewed for release candidate 5.6.0-rc3 (changes since the 5.6.0-rc2 review: FerroEHR takes ehds-logging and ihe-atna 0.3.2, and the FHIR AuditEvent names the object it touched by its openEHR object id as entity.what.identifier where it wrote a what.reference no FHIR server resolves, so H2 states it and names its test (#4366); the audit store writes a drained batch, its search tokens and its token sets in one transaction on one connection, and the GIN index on search_tokens collects new entries in a 512 kB fast-update list (GIN fastupdate) that audit migration 0019 sets, which changes no control H5 or H7 states (#4375, #4424); the ITI-81 route answers an authentication refusal as an OperationOutcome, the fail-closed 503 is RFC 9457 problem details, and fail_closed_503_carries_openehr_error_body_and_retry_after asserts the problem body (#4342)); every named test exists; no hazard added |
The logging component’s intended performance
EHDS Annex II 1.1 asks that the harmonised software components “achieve the
performance intended by its manufacturer” and be suitable for their intended
purpose in normal conditions of use (Regulation (EU) 2025/327,
docs/law/eu/ehds/text.html).
Annex III point 2 asks the technical documentation for the system in place to
evaluate that performance. This page states the performance Cadasto B.V.
intends for FerroEHR’s European logging software component, as claims a test
can check, and the test that checks them. The Regulation names no method and
no figure, so both are FerroEHR’s own design.
Warning
A met claim shows what the software did on the run that measured it. It does not show that the component meets Annex II, which no conformity assessment has examined.
The claims
The component is the access log the hazard log assesses:
every access through the API is recorded, by ehds-logging and ihe-atna
through ferroehr::system_log, in the local store, and retrieved through IHE
ITI-81. The claims hold under the deployment posture the
instructions for use
set for real data: the access log on, the local store on, and
[audit] fail_mode = "closed".
| Claim | Stated performance | How it is checked |
|---|---|---|
| Completeness | Every composition create and read has exactly one access record, and every AQL execution at least one (one per EHR it served). | Measured on every run: the access records of the measured window are counted through ITI-81 per operation id and compared with the operations that ran. |
| Loss under outage | No access succeeds without its record. A record the queue or the local store cannot take refuses the request with 503, and a record owed to a FHIR Audit Record Repository that is down stays owed and is delivered when it returns. | Deterministic tests, not a load measurement: app/ferroehr-rest/tests/it/audit_e2e.rs (fail_closed_returns_503_when_channel_full, fail_closed_503_carries_openehr_error_body_and_retry_after), app/ferroehr/tests/it/access_event_fail_closed.rs, and app/ferroehr/tests/it/audit_feed.rs (arr_outage_leaves_rows_pending_then_delivers_on_recovery). |
| Latency added | With the access log on, the median p50 and p95 request latency are at most 125 % of those with it off. | Measured: the same workload with the log off and on, on one machine. |
| Throughput kept | With the access log on, the median throughput is at least 80 % of that with it off. | Measured, as above. |
| Retrieval | A page of 50 access records retrieved through ITI-81 from the measured window takes at most a quarter of a second at p95 (MAX_RETRIEVAL_P95_MS in the verdict script). | Measured: 50 paged retrievals after each run. |
“Normal conditions of use” for these figures are the load of the release
performance guard: a store seeded with 20 EHRs of 10 compositions each, then
2,000 operations over 8 concurrent workers, of every 20 six composition
creates, eight composition reads and six AQL queries
(tools/loadtest).
The intended-purpose statement names no load, so this one is chosen to be the
one every release is already measured under. The ratios compare two runs on
one machine, so they hold across machines in a way an absolute latency would
not; the retrieval figure is absolute and generous for that reason.
The test
scripts/perf/audit.sh
starts the quickstart on fresh volumes, three times with the access log off
and three times with it on and failing closed, alternating the order. The
load test runs the workload against each; with the log on it waits for the
asynchronous log to settle after the seed, opens the measured window, and
after the measured phase counts the window’s records per operation id (the
ITI-81 subtype and _count=0) until two readings a second apart agree, then
times the paged retrievals.
scripts/perf/audit-verdict.sh
compares the medians with the claims above and exits non-zero when one is not
met; its --self-test holds each budget to its edge.
The release pipeline runs it on the release candidate in its performance leg.
A claim not met refuses the release, and the summary is attached to the
release as ferroehr-<tag>-logging-performance.md, which is the latest result
for that release. The weekly performance guard runs it on main.
To run it yourself against an image:
cargo build --release -p loadtest
FERROEHR_PORT=8080 bash scripts/perf/audit.sh ghcr.io/ferrohealth/ferroehr:<tag> \
ghcr.io/ferrohealth/ferroehr-postgres:<tag> perf-audit
What it does not measure
- Records over syslog or the FHIR feed are not timed: both send from the stored record, after the request, so they add no latency to it.
- The store’s size over years is not loaded: the retrieval figure holds for the store a run leaves. ITI-81 answers through a token index, so a search reads an index rather than every record, but no run measures a store of millions of records.
- Access made straight against the database, outside the server, is not recorded at all (hazard H1).
Claims review
Every public text about FerroEHR is read, once per release, against EHDS Art. 28 (Regulation (EU) 2025/327). The article reads: “In the information sheet, instructions for use or other information accompanying EHR systems, and in the advertising of EHR systems, it shall be prohibited to use text, names, trademarks, pictures and figurative or other signs that may mislead the professional user […] with regard to their intended purpose, interoperability and security by:
- (a) ascribing functions and properties to the EHR system which it does not have;
- (b) failing to inform the professional user of likely limitations related to interoperability or security features of the EHR system in relation to its intended purpose;
- (c) suggesting uses for the EHR system other than those stated to form part of the intended purpose in the technical documentation.“
This page is the record of those reviews: what was read, what was found, what was changed. It also carries the re-assessment of EHDS Annex II 2.5. The measure for point (c) is the intended-purpose statement.
- What is reviewed, and how
- The review of 2026-10-06
- The review of 2026-10-08
- The review of 2026-10-15
- The review of 2026-10-16
- The review of 2026-10-19
- The review of 2026-10-19, for 5.0.0
- The review of 2026-10-20, for 5.1.0
- Annex II 2.5, re-assessed
- Review record
- The review of 2026-10-20, for 5.2.0
- The review of 2026-10-22, for 5.3.2
- The review of 2026-10-24, for 5.4.0
- The review of 2026-10-24, for 5.4.1
- The review of 2026-10-25, for 5.4.2
- The review of 2026-10-27, for 5.5.0
- The review of 2026-10-28, for 5.6.0-rc1
- The review of 2026-10-29, for 5.6.0-rc2
- The review of 2026-10-29, for 5.6.0-rc3
Warning
The quotations are from the Official Journal text vendored at
docs/law/eu/ehds/text.html(EUR-Lex). Art. 28 takes “professional user” from Article 3, point (8), of Regulation (EU) 2018/1807, which the repository does not carry, so these pages do not restate that definition. A text that passes this review has been read against Art. 28 by the manufacturer; no authority has examined it.
What is reviewed, and how
The texts in scope, each release:
- the repository
README.mdand the landing page atferroehr.eu; - every page of this book, except the pages generated from the tracker and from committed measurement records, whose generators are reviewed instead;
SECURITY.md;- the READMEs of the published
openehr-*crates; - the OCI labels of the three images (
docker/Dockerfile,docker/viewer/Dockerfile,docker/postgres/Dockerfile) and the Helm chart’s description; - the public sandbox at
sandbox.ferroehr.eu.
Each claim about a function, a property, interoperability or security is checked against the code, the configuration defaults and the committed records, and against the intended-purpose statement. A misleading claim is fixed in the same change as the review. Measured numbers are never typed into a text: the conformance and performance figures come from generators over the committed run records, and a CI check fails a page whose numbers disagree with them.
The review of 2026-10-06
Read on the tree after commit d6539d470, before FerroEHR 4.3.5.
Changed
| # | Text | Claim | Art. 28 | Finding | Now reads |
|---|---|---|---|---|---|
| 1 | README, opening | “Every compliance claim it makes is machine-verified” | (a) | Only the openEHR conformance numbers are machine-verified. The regulatory pages are reviewed by hand, and “compliance” invites the regulatory reading | “Every conformance claim it makes is machine-verified” |
| 2 | README, “What makes it different” | “Compliance you can verify.” | (a) | as row 1 | “Conformance you can verify.” |
| 3 | README, licence paragraph and “Security & operations” | “Multi-tenancy […] fully integrated: each tenant is an isolated logical openEHR system, enforced by PostgreSQL row-level security” | (a) | FerroEHR is single-tenant, with no tenant column, row policy or tenancy configuration (one instance, one organisation) | “One instance per organisation: FerroEHR is single-tenant […]”; multi-tenancy removed from the feature list |
| 4 | README, “Security & operations” | national identifiers “sealed under a per-tenant key” | (a), (b) | there are no tenants, and the sealing is off by default | “sealed under a key the deployment holds […] (off by default)” |
| 5 | README, “Security & operations” | “IHE ATNA-compliant system log (DICOM audit messages over TLS syslog)” | (a), (b) | “compliant” claims a property nobody assessed; the syslog sink is off by default and ships with UDP; the default sink is the local hash-chained store | the access log, its two renderings, the local store and the optional forwarding, described as they ship |
| 6 | README, “Security & operations” | “Hardened by default” | (b) | TLS is off by default, and the shipped deployment_profile is sandbox, which must not hold real patient data | “Hardening built in”, with both limitations stated |
| 7 | README, “Integration” | the FHIR R4B connectors, with no limitation | (b) | the in-tree connector was removed in favour of FerroBRIDGE in v4.3.6 (#3080) | the removal is stated |
| 8 | Landing page, title, og: and twitter: descriptions, structured data | “Spec-compliant and measured”, “spec-conformant and measured” | (a) | states conformity as a property; what exists is a measured openEHR conformance record | “openEHR conformance, measured” |
| 9 | Landing page, hero note | “every compliance claim machine-verified” | (a) | as row 1 | “every conformance number machine-verified” |
| 10 | Landing page, conformance section | “Compliance you can verify.” and “Every release runs the complete conformance catalogue against the live server” | (a) | the release pipeline does not run the catalogue; the committed record measured FerroEHR 4.3.0 | “Conformance you can verify.”; the record “names the FerroEHR version it measured” |
| 11 | Landing page, feature grid heading | “Everything the openEHR standard asks for” | (a) | claims complete coverage of the standard, which nothing measures | “The openEHR platform services” |
| 12 | Landing page, Security card | “multi-tenancy” | (a) | as row 3 | “One instance per organisation.” |
| 13 | Book, Introduction | “Compliance you can run yourself” | (a) | as row 1 | “Conformance you can run yourself” |
| 14 | Book, System architecture | “why the compliance claims are checkable”, “the compliance claims are machine-derived” | (a) | as row 1 | “conformance claims” |
| 15 | Book, Beyond the core | “Security, multi-tenancy, and the audit trail” | (a) | as row 3 | “Security, instance separation and the audit trail” |
Read and kept
- The conformance and performance figures on the README, the landing page
and the book: rendered from
docs/conformance/ferroehr/byscripts/render/conformance-stats.shandscripts/render/perf-assets.sh, and checked in CI. No number is typed. - “openEHR-spec-conformant”, on the Conformance page,
in the
openehr-*crate READMEs, in theorg.opencontainers.image.descriptionlabel of the server image and in the Helm chart description: a claim about the openEHR specifications, backed by the committed record (CORE and STANDARD, every case passed) and explained on that page. It makes no claim about any regulation. - “aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture” (the compliance overview, Why FerroEHR exists): stated as an aim, beside the statement that FerroEHR holds no certification and no declaration.
- The compliance pages, the DPIA and the go-live checklist: each says it does not make a deployment compliant with anything.
- The image labels: the server image carries
eu.ferroehr.image.carries-sandbox-posture="true", which tells an operator the shipped profile; the manufacturer label names Cadasto B.V. SECURITY.md: its commitments match the procedures indocs/post-market.mdand the release procedure.- The public sandbox: the viewer shows the sandbox notice on every page, the README calls its content demo data, wiped nightly, and the landing page calls it a live demo.
Left open
- The Helm chart’s description (
Chart.yaml, and the chart README that helm-docs generates from it) says “hardened-by-default security posture” and lists the workload hardening, which is accurate, but does not say that a stock install runs thesandboxprofile. The chart now declaresconfig.deployment_profile: sandboxin its values, and the chart README says so under “Before you install”; theChart.yamldescription still does not.
The review of 2026-10-08
Read on the tree after commit de648fcc3, for FerroEHR 4.3.6. This release
removes the in-tree FHIR connector (#3080) and moves the deployment gaps off
the public status document (#3671), so those two facts were checked in every
text in scope.
Changed on 2026-10-08
| # | Text | Claim | Art. 28 | Finding | Now reads |
|---|---|---|---|---|---|
| 1 | Landing page, “Fits your stack” | “FHIR R4B” | (a) | FerroEHR maps no FHIR resources; it reaches FHIR R4B terminology servers and renders its access log as FHIR R4 AuditEvent | “FHIR R4B terminology” |
| 2 | Book, instructions for use, CRA user information, configuration, go-live checklist | an accepted deployment gap is stated “on GET /ferroehr/rest/status” | (a) | the gaps moved to the authenticated GET /management/status (#3671); the public status document carries the profile only | “on GET /management/status” |
| 3 | Book, instructions for use, the deployment profile | the list of what production refuses | (b) | omitted the plaintext audit feed and the plaintext object store (#3676, #3689) | both named |
| 4 | Book, instructions for use, limitations | none on FHIR | (b) | an operator of an earlier release may rely on the removed connector | FerroEHR maps no FHIR resources, and [fhir] is refused at boot |
| 5 | Book, information sheet, (e) | the standards table | (b) | omitted the FHIR terminology operations and RFC 9470, which this release uses | both listed |
Read and kept on 2026-10-08
- The README’s integration list and the book’s Beyond the core: both state that FHIR R4 mapping is FerroBRIDGE’s and name the two uses of FHIR FerroEHR keeps.
- Single tenancy: the README, the landing page and the book state one instance per organisation; “multi-tenant” appears only where the book quotes openEHR’s placement of multi-tenancy outside one system.
- The Helm chart’s description: it now says a stock install runs the
sandboxprofile (#3637), which closes the item left open on 2026-10-06.
Left open on 2026-10-08
Nothing.
The review of 2026-10-15
Read on the tree after commit 606a29cce, for FerroEHR 4.3.9. The facts of
this release checked in every text in scope: the ABAC patient scope over
EHR-only queries and [authz.abac] patient_namespace (#3771); a restricted
EHR_ACCESS default with the role gate off, and the rbac_off gap (#3742);
national identifiers sealed per party version, the sealed form in write
responses and the refusal of plaintext protected values on import (#3772);
digest version 2 of the audit chain (#3686); the AQL row rules (#3733,
#3734); the removal of FOSSA; the TLS certificate reload and EC keys (#3765,
#3761); the four-cluster production layout (#3755 to #3757, #3762); the
example generator (#3739, #3740); quoted GET query parameters (#3781). The
2026-10-08 move of the deployment gaps to GET /management/status (#3671) was
re-checked on every page, and eleven pages still named the public document.
Changed on 2026-10-15
| # | Text | Claim | Art. 28 | Finding | Now reads |
|---|---|---|---|---|---|
| 1 | Book, licensing, security, go-live checklist (two items), Kubernetes, authentication configuration (two places), Compose (two places), installation, configuration, command line, DPIA R9, compliance overview (GDPR Art. 25, DSV Art. 4) | an open or accepted deployment gap, or the licence in force, is reported on GET /ferroehr/rest/status (or /rest/status) | (a) | the public document carries status, the versions, the timestamp and the profile only; the gaps, the licence and the support period are on GET /management/status (#3671) | “on GET /management/status”; the configuration page adds that the public document carries the profile only |
| 2 | Book, instructions for use, the deployment profile | the list of what production refuses | (b) | omitted the role gate off while authentication is on, the rbac_off gap this release adds (#3742) | named |
| 3 | Book, instructions for use, limitations | none on how AQL reads repeated nodes | (b) | AQL 1.1.0 does not define the rows of a path through a repeated node; this release changed them (#3733), so another server, or a client written for an earlier release, may count differently | the limitation is stated, with the regrouping a client needs |
| 4 | Book, instructions for use, limitations | none on the audit chain’s coverage | (b) | records written before the upgrade keep digest version 1, which leaves out the columns added after the chain was introduced (#3686) | stated, with the columns and the trigger that alone protects them |
| 5 | Book, configuration, migrate_on_runtime_credential | a domain relocated to its own database “is prepared on that domain’s runtime DSN” | (a) | [storage.migrate.<domain>] url now prepares it on its own migration credential (#3756); only without it does the runtime DSN prepare it and open the gap | both cases stated |
| 6 | Book, records of processing, categories of personal data | clinical content “in the ehr schema”; parties “in the demographic schema” | (a) | the schemas are clinical and party since the storage rewrite | clinical and party |
| 7 | Book, Kubernetes, server TLS | the viewer’s OIDC client “trusts the public roots it is built with”, so a private-CA issuer is not trusted | (a) | the viewer’s OIDC client verifies against the platform trust store (#3767) | a private-CA issuer is trusted once its CA is in the store or under SSL_CERT_DIR |
| 8 | Book, querying with AQL, the URL form | how a parameter value binds | (b) | silent on a quoted value, which now binds the JSON string it denotes (#3781) | the quoted form and the malformed case stated |
| 9 | Book, templates and validation, the example composition | “a skeleton instance you can fill in”, with no limitation | (b) | the example passes the server’s own validation except for two open defects (#3779, #3780) and templates whose mandatory content only a slot admits | the exceptions named |
| 10 | Book, threat model, what each database credential can reach | the writer roles hold SELECT, INSERT, UPDATE and DELETE on their schema | (b) | the migration bookkeeping is now read-only to every runtime role, and the boot refuses a writable one | stated below the table |
| 11 | Book, information sheet, (e) | TERM “3.1.0” | (b) | the server applies amendment 3.1.4 (SPECPR-450), which adds properties and units the template upload and the quantity validation accept (#3717, #3727) | “3.1.0, with amendment 3.1.4 (SPECPR-450)” |
The technical documentation’s Annex III 2 named the public status document for
the deployment posture too; it names GET /management/status now.
| 12 | Repository README, the template-model and example bullets (two places) | every template “generates spec-valid example compositions that pass the server’s own validation” | (a) | #3779 and #3780 are open, and a template whose mandatory content only an archetype slot admits has no committable example | “a template […] generates” them, “except for the cases the book’s example page lists” |
| 13 | Repository README, the deployment posture and licence bullets | sandbox names every open gap “on GET /rest/status”; “GET /ferroehr/rest/status reports the grant in force” | (a) | both are on GET /management/status (#3671) | “on GET /management/status” |
| 14 | Book, EHDS readiness row 1.2 | its issue reference | (a) | the probe that issue asked for ran (#3692), without hostUsers: false or sslmode=verify-full | names #3788 for those two |
Read and kept on 2026-10-15
- FOSSA: no text in scope names FOSSA, a FOSSA badge or a FOSSA lane after its removal in this release.
- The patient scope and
patient_namespace: the[authz.abac]table and its patient-scope list state the EHR-only queries, the direct-route refusals and the namespace rule as they ship; the security page’s fail-closed box is unchanged by them. - The restricted
EHR_ACCESSdefault andrbac_off: the authentication configuration, the configuration page, the go-live checklist, the Kubernetes values table and the CRA risk assessment (d) state both. - Identifier protection: the privacy configuration, the threat model’s sealed-identifier section, the operations guide and the admin API page state the per-version rows, the sealed write response, the plaintext refusal on import and load, and the erasure of sealed rows.
- The audit chain: the audit trail page states both digest versions and the downgrade finding.
- AQL: querying with AQL and the AQL engine concept page state the row rules and say AQL 1.1.0 does not define them.
- TLS: the server configuration and the Kubernetes page state the reload interval, the EC key formats and the explicit-curve refusal.
- The information sheet’s BALP row (“the profile revision is not pinned”): kept. The delete agent types follow BALP 1.1.4, and the profile URLs the records carry are unversioned.
- The image labels and the Helm chart’s description: unchanged by this
release but for the chart description’s NetworkPolicy defaults (#3743),
which match
values.yaml.
Left open on 2026-10-15
Nothing.
The review of 2026-10-16
Read on the tree after commit e923076de, for FerroEHR 4.3.10. The facts of
this release checked in every text in scope: the usage report in format 2
(#3795); the usage report off in a CI run, whatever the switch says (#3794);
the dump and load archive on zip 9.0.4 (#3798).
Changed on 2026-10-16
| # | Text | Claim | Art. 28 | Finding | Now reads |
|---|---|---|---|---|---|
| 1 | Book, instructions for use, the usage report | “Every deployment sends a daily usage report to Cadasto B.V. by default” | (a) | the report also goes out at each start, and a server started in a CI run sends none (#3794) | both stated, with the two CI variables |
| 2 | Book, operations, the usage report | “It is on by default” | (a) | off in a CI run, whatever the switch says (#3794) | “on by default outside a CI run” |
Read and kept on 2026-10-16
- The usage report’s fields: the usage report page
lists the format 2 fields and the
statusobject as the server sends them, and says earlier releases send format 1. The field list in the CRA risk assessment’s outbound data flows, the shared responsibility summary, the records of processing note, the intended purpose and the threat model name the same information, which format 2 only regroups. - The CI switch: the usage report page
states the two variables, the boot line and the
--printnote as they ship, and the server configuration table states the two variables. - The dump and load archive: the
admin APIs page names no zip
library and its
compression_formatvalues are unchanged. - The README,
SECURITY.md, the crate READMEs, the image labels and the Helm chart’s description: unchanged by this release, and none names the usage report’s format or the zip library.
Left open on 2026-10-16
Nothing.
The review of 2026-10-19
Read on the tree after commit a15290689, for FerroEHR 4.3.11. The facts of
this release checked in every text in scope: the viewer drawn in FerroUI
0.1.6 (#3802, #3811, #3815, #3820); the viewer’s tab icon (#3808); the coming
split of the logging component with FerroSYS (#3662); the zip advisories
judged not affecting in the Rust-dependency VEX document (#3806).
Changed on 2026-10-19
Nothing. Every text in scope already states these facts.
Read and kept on 2026-10-19
- The viewer pages: the viewer names the footer’s manufacturer line and the System screen’s Manufacturer card (#3634), and dark mode describes the moon and sun button and the theme cookie as FerroUI draws them. No page names a component library the viewer no longer uses.
- The logging component: the EHDS readiness page and the technical documentation say FerroEHR ships the whole logging component today and that FerroSYS takes the central store, review, retention and retrieval in a later release; nothing calls that split shipped.
- The security notices: the release’s Security entry names the VEX document that records the three zip advisories as not affecting FerroEHR.
Left open on 2026-10-19
Nothing.
The review of 2026-10-19, for 5.0.0
Read on the tree after commit ec78e7f40, for FerroEHR 5.0.0. The facts of
this release checked in every text in scope: openehr-sdt builds a canonical
PARTY from a party Web Template and stamps the caller’s rm_version (#3810,
#3809); the openEHR terminology serves the interoperability group; archetype
identifiers follow the BASE 1.3.0 grammar under the development profile
(#3835, #3845); the nine openehr-* crates at 0.0.89; only the newest
release is supported (#3853); the viewer serves its hydration bundle on a
route of its own (#3849); the viewer draws with FerroUI 0.1.7 and chooses its
language from Accept-Language or [ui] locale.
Changed on 2026-10-19, for 5.0.0
- The Rust crates: the dependency examples named the
crates at
0.0.86, three versions behind the published line; they name0.0.89. Theopenehr-sdtrow names the PARTY build beside the composition formats. - The viewer: the
ui.localerow said a deployment adds a language as a catalog file. The catalogs are embedded when the viewer is built, so the row now says a new catalog reaches a deployment in a rebuilt image, never at run time.
Read and kept on 2026-10-19, for 5.0.0
- The support period:
SECURITY.md, the Rust crates page and the release notes say that only the newest release is supported and that the five years of security fixes are a commitment for the product, shipped in the newest release. No text in scope still promises fixes to an earlier release. - The crate READMEs: the
openehr-sdtREADME describes the composition formats and does not mention the PARTY build. An omission ascribes no function the crate lacks, and the README changes with the crate’s next content version. - The specification pins:
docs/VERSIONS.mdand the crate table name BASE 1.2.0 and 1.3.0, which is what the two generations implement.
Left open on 2026-10-19, for 5.0.0
Nothing.
The review of 2026-10-20, for 5.1.0
Read on the tree after commit 18097c6f1, for FerroEHR 5.1.0. The facts of this
release checked in every text in scope: authentication and the SMART on openEHR
scope check run on the shared ferrohealth-smart crate, only asymmetric
algorithms verify a token, the HMAC key is gone, and every route requires a
scope (#3872); the Keycloak overlay and the development stack serve their
issuer over TLS with a development CA, and the CDR accepts their tokens again
(#3873); AQL rooted at PERSON, ORGANISATION and ROLE, and a demographic
root joined to its EHR’s content (#3818, #3831); the audit FHIR feed and the
remote policy service authenticate with OAuth 2.0 client credentials (#3829);
the chart mounts a database CA for sslmode=verify-full (#3788); a rotated
mTLS client CA is re-read without a restart (#3769); the viewer reads every
word from its catalog with FerroUI 0.2.0 (#3860); the nine openehr-* crates
at 0.0.90.
Changed on 2026-10-20, for 5.1.0
- The repository
README.md: the authentication bullet named Basic and OAuth2/OIDC alone. Every route now refuses a caller whose scopes do not grant the operation, a limitation a deployment must know (Art. 28(b)), so the bullet says the SMART on openEHR scopes are checked on every route. - The Rust crates: the dependency examples named the crates
at
0.0.89; they name0.0.90.
Read and kept on 2026-10-20, for 5.1.0
- The Keycloak quickstart: Docker Compose, getting started and the README offer the overlay for bearer-token authentication. Between #3872 and #3873 the CDR did not accept the overlay’s tokens; on this tree it does, and the compose page names the development CA and the scopes the realm puts in every token.
- Authentication and authorization: security,
the authentication reference and
SMART App Launch describe the
[auth]keys, the RFC 9068 profile and the scope check as the code does. - Annex II 2.5: a caller refused for want of a scope is refused by the grant its own identity provider issued, which the deployment controls; the re-assessment below stands.
Left open on 2026-10-20, for 5.1.0
Nothing.
Annex II 2.5, re-assessed
EHDS Annex II 2.5: “The harmonised software components of an EHR system shall not include features that prohibit, restrict or place an undue burden on authorised access, personal electronic health data sharing or use of personal electronic health data for permitted purposes.” The text does not define “undue”.
The EHDS readiness page marks the row “Shipped” with two
pieces of evidence: the query surface over the whole stored record, and an
authorisation layer that refuses or permits without a commercial gate. The
question re-assessed here is whether the authorisation defaults change that,
now that production refuses the open EHR_ACCESS default unless it is
accepted by name.
- What “authorised” means here. The deploying organisation decides who is
authorised, and expresses it in RBAC roles, ABAC policy and per-EHR
EHR_ACCESSsettings. A refusal of a caller the organisation has not authorised is not a burden on authorised access. - The
restricteddefault. Underehr_access_default = "restricted", an EHR with no settings is reachable only by the admin role, so a professional the organisation means to authorise is refused until someone writes that EHR’s settings. That is a burden, and it is the organisation’s choice: theopendefault remains available,productionaccepts it by name, and the restrictive one exists because GDPR Art. 25(2) asks that personal data are not by default “made accessible without the individual’s intervention to an indefinite number of natural persons”. The settings take role entries (role:nurse), so one entry covers a whole role, and the admin role can always reach the record to write them (per-EHR access control). - Restriction of processing. A record under restriction is refused on every read path. The controller sets the mark under GDPR Art. 18; it is a restriction the organisation decides on, not one the software imposes (retention, restriction and objection).
- Small-cell suppression. A cohort query that would serve fewer distinct
EHRs than
cohort.small_cell_threshold(default 5) withholds its rows and says so. It applies only to population queries across the pseudonymisation boundary, and the deployment sets the threshold. - Rate limits and the admission limit. On by default, set above the server’s own measured whole-server ceiling, so they refuse abuse and leave normal load alone (request limits).
- The licence. The licence in force changes only the stamp on new
identifiers; it gates no feature, endpoint, limit or message
(
app/ferroehr/src/licence/mod.rs).
Outcome. No feature found prohibits or restricts access, sharing or use that the deploying organisation has authorised, and the burdens found are settings the organisation chooses and can change. The “Shipped” status stands on this reasoning, which the readiness page will cite.
Review record
One row per release. The release procedure adds the row when the release is cut, after the texts in scope have been read.
| Reviewed | Tree | Changed | Left open |
|---|---|---|---|
| 2026-10-06 | before 4.3.5, after d6539d470 | 15 claims, in the README, the landing page and three book pages | the chart description (#3637) |
| 2026-10-08 | 4.3.6, after de648fcc3 | 5 claims, in the landing page and five book pages; the chart description item closed | nothing |
| 2026-10-15 | 4.3.9, after 606a29cce | 14 claims, in 17 book pages, the README and the readiness row | nothing |
| 2026-10-16 | 4.3.10, after e923076de | 2 claims, in two book pages | nothing |
| 2026-10-19 | 4.3.11, after a15290689 | none | nothing |
| 2026-10-19 | 5.0.0, after ec78e7f40 | 2 claims, in two book pages | nothing |
| 2026-10-20 | 5.1.0, after 18097c6f1 | 2 claims, in the README and one book page | nothing |
| 2026-10-20 | 5.2.0, after e5aa1902b | 1 claim, in one book page | nothing |
| 2026-10-22 | 5.3.2, after 434cd351e | 4 claims, in four book pages and the technical documentation | nothing |
| 2026-10-23 | 5.3.3, after 25845e6d3 | 1 claim, in one book page: the templates page said ` | raw` is write-only, corrected for #3934 |
| 2026-10-24 | 5.4.0, after de38ec433 | none | nothing |
| 2026-10-24 | 5.4.1, after 9fdd36091 | 2 claims, in two book pages: the compliance index named [terminology.external] and a terminology route, and the crates page pinned 0.0.97 | nothing |
| 2026-10-25 | 5.4.2, after 2333f98ba | none; the changes of this release updated their own pages in the pull requests that made them | nothing |
| 2026-10-27 | 5.5.0, after bed3543e3 | 1 claim, in the hazard log: H2 named a middleware test renamed by #4192 | the conformance chapter bars still count the CNF TDD cases, which only a new measured run removes (#4204) |
The review of 2026-10-20, for 5.2.0
Read on the tree after commit e5aa1902b, for FerroEHR 5.2.0. The facts of this
release checked in every text in scope: authentication runs on
ferrohealth-smart 0.3.1, with [auth.basic] cache_ttl_seconds in place of
[authz] basic_cache_ttl_seconds, scopeless_subjects accepted and a
malformed bearer token answered 401 (#3875); [smart] issuer_metadata_file
(#3875); the EHDS Art. 8 restriction of access with its emergency override
(#3682); the audited read of a party’s protected identifiers (#3776); an EHR
extract never carries a sealed identifier (#3777); the FLAT history origin from
the earliest event (#3888); the nine openehr-* crates at 0.0.91.
Changed on 2026-10-20, for 5.2.0
- The Rust crates: the dependency examples named the crates
at
0.0.90; they name0.0.91.
Read and kept on 2026-10-20, for 5.2.0
- The Art. 8 statements: security, the audit trail, the compliance overview, the instructions for use, EHDS readiness rows 1.1 and 1.3 and the hazard log describe the restriction as the code does, and none of them calls it planned any more.
- The authentication reference: the configuration reference,
security and using the API
name the moved cache key and the
401for a bearer value outsideb64token.
Left open on 2026-10-20, for 5.2.0
Nothing.
The review of 2026-10-22, for 5.3.2
Read on the tree after commit 434cd351e, for FerroEHR 5.3.2. The facts of this
release checked in every text in scope: the access log written through
FerroAUDIT’s ehds-logging and ihe-atna 0.2.4, with the categories named by
their HL7 Europe codes, category_basis, and the record’s jti, operation
subtype and outcomeDesc written again (#3887, #3902); the retention register
keyed on the category token (clinical migration 0016); the syslog feed over
RFC 5425 TLS only, with the UDP transport removed; ferrohealth-smart 0.4.0
and one login record per Basic credential verification (#3895); a + in a
query string read as a space (#3891); the subject id of a lookup by subject
kept out of every log, span and error body (#3906); Rust 1.99.1 with the MSRV
at 1.98; the nine openehr-* crates at 0.0.95.
Changed on 2026-10-22, for 5.3.2
- The Rust crates: the dependency examples named the crates
at
0.0.91; they name0.0.95. - EHDS readiness, the European logging component row
(from
website/book/ehds.yaml): it namedehds-loggingandihe-atnaat 0.1.1; it names 0.2.4, the version the rootCargo.tomlpins. - The hazard log, the component paragraph: as the row above.
- The technical documentation’s scope (
docs/technical-documentation/README.md): as the row above.
Read and kept on 2026-10-22, for 5.3.2
- The audit statements: the README, security, the audit trail, audit configuration, the go-live checklist, the information sheet and the instructions for use describe the syslog feed as TLS only and the categories by their HL7 Europe codes, as the code does.
- The toolchain: the README and building from source name the Rust 1.99 toolchain, and the Rust crates the MSRV 1.98.
- The landing page,
SECURITY.md, the crate READMEs, the image labels and the chart description make no claim this release changes.
Left open on 2026-10-22, for 5.3.2
Nothing.
The review of 2026-10-24, for 5.4.0
Read on the tree after commit de38ec433, for FerroEHR 5.4.0. The facts of this
release checked in every text in scope: the viewer rebuilt on FerroUI 0.3.6 in
islands mode, working with JavaScript off, with its new addresses, its
preferences as cookies of their own and an open tab that idles out (#3932,
#3057); an ECDSA or Ed448 OpenPGP signing key refused at boot for
RUSTSEC-2026-0387 (#3965); the signer’s choice of subkey and primary key
(#3967, #3969, #3971); the audit drain closing its syslog connection with
close_notify and FerroAUDIT 0.2.5 (#3959); the nine openehr-* crates
unchanged at 0.0.97.
Changed on 2026-10-24, for 5.4.0
Nothing: the pull requests that made these changes updated the texts they touched.
Read and kept on 2026-10-24, for 5.4.0
- The viewer statements: the README, the viewer
pages and the image label of
docker/viewer/Dockerfiledescribe the viewer the code draws; no text names a removed address, theferroehr-themecookie, the Tailwind build or a background poll. - The signing statements: PGP signing and the authentication and signing configuration state the refused ECDSA and Ed448 keys, the subkey the server signs with and the retired-key path, as the code does.
- The audit statements: the hazard log and
EHDS readiness name
ehds-loggingandihe-atna0.2.5, the version the rootCargo.tomlpins. - The landing page,
SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the other image labels and the chart description make no claim this release changes.
Left open on 2026-10-24, for 5.4.0
Nothing.
The review of 2026-10-24, for 5.4.1
Read on the tree after commit 9fdd36091, for FerroEHR 5.4.1. The facts of this
release checked in every text in scope: the CDR asks one terminology server
under [terminology.server] through ferrobridge-term, and chart 14.0.0 wires
it (#3085); an unserved method is 405 with Allow under SMART (#3987);
the conformance statement declares unmatched-member-refused (#3988); a ROLE
template whose single attributes state 0..* uploads, and a demographic
template’s example is a party (#3994, #3995); the viewer offers demographic
templates only where they work (#3997); the nine openehr-* crates at 0.0.98.
Changed on 2026-10-24, for 5.4.1
- The compliance index (SGB V § 355 row) named
[terminology.external]and “the terminology route”; it now names[terminology.server]and itsurl. - The crates page pinned the
openehr-*crates at0.0.97; this release publishes0.0.98.
Read and kept on 2026-10-24, for 5.4.1
- The terminology statements: the
integrations configuration, the
Kubernetes installation and the chart README
describe the one
[terminology.server]andsecrets.terminologyClientSecret, as the code and the chart do. - The template statements: the templates page and content negotiation state the example party of a demographic template and its JSON and XML forms.
- The
405statement: content negotiation’s “405always names the allowed methods” holds under SMART as well. - The landing page,
SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.
Left open on 2026-10-24, for 5.4.1
Nothing.
The review of 2026-10-25, for 5.4.2
Read on the tree after commit 2333f98ba, for FerroEHR 5.4.2. The facts of this
release checked in every text in scope: a valid Certification Revocation of a
Direct Key signature cancels it, so it no longer lets the primary key sign
(#3972); an access record whose patient lookup failed writes unavailable
(#3900); the viewer’s Paths tab offers a query only on a COMPOSITION template,
on FerroUI 0.3.7 (#4001); ferrohealth-smart 0.5.0, one openehr-sdt in the
build (#4002); ihe-atna and ehds-logging 0.2.7 (#3954); zip 8.6.0 out of
the build (#3799); chart 14.0.1; the nine openehr-* crates at 0.0.98.
Changed on 2026-10-25, for 5.4.2
Nothing at the cut: each change above updated its page in its own pull request.
Read and kept on 2026-10-25, for 5.4.2
- The signing statement: the PGP signing page states that a revoked Direct Key signature is not chosen, as the code does.
- The viewer statement: Templates & EHRs says a node offers Build a query on this path only on a COMPOSITION template.
- The logging component: the EHDS readiness page,
rendered from the FerroHEALTH book’s
website/book/ehds.yaml, and the hazard log nameehds-loggingandihe-atna0.2.7. - The installation pins: the Kubernetes installation, the chart README and verifying releases name chart 14.0.1 and image 5.4.2.
- The landing page,
SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.
Left open on 2026-10-25, for 5.4.2
Nothing.
The review of 2026-10-27, for 5.5.0
Read on the tree after commit bed3543e3, for FerroEHR 5.5.0. The facts of
this release checked in every text in scope: the server and the viewer run on
the family’s ferrohealth-server, ferrohealth-config, ferrohealth-telemetry,
ferrohealth-smart, ferrohealth-authz and ferrohealth-secret crates, with
the renamed and retired configuration keys under Changed and Removed (#4087,
#4038, #4028, #4139, #4214); the EHR index asks an IHE patient identity source
over PIXm by default (#3380); opaque access tokens are checked by introspection
(#4033); [auth] ca_file trusts a private issuer CA (#3909); the audit trail is
delivered and retrieved through ihe-atna and carries the data permit (#4029,
#4030, #4120); Template Data Document import is removed (#4199); the quickstart
Keycloak overlay trusts its CA through ca_file and signs the viewer in
(#4236, #4243); the chart’s usageReport.enabled=false reaches FerroTERM, FerroBRIDGE and FerroPIX (#4270); chart 20.0.0 with appVersion 5.5.0.
Changed on 2026-10-27, for 5.5.0
- The hazard log: H2 named
ehr_id_from_various_paths, which #4192 replaced withehr_id_from_the_matched_route; the log names the new test.
Read and kept on 2026-10-27, for 5.5.0
- The configuration pages: the server configuration,
authentication, security and
the viewer name the new keys and say which old keys
refuse the start (
slow_aql_ms,user_role,LEPTOS_SITE_ADDR); no page still offerstoken_url,server.bind,ferroehr config default,healthcheck --urlor the TDD routes. - The CRA risk assessment: its outbound data flows table gains the token introspection flow and the patient identity source over PIXm, with revision 14.
- The installation pins: the Kubernetes installation,
the chart README and verifying releases name chart
20.0.0 and image 5.5.0, and the compose page
names
FERROEHR_CONFIGand the Keycloak overlay’sca_file. - The landing page,
SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.
Left open on 2026-10-27, for 5.5.0
- The conformance chapter bars still show the CNF TDD cases from the last measured run; the record drops them only when the campaign runs again (#4204).
The review of 2026-10-28, for 5.6.0-rc1
Read on the tree after commit df14b4311, for the FerroEHR release candidate
5.6.0-rc1. The facts of this release checked in every text in scope: the REST
surface serves three namespaces, ITS-REST alone under /openehr/v1/, FHIR and
IHE under /fhir/r4/ and FerroEHR’s extensions under /extensions/v1/, with
no redirect from the old paths (#4330); the audit store renders each record’s
AuditEvent from its columns, answers every ITI-81 parameter through search
tokens, and its migration refuses a store holding records of an earlier
release (#4300, #4302, #4313); ferrohealth-server 0.3.7 bounds a silent
connection by [server] header_read_timeout_ms (#4283, GHSA-2mc2-h3jq-c5wg);
an AQL query nested past its depth bound answers 400 (#4284); a cut or
unreachable database answers 503 with Retry-After within about a second
(#4290, #4323); an abandoned query frees its connection (#4285); storage
generation 3 (#4306); a deployment that declares no category map records
accesses through the map FerroEHR ships (#4298); chart 20.0.1-rc1 with
appVersion 5.6.0-rc1.
Changed on 2026-10-28, for 5.6.0-rc1
- The hazard log: H7 named
the_current_digest_covers_every_columnandrecords_under_the_original_digest_still_verifyand kept a remainder for records under the version 1 digest; #4300 retired the versioned digests with the records they covered, so H7 namesthe_digest_covers_every_columnand the render-policy and search-token tests, and drops that remainder. H3 said FerroEHR ships no category map; it names the shipped map and its test (#4298). - The technical documentation named the FerroAUDIT pins as 0.2.5; it names
0.3.1, the version the root
Cargo.tomlpins.
Read and kept on 2026-10-28, for 5.6.0-rc1
- The API pages: the API overview names the
three namespaces, the resources page the
paged CONTRIBUTION list under
/extensions/v1/, and the audit trail the ITI-81 route under/fhir/r4/and the empty audit store 5.6.0 starts on; no page in scope names a moved route under/openehr/v1/. - The operations pages: AQL states the depth bounds,
operations the
503on a cut or unreachable database, and the server configuration and securityheader_read_timeout_ms. - The installation pins: the Kubernetes installation,
the chart README and verifying releases name chart
20.0.1-rc1 and image 5.6.0-rc1 in the book of main; the frozen book of 5.5.0
under
/docs/latest/keeps the 5.5.0 pins, since a pre-release freezes no book. - The landing page,
SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.
Left open on 2026-10-28, for 5.6.0-rc1
- The public sandbox runs 5.5.0 and is not redeployed for a pre-release, so it serves the old paths until 5.6.0 ships.
The review of 2026-10-29, for 5.6.0-rc2
Read on the tree after commit 40982f374, for the FerroEHR release candidate
5.6.0-rc2; 5.6.0-rc1 was tagged at 61d5d1ff7 and not published. The review of
2026-10-28 stands for everything 5.6.0-rc1 carried. The facts added since and
checked in every text in scope: the [origins] register stamps a direct commit
from a registered client with that client’s origin, a retention period may be
keyed on a registered origin, and the ABAC request carries category and
origin attributes (#4299); a client CA under client_auth = "none" is
refused at load (#4349, GHSA-52mf-qg39-4q4j); the chart refuses to render
without an explicit deployment profile (#4294); the logging component’s
intended performance is stated and measured (#4293) and its ATNA transactions
are exercised against independent implementations (#4296); an unclassified
record’s FHIR rendering carries the BALP entity:data slice (#4353); chart
21.0.0-rc2 with appVersion 5.6.0-rc2.
Changed on 2026-10-29, for 5.6.0-rc2
- The hazard log: H4 said a body without
FEEDER_AUDITrecords this server as its origin; it names the registered client’s origin and its test (#4299). H3’s remainder said the chart’s empty map matches the binary, which ships a map since #4298; it says the shipped map applies.
Read and kept on 2026-10-29, for 5.6.0-rc2
- The retention and access pages: retention, the
audit configuration and
security describe the
[origins]register, the origin-keyed retention policy and the two ABAC attributes as the code implements them. - The installation pins: the Kubernetes installation,
the chart README and verifying releases name chart
21.0.0-rc2 and image 5.6.0-rc2 in the book of main, and the Kubernetes page
sets
config.deployment_profilein its install example (#4294). - The TLS and performance pages: the server configuration
says
client_ca_fileis refused underclient_auth = "none", and logging performance states the claimsscripts/perf/audit.shmeasures. - The landing page,
SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.
Left open on 2026-10-29, for 5.6.0-rc2
- The public sandbox runs 5.5.0 and is not redeployed for a pre-release, so it serves the old paths until 5.6.0 ships.
The review of 2026-10-29, for 5.6.0-rc3
Read on the tree after commit df26a92e6, for the FerroEHR release candidate
5.6.0-rc3. The review of 2026-10-29 for 5.6.0-rc2 stands for everything that
release candidate carried. The facts added since and checked in every text in
scope: every error on the openEHR, extension and management routes is RFC 9457
problem details, the violations of a validation refusal reach a client as
errors under Prefer: return=representation, and an authentication refusal
under the FHIR base is an OperationOutcome (#4342); the openehr-its client
reads a problem details refusal (#4355); a + in a query string is a literal
plus (#4356); FROM PERSON p CONTAINS EHR e joins through the EHR’s
EHR_STATUS.subject.external_ref (#4411); a projection ending on a list
attribute answers the list once (#4413); the local EHR index is a supported
posture (#4379); [server] idle_timeout_ms closes an idle kept-alive
connection on ferrohealth-server 0.4.0 (#4417); the FHIR AuditEvent names
the object it touched by entity.what.identifier on FerroAUDIT 0.3.2 (#4366);
openehr-query is Apache-2.0 from 0.0.116 (#4405); the chart and the sandbox
run FerroPIX 0.0.12 (#4392); chart 21.0.0-rc3 with appVersion 5.6.0-rc3.
Changed on 2026-10-29, for 5.6.0-rc3
- The validation pages: template validation
and the privacy configuration said a
422lists its violations invalidationErrors, the body #4342 replaced; they name the problem’serrors, sent underPrefer: return=representation, and link thevalidation-failedtype. - The hazard log: H2 states that the FHIR
AuditEventnames the object it touched byentity.what.identifierand names its test (#4366).
Read and kept on 2026-10-29, for 5.6.0-rc3
- The API pages: Problem types and
content negotiation and errors
describe the problem body, its types and the
Prefermembers as the code writes them; querying with AQL describes the party join through the subject reference and the list projection as the engine answers them. - The server configuration: the server configuration
states
idle_timeout_msand the head bound asferrohealth-server0.4.0 applies them. - The licensing page and the crate READMEs name
openehr-queryApache-2.0 from 0.0.116 and BUSL-1.1 for 0.0.60 to 0.0.115. - The installation pins: the Kubernetes installation, the chart README and verifying releases name chart 21.0.0-rc3 and image 5.6.0-rc3 in the book of main.
- The landing page,
SECURITY.md, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.
Left open on 2026-10-29, for 5.6.0-rc3
- The public sandbox runs 5.5.0 and is not redeployed for a pre-release, so it serves the old paths and the old error body until 5.6.0 ships.