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.
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
- Compliance 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, the FHIR R4 connectors, 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, measured performance and the comparison with another CDR (in both directions) 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. It starts with a
conversation with the maintainer named in
MAINTAINERS.md,
and that conversation is usually short. 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, and we measure ourselves against EHRbase with the same instrument, publishing both directions of the result. 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 server.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 subject id
(
sandbox-patient-01…-08in theferroehr-sandboxnamespace, soGET /ehr?subject_id=sandbox-patient-01&subject_namespace=ferroehr-sandboxfinds one). 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
definition/archetype/adl2anddefinition/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, the FHIR connector, 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 /ferroehr/rest/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 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/rubentalstra/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/rubentalstra/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 extensions (uuid-ossp, pgcrypto, pg_trgm, btree_gist), so the app role never needs superuser. |
ghcr.io/rubentalstra/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 extensions, and bakes in no migration state. 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 two core services (no profile needed):
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; server.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 default
sandboxdeployment profile: the server names every separation it has not made on the boot banner, in the log and onGET /ferroehr/rest/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 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 on port 8081 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 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 |
One confidential client is defined (ferroehr /
ferroehr-quickstart-secret, with the password grant enabled so a token can be
fetched by curl). Fetch a token and call the API with it:
TOKEN=$(curl -s -d client_id=ferroehr -d client_secret=ferroehr-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
Warning
The realm, the client secret and the user passwords are demo values, served over plain HTTP by a Keycloak in
start-devmode, which is also why the overlay sets the server’sallow_insecure_issueropt-in. For a real deployment, drop this overlay and point[auth.oidc](orFERROEHR__AUTH__OIDC__*) 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
external terminology on, so archetype value-set bindings resolve at commit and
AQL TERMINOLOGY() expands through it (the /terminology/* extension routes
answer from it too once [terminology] api_enabled = true, which is off by
default):
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__EXTERNAL__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_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/rubentalstra/ferroehr:<release> | Server image to run. |
FERROEHR_POSTGRES_IMAGE | ghcr.io/rubentalstra/ferroehr-postgres:<release> | Database image to run. |
FERROEHR_VIEWER_IMAGE | ghcr.io/rubentalstra/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_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__LOG__FORMAT | pretty | Log rendering for docker compose logs. Set json for log collectors. |
FERROEHR__LOG__FILTER | info | Log level filter. |
FERROEHR__DB__MAX_CONNECTIONS | 10 | Server connection-pool ceiling. |
FERROEHR__SERVER__MAX_IN_FLIGHT | 256 | In-flight request admission cap (503 past it; 0 disables). |
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 /ferroehr/rest/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 where the server auto-discovers it.
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 plus
FERROEHR__TELEMETRY__METRICS_PUSH=true), switches stdout to JSON lines
(FERROEHR__LOG__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.
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
- Optional integrations
- 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/rubentalstra/charts/ferroehr \
--version 10.0.2 -n ferroehr \
--set database.existingSecret=ferroehr-db \
--set image.tag=4.3.0
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/rubentalstra/charts/ferroehr --version 10.0.2
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 10.0.2 | SemVer over the chart’s own contract |
| Image tag | the server binary | --set image.tag=4.3.0 (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/rubentalstra/charts/ferroehr:<chart-version> \
--certificate-identity-regexp '^https://github\.com/rubentalstra/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/rubentalstra/charts/ferroehr:<chart-version> -R rubentalstra/FerroEHR
# the image it deploys
gh attestation verify oci://ghcr.io/rubentalstra/ferroehr:<tag> -R rubentalstra/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/rubentalstra/charts/ferroehr --version 10.0.2 \ -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__DB__URL=postgres://u:p@db:5432/ferroehr \ --entrypoint /usr/local/bin/ferroehr ghcr.io/rubentalstra/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.oidc issuer or
a config.auth.basic.users entry before expecting a request to succeed.
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.bind,
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: auth.oidc.hmac_secret, signing.key_passphrase,
multimedia.secret_access_key, a Basic user’s password_hash, a terminology
client_secret, and the four URL-shaped ones (db.url, events.url,
fhir.outbound.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.auth.oidc.hmac_secret: set `secrets.authOidcHmacSecret` 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.
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.
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 /ferroehr/rest/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:: authOidcHmacSecret, signingKeyPassphrase, eventsUrl,
fhirOutboundUrl, auditFhirFeedUrl, basicUserPasswordHashes,
multimediaAccessKeyId, multimediaSecretAccessKey,
terminologyOauth2ClientSecrets, 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.authOidcHmacSecret | mounted at /etc/ferroehr-secrets/auth.oidc.hmac_secret; only the path is env |
secrets.signingKeyPassphrase | mounted at /etc/ferroehr-secrets/signing.key_passphrase |
secrets.multimediaSecretAccessKey | mounted at /etc/ferroehr-secrets/multimedia.secret_access_key |
secrets.basicUserPasswordHashes (per username) | mounted at /etc/ferroehr-secrets/auth.basic.users.<username>.password_hash; the chart injects the matching password_hash_file |
secrets.terminologyOauth2ClientSecrets (per client) | mounted at /etc/ferroehr-secrets/terminology.external.oauth2_clients.<name>.client_secret; the chart injects the matching client_secret_file |
secrets.eventsUrl, secrets.fhirOutboundUrl | mounted at /etc/ferroehr-secrets/events.url and …/fhir.outbound.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 username, and declare only theusernameandrolesunderconfig.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 (TLS with sslmode=verify-full, pgaudit, at-rest
encryption, WAL archiving / PITR) belong to whoever provisions PostgreSQL; the
chart references them but cannot enforce them. See
Operations.
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/DEGRADED) or 503 (DOWN): 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 defaults to 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.ferroehr healthcheck --url …is still useful by hand; it is not what a readiness probe should run.
Warning
Migrations run only at boot, so a replaced or wiped database leaves the pods NotReady until something migrates it. Readiness reports
"migrations": {"status":"DOWN","detail":"core schema tables missing (migrations not applied)"}while"db"readsUP: 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:
oidc:
issuer: https://keycloak.example.org/realms/ferroehr
audiences: [ferroehr]
or Basic auth, whose password hashes go in secrets.basicUserPasswordHashes
while the username and 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.
Optional 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/rubentalstra/charts/ferroehr \
--version 10.0.2 -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. |
| OAuth2/OIDC auth | config.auth.oidc.issuer | unset | Prefer JWKS/discovery over the HS256 secrets.authOidcHmacSecret. |
| 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. |
| FHIR inbound/façade | config.fhir.api_enabled | off | Read façade + inbound mapping. |
| FHIR outbound → AMQP | config.fhir.outbound.enabled | off | ⚠ Carries PHI (the mapped FHIR resource). Separate exchange; TLS broker only; URL via secrets.fhirOutboundUrl. |
| S3 multimedia | config.multimedia.enabled | off | ⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket; keys via secrets.multimediaAccessKeyId and secrets.multimediaSecretAccessKey. |
| External terminology | config.terminology.external.enabled | off | FHIR terminology server; the provider map is more config.terminology.external.providers keys. To run one 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; unset means the OpenTelemetry layer is not installed at all (zero overhead). 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.
FerroEHR Viewer (a second workload, off by default)
viewer.enabled renders a second Deployment and Service for the Leptos 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.
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/rubentalstra/charts/ferroehr \
--version 10.0.2 -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.external.enabled,
config.terminology.external.fail_on_error from terminology.failOnError, and
the default provider 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. Four shapes are refused
while terminology.wireCdr is on:
config.terminology.external.providers.default— two answers to “which server resolves a binding” is how resolution ends up pointed somewhere nobody chose.config.terminology.external.enabled: false— the renderedferroehr.tomlwould say the opposite of your values file.config.terminology.external.fail_on_errordisagreeing withterminology.failOnError— same reason, for the posture that decides whether an unresolvable binding is a422.- a provider of your own with no
config.terminology.external.routesentry naming it — an unmatched terminology falls back to the provider calleddefault, which the wiring has just taken, so an unrouted provider is configuration that renders, boots and is never called.
Set terminology.wireCdr=false to keep your own provider and run FerroTERM
unwired. A second provider under any other name is fine at any time, with a
routes entry pointing at it:
config:
terminology:
external:
providers:
tx:
type: fhir
url: https://tx.example.com/fhir
routes:
"http://loinc.org": tx
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.
Per-domain backups (three CronJobs, 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.
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 and
backup.linkage.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 three 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
Four 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.
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/rubentalstra/charts/ferroehr --version 10.0.2 \
-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 HMAC secret under the 32-byte floor, 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/rubentalstra/ferroehr:4.3.0 \
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 OIDC HMAC secret, 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 development mechanism here and config.auth.oidc is the
production one.
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/rubentalstra/ferroehr:main \
-R rubentalstra/FerroEHR --format json \
| jq '.[0].verificationResult.signature.certificate
| {subjectAlternativeName, issuer, sourceRepositoryRef, runnerEnvironment}'
{
"subjectAlternativeName": "https://github.com/rubentalstra/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) |
| 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/rubentalstra/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/rubentalstra/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/rubentalstra/ferroehr"
- "ghcr.io/rubentalstra/ferroehr:*"
- "ghcr.io/rubentalstra/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/rubentalstra/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/rubentalstra/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/rubentalstra/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/rubentalstra/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/rubentalstra/ferroehr:main \
-R rubentalstra/FerroEHR \
--signer-workflow rubentalstra/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/rubentalstra/ferroehr:<tag> -R rubentalstra/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/rubentalstra/charts/ferroehr:<version> -R rubentalstra/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.limits.body_bytes(413 on an over-large body),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.connection.header_read_timeout_secsand its HTTP/2 siblings bound a socket before a request exists;config.server.rate_limitrefuses429;config.server.max_in_flightsheds503when 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 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 |
| OIDC issuer (discovery + JWKS) | config.auth.oidc.issuer, unless config.auth.oidc.jwks_json or config.auth.oidc.jwks_json_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(s) | config.terminology.external.enabled plus a provider 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.external.oauth2_clients.<name>.token_url | 443 | off-cluster |
| AMQP broker (events) | config.events.enabled plus secrets.eventsUrl | 5672, or 5671 with config.events.tls | in-cluster |
| AMQP broker (FHIR outbound) | config.fhir.outbound.enabled plus secrets.fhirOutboundUrl | 5672 / 5671 | 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 | 514 UDP, or 6514 TCP with config.audit.syslog.transport: tls | off-cluster |
| FHIR audit repository | config.audit.fhir_feed.enabled plus secrets.auditFhirFeedUrl | 443 | off-cluster |
| Subject-proxy source system | a config.subject_proxy.systems entry’s base_url | 443 | off-cluster |
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 OIDC HMAC secret | secrets.authOidcHmacSecret (HS256 development setups) | the ability to mint valid tokens for any user and role |
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.terminologyOauth2ClientSecrets | access to that terminology server as this client |
| AMQP broker URLs | secrets.eventsUrl, secrets.fhirOutboundUrl | the FHIR outbound stream carries PHI; the events stream 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.auth.oidc.hmac_secret_file,config.signing.key_passphrase_file,config.multimedia.secret_access_key_file, a terminology client’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 |
| OIDC HMAC secret | update, then restart | invalidates every token signed with the old secret; prefer JWKS or discovery, where the issuer rotates for you and no secret lives here |
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.log.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.98.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, overriding the search order (FERROEHR_CONFIG,./ferroehr.toml,/etc/ferroehr/ferroehr.toml);--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 config defaultwrites the annotated default configuration template to stdout, which is the reference every key in the configuration reference is drawn from;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) probes the status endpoint and exits 0 or 1. It defaults tohttp://127.0.0.1:8080/ferroehr/rest/status; override with--urlorFERROEHR_HEALTHCHECK_URL.
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 connector, outbound emitter, FHIR terminology providers, and
the FHIR AuditEvent audit sinks), 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,
fhir.outbound.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
Generate an annotated template, edit it, and run:
# Write a fully-commented ferroehr.toml with every key at its default.
ferroehr config default > ferroehr.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→log.filter.PORT→ a0.0.0.0:<PORT>value forserver.bind(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.oidc] issuer = "…" | FERROEHR__AUTH__OIDC__ISSUER=… |
[management.endpoints] env = "off" | FERROEHR__MANAGEMENT__ENDPOINTS__ENV=off |
[terminology.external.providers.default] url = "…" | FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__DEFAULT__URL=… |
Scalars are typed automatically (bool / int / float, else string).
List-typed keys take comma-separated values
(FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr,other). Map-keyed tables are
reachable too: the map key is just another segment
(FERROEHR__SUBJECT_PROXY__SYSTEMS__PAS__BASE_URL). 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,ferroehr config check, logs), and each has a*_filesibling that reads the value from a file, for Kubernetes and Docker secret mounts. Setting a secret and its*_filesibling at once is a boot error.
File discovery
The first of these that exists is loaded (later layers still override its values):
--config <path>,FERROEHR_CONFIG=<path>,./ferroehr.toml(current directory),/etc/ferroehr/ferroehr.toml.
An explicitly pointed-at file (1–2) is fatal if absent; the search-order files (3–4) are simply skipped when absent, but fatal if present and unreadable or unparseable.
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, with the offending line number
and a did-you-mean suggestion, and in the
FERROEHR_environment namespace. A variable in that namespace that is neither a known section nor one of the reserved non-configuration names is a boot error, so a misspelled security key can never be silently ignored. A single-underscore near miss (FERROEHR_DB_URL) is reported with the exact uniform spelling it should have had. - 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 (default) | 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 /ferroehr/rest/status under deployment |
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 three pools reach three different PostgreSQL clusters, read from pg_control_system().system_identifier on each pool, never from the DSN text |
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) |
migrate_on_runtime_credential | [db] migrate_url set, or migrate = "verify", so the credential that serves requests cannot alter the schema |
An accepted gap is stated on every boot and on /rest/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.limits], [server.rate_limit], [server.connection], [server.tls], [server.identity] | The HTTP listener, request limits, rate limiting, connection bounds, TLS, the deployment’s own identity | Server, database & telemetry |
[db] | PostgreSQL connection, pool, migrations | Server, database & telemetry |
[log], [telemetry] | Log rendering and OpenTelemetry export | 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 |
[events], [fhir] | Change eventing and the FHIR connector | Integrations |
[terminology], [multimedia] | External terminology servers, multimedia externalization | Integrations |
[audit], [audit.store], [audit.syslog], [audit.fhir_feed] | The IHE ATNA audit trail and its sinks | Audit & subject proxy |
[subject_proxy] | The FHIR systems subject-proxy frames may read | Audit & subject proxy |
[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 and REST surface ([server] and its sub-tables), the PostgreSQL
connection ([db]), and the two observability sections ([log],
[telemetry]). Precedence, the environment-name grammar, and file discovery
are on the Configuration reference index.
[server]base_path: shortening the REST base path- Behind a path-prefixed reverse proxy
[server.limits]: request-body sizes[server.rate_limit]: per-caller request rates[server.connection]: bounds before a request existssystem_id: the data-authoring identity[server.tls]: native TLS and mutual-TLS client authentication[server.identity]
[db][storage][log][telemetry]
[server]
The HTTP listener and REST surface.
[server]
bind = "0.0.0.0:8080"
base_path = "/ferroehr/rest/openehr/v1"
max_in_flight = 256
swagger_ui = "private"
cors_permissive = false
system_id = "ferroehr.local"
| Key | Type | Default | Description |
|---|---|---|---|
bind | string | 0.0.0.0:8080 | Socket address the API listener binds. |
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. |
max_in_flight | int | 256 | Concurrent-request admission cap (not a rate). Requests beyond it are shed immediately with 503 + Retry-After, never queued, so offered load beyond capacity cannot exhaust memory. 0 installs no shedding layer at all. |
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__SERVER__SYSTEM_ID). |
The shed sits on the clinical API subtree only, as its outermost layer: a shed request never reaches authentication, auditing, or the request body, and the always-on status, health, discovery and management routes are never shed.
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
stays at the process root: /health, /health/liveness, /health/readiness.
Set it from the environment with:
FERROEHR__SERVER__BASE_PATH=/ferroehr/v1
Note
The
ferroehr healthchecksubcommand, which the published container images run as their Docker health check, derives its default URL from the effective configuration (http://127.0.0.1:<port><REST root>/status), so it follows a shortened base path on its own. An explicitFERROEHR_HEALTHCHECK_URLstill wins, for example to probe/health/readiness, which no base path affects.
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__SERVER__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
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.
[server.limits]: request-body sizes
[server.limits]
body_bytes = 16777216 # 16 MiB
bulk_body_bytes = 67108864 # 64 MiB
| Key | Type | Default | Description |
|---|---|---|---|
body_bytes | int | 16777216 | The largest request body the ordinary clinical surface accepts, in bytes. |
bulk_body_bytes | int | 67108864 | The largest body the bulk routes accept: operational-template upload, /message/import, /message/tdd. |
A request over its tier’s limit is refused 413 Payload Too Large with the
standard openEHR error body. The status is not in the ITS-REST status table; it
is admitted there as an additional, non-conflicting code, and is what
RFC 9110 §15.5.14 defines for this refusal.
The defaults are sized against the measured payloads in the vendored clinical
corpus rather than chosen as round numbers: the clinical tier clears the
largest operational template in that corpus several times over, and the bulk
tier is four times the clinical one, for payloads with no published bound (a
whole-EHR extract, a TDD batch). Raise body_bytes if your compositions
embed large DV_MULTIMEDIA data: a base64 radiology image can exceed either
tier on its own, and that is a deliberate operator decision rather than a
default.
[server.rate_limit]: per-caller request rates
[server.rate_limit]
enabled = true
principal_per_second = 1024
principal_burst = 2048
address_per_second = 2048
address_burst = 4096
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Whether rate limiting is active. Off allocates no limiter state and costs no per-request check. |
principal_per_second | int | 1024 | Sustained requests per second per authenticated subject, on the clinical API. |
principal_burst | int | 2048 | How far one principal may burst before refusal. |
address_per_second | int | 2048 | Sustained requests per second per client address, across the whole tree. |
address_burst | int | 4096 | How far one address may burst before refusal. |
This is not max_in_flight, and you should be able to tell them apart from
the status alone. max_in_flight protects capacity: too many requests in
flight at once, from anyone, and the excess is shed 503 + Retry-After.
Rate limiting 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.
Two tiers, because they defend different things. The address tier sits outside authentication, so a flood of unauthenticated requests is refused before it can make the server verify a signature per request; a limiter must not itself be the expensive path. The principal tier sits inside authentication, keyed on the authenticated subject, which is the only fair key for a clinical API: a hospital behind one NAT is a single address, so an address-keyed clinical limit would throttle an entire site because one client was busy.
Both defaults sit above this implementation’s own measured whole-server
ceiling, so neither tier can refuse a caller until it is asking for more than
the server could have served; below that line, capacity is max_in_flight’s
job. A deployment sized for more than the reference measurement environment
should raise both in proportion.
Refusals carry the limiter’s own Retry-After and x-ratelimit-* headers
alongside the openEHR error body. 429 is the status RFC 6585 §4 defines for
this refusal, admitted by ITS-REST as an additional, non-conflicting code.
The always-on health family is covered by the address tier only, deliberately: an orchestrator probe must never be refused because a principal-keyed bucket was exhausted, and probe rates are nowhere near the address ceiling.
Tip
Benchmarking this server? Turn the limiter off 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.connection]: bounds before a request exists
[server.connection]
header_read_timeout_secs = 10
max_concurrent_streams = 256
http2_keep_alive_interval_secs = 30
http2_keep_alive_timeout_secs = 10
Every other limit on this page engages once a request has been parsed and dispatched: body size, the request timeout, the rate limiter, the in-flight shed. A client that opens a socket and then trickles request headers reaches none of them, while costing itself almost nothing. This table is where that is bounded, and HTTP/1 and HTTP/2 need different bounds because the exposure differs: HTTP/1 streams a request head, so it can be trickled; HTTP/2 multiplexes streams, so the exposure is concurrency.
| Key | Type | Default | Description |
|---|---|---|---|
header_read_timeout_secs | int | 10 | How long a connection may take to deliver a complete HTTP/1 request head, in seconds. 0 disables the bound. Applies to both listeners. |
max_concurrent_streams | int | 256 | The most HTTP/2 streams one connection may have open at once. Bounds the request-setup work a peer can trigger by opening and immediately cancelling streams (the “HTTP/2 Rapid Reset” amplification, CVE-2023-44487). 0 leaves the HTTP library’s own default. |
http2_keep_alive_interval_secs | int | 30 | Interval between HTTP/2 keep-alive PINGs, in seconds. 0 disables them, and a peer that vanishes without a FIN is then held until the operating system notices. |
http2_keep_alive_timeout_secs | int | 10 | How long to wait for a keep-alive PING response before closing the connection, in seconds. |
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.
system_idis not[server.identity].system_idsays which system authored the data;[server.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.
[server.tls]: native TLS and mutual-TLS client authentication
Native TLS termination on the main listener, off 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.
[server.tls]
enabled = false
client_auth = "off"
min_version = "1.3"
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Terminate TLS natively on the main listener. |
cert_file | path | unset | Server certificate chain (PEM). Required when enabled; a missing, unreadable or certificate-less file stops startup. |
key_file | path | unset | Server private key (PEM). Required when enabled. |
client_auth | enum{off,optional,required} | off | 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 unless client_auth = "off". |
min_version | enum{“1.3”,“1.2”} | "1.3" | The lowest TLS version this listener negotiates. |
Note
min_versiondefaults to 1.3 only, following the OWASP Transport Layer Security Cheat Sheet: web applications must default to TLS 1.3 and may support TLS 1.2 for compatibility. Setting"1.2"enables 1.2 alongside 1.3, never instead of it; pick it only 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 not selectable at all: RFC 8996 deprecates them, and neither this key nor the TLS library offers them.
[server.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 | FerroEHR project | Providing organisation. |
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 DSN. 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 the HTTP request timeout cannot be:
answering a client by dropping the handler future does not cancel the
statement PostgreSQL is running, so without it a handful of expensive queries
can hold every pooled connection while every one of their callers has already
given up. Keep it above query.timeout_ms
so the AQL engine’s own typed refusal 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.
One constraint on relocation: the linkage migration set revokes a function the
party set creates, so those two domains are prepared in the same database. A
layout that splits them is refused before anything connects, with the remedy.
[log]
[log]
format = "auto"
filter = "info,ferroehr=info"
| Key | Type | Default | Description |
|---|---|---|---|
format | enum{auto,json,pretty} | auto | Stdout rendering. auto picks json when stdout is not a TTY and the coloured human format when it is; an explicit pretty forces colour even through a pipe. |
filter | string | info,ferroehr=info | Boot log-filter directives; also the value /management/loggers resets to. RUST_LOG is a recognized lower-priority alias. |
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.
[telemetry]
OpenTelemetry export. With otlp_endpoint unset the trace export layer is not
installed at all, at zero overhead.
[telemetry]
service_name = "ferroehr"
environment = "dev"
traces_sample_ratio = 1.0
metrics_push = false
| Key | Type | Default | Description |
|---|---|---|---|
otlp_endpoint | string | unset | OTLP/gRPC collector endpoint. Unset ⇒ no trace export. |
service_name | string | ferroehr | The service.name resource attribute. |
environment | string | dev | The deployment.environment resource attribute. |
traces_sample_ratio | float | 1.0 | Head-sampling ratio (0.1 is a common production start). |
metrics_push | bool | false | Also push metrics over OTLP, alongside the Prometheus pull surface. Both surfaces are fed by one meter provider, so every instrument reaches both; the push needs otlp_endpoint set as well. |
flame_file | path | unset | Span-timing flamegraph capture: write folded stack samples of every span to this file, and render offline with inferno-flamegraph. Unset ⇒ the layer is not installed. For diagnostic sessions, not a standing posture: the file grows with span traffic. |
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.
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: Basic credentials, OAuth2/OIDC bearer tokens, or both.
[auth]
enabled = true
verified_cache_ttl_seconds = 60
[[auth.basic.users]]
username = "clinician"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…$…" # never a plaintext password
roles = ["USER"]
[auth.oidc]
issuer = "https://keycloak.example.com/realms/ferroehr"
audiences = ["ferroehr"]
algorithms = ["RS256"]
| 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. |
verified_cache_ttl_seconds | int | 60 | Verified Basic-credential cache TTL (0 disables). Argon2 verification costs real CPU per call by design, so a credential that has verified is remembered (as a digest of the presented header, never plaintext) and re-verified after the TTL. It bounds both the KDF cost of a busy client and how long a revoked credential keeps working. |
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[[auth.basic.users]], configure[auth.oidc], or setauth.enabled = falsefor a development server.
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 |
|---|---|---|---|
username | string | required | Principal name. A blank or missing one is a boot error. |
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. The Argon2id floor is validated identically either way, because validation runs after the file is resolved. Exactly one of the pair is required. |
roles | list of string | ["USER"] | Roles granted, upper-cased on authentication. Use ["ADMIN"] for an administrative account. |
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.
[auth.oidc]: bearer validation
The table’s absence disables bearer authentication entirely. When present, the server validates tokens as a resource server; it never issues them.
| Key | Type | Default | Description |
|---|---|---|---|
issuer | string | required when the table is present | Expected iss; also the OIDC discovery base. Must be an absolute https URL with no query and no fragment (RFC 8414 §2), boot-validated. |
audiences | list of string | required, non-empty | Accepted aud. An empty or all-blank list is a boot error. |
algorithms | list of string | ["RS256"] | Accepted signature algorithms. Boot-bound to the key source: HS* requires hmac_secret, RS*/ES*/PS* require public keys (a static JWKS or the discovered one). none is refused outright. |
require_at_jwt | bool | false | Refuse a token that does not carry typ: at+jwt. A token that does carry it is held to RFC 9068 §2.2 either way: iat, jti and client_id become mandatory for it. |
clock_skew_leeway_seconds | int | 60 | Leeway on the time-based claims (exp/nbf). Capped at 300; above that is a boot error. |
allow_insecure_issuer | bool | false | Accept a non-https issuer. Development and test only. |
hmac_secret / hmac_secret_file | secret / path | unset | Symmetric HS* secret (development/test), minimum 32 bytes. At most one of the pair. |
jwks_json / jwks_json_file | string / path | unset | Static JWKS document. At most one of the pair. |
connect_timeout_ms | int | 3000 | TCP connect timeout for the discovery + JWKS fetches. |
request_timeout_ms | int | 5000 | Whole-request timeout for the discovery + JWKS fetches (connect, TLS, body read). |
negative_cache_ttl_seconds | int | 10 | How long a failed discovery/JWKS fetch is remembered (0 disables). |
The boot rules, and what each one prevents:
audiencesmust name at least one audience. RFC 7519 §4.1.3 obliges a recipient that does not identify itself with a value in a presentaudclaim to reject the JWT, and RFC 9068 §4 step 4 makes the check unconditional for an access token. A resource server that declares no audience cannot reject a token minted for a different resource server, and cannot tell an OpenID Connect ID token (whoseaudis a client id) from an access token (RFC 8725 §3.9, §3.12). Set it to whatever your identity provider puts inaudfor this CDR.issuermust be anhttpsURL with no query or fragment. That is the RFC 8414 §2 definition of an issuer identifier, and §6.2 requires TLS for issuer metadata: over plain HTTP an attacker on the network can serve their own signing keys. A development issuer is opted in explicitly withallow_insecure_issuer = true; the no-query/no-fragment rules still apply, since those are structural.clock_skew_leeway_secondsis capped at 300. RFC 7519 §4.1.4 allows “some small leeway, usually no more than a few minutes, to account for clock skew”, and RFC 9068 §4 step 6 repeats the bound. A large leeway silently extends the life of every token past itsexp.hmac_secretmust be at least 32 bytes. RFC 8725 §3.5: a human-memorizable password must not be used directly as the key to a keyed-MAC algorithm such asHS256. A symmetric key is also shared with the authorization server (meaning this server could mint the very tokens it accepts) so the boot log warns that it is a development posture. Prefer discovery orjwks_json.- The algorithm set is bound to the key source. A key belongs to one
algorithm family, and accepting an algorithm the configured key material
cannot verify is the algorithm-confusion setup RFC 8725 §3.1 warns about:
most famously an
RS256deployment that also acceptsHS256, letting an attacker sign with the public key as if it were a shared secret.
The signing-key source is exactly one of: the symmetric secret, the static
JWKS, or (when neither is set) the issuer’s OIDC discovery document.
Configuring both hmac_secret and jwks_json (in either direct or *_file
form) is a boot error, never resolved by silent precedence. 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.
The last three keys apply only when keys come from OIDC discovery. The timeouts
stop an unresponsive identity provider from parking bearer requests until the
operating system’s TCP timeout; the negative cache means a provider outage
costs one discovery attempt per negative_cache_ttl_seconds rather than one
per incoming request, so callers get fast 401s instead of slow ones. Keep the
negative TTL short: it is also how long recovery takes to be noticed after the
provider comes back.
[authz]
Role-based (RBAC) and attribute-based (ABAC) authorization. The full evaluation order and design rationale are in Security.
[authz.rbac]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | The coarse role gate (active when authentication is enabled). |
admin_role | string | ADMIN | Role required for admin-class operations. A blank value is a boot error. |
user_role | string | USER | Baseline clinical role. |
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. |
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. |
role_claims | list of string | ["roles","groups","entitlements","realm_access.roles"] | JWT claim paths mined for roles, in order. Dotted paths walk nested claims. Must be non-empty and contain no blank path. scope is not a role source; see Security. |
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. 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. |
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. |
[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(string, required whenengine = "remote", and it must end with/, because the policy name is appended),connect_timeout_ms(int,2000),request_timeout_ms(int,5000).[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]
SMART App Launch. Off by default; when off the discovery document is not served and the scope gate is inert. See SMART App Launch.
[smart]:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Serve the discovery document and activate the scope gate. |
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. |
ehr_id_claim | string | ehrId | Token claim carrying the launch context’s openEHR EHR id. |
patient_claim | string | patient | Fallback launch-context claim when ehr_id_claim is absent. |
require_smart_scopes | bool | false | When true, the resource-scope gate is fail-closed across the composition, template and AQL families, and the openehr-permission-v1 capability is advertised: every caller on those families needs a Bearer token carrying a matching SMART resource scope, so a Basic-authenticated caller, which carries no scopes at all, is refused with 403 there (the EHR family is not scope-governed). When false the gate is advisory (it enforces only when a token actually carries SMART resource scopes) and the capability is not claimed. |
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. |
[smart.episode]: enabled (bool, false) advertises episode context and
accepts the launch/episode scope and episodeId claim, but applies no
episode-scoped filtering.
[smart.endpoints] carries the external authorization-server endpoints the
discovery document publishes verbatim: issuer, jwks_uri,
authorization_endpoint, token_endpoint, registration_endpoint,
introspection_endpoint, revocation_endpoint, management_endpoint (all
string, unset ⇒ omitted from the document); the advertised lists
token_endpoint_auth_methods_supported, grant_types_supported,
response_types_supported, code_challenge_methods_supported,
scopes_supported and capabilities (all list of string, [];
capabilities appends operator-advertised HL7 base capabilities such as
launch-ehr or sso-openid-connect to the derived openEHR set); and
allow_insecure_endpoints (bool, false).
Everything in [smart.endpoints] is published at
/.well-known/smart-configuration for third-party applications to act on, so
smart.enabled = true boot-validates it rather than relaying whatever is
configured:
| Rule | Why |
|---|---|
Deprecated grant types (implicit, password) rejected, whether or not SMART is enabled | the SMART App Launch specification’s deprecated-flows section |
public_base_url, authorization_endpoint, token_endpoint required | an enabled Platform without them publishes an unusable document |
Every advertised endpoint an absolute https URL | the document tells apps where to send an authorization request and exchange a code, so a plaintext endpoint exposes the code and the access token (RFC 6749 §3.1.2.1, RFC 8414 §6.2). allow_insecure_endpoints = true opts out for development |
issuer has no query and no fragment | RFC 8414 §2, the same rule auth.oidc.issuer follows, because it is the same identity |
response_types_supported non-empty | RFC 8414 §2 marks the field REQUIRED |
token_endpoint_auth_methods_supported non-empty | an empty list advertises a server that authenticates no client |
code_challenge_methods_supported includes S256 | SMART App Launch requires PKCE (RFC 7636); publishing a list without it tells every app the server cannot do PKCE, and plain alone is not sufficient |
smart.endpoints.issuer equals auth.oidc.issuer | one says where apps obtain tokens, the other which tokens this server accepts. A mismatch means every app gets a valid token and every request is refused |
smart.enabled requires [auth.oidc] | the CDR cannot validate the tokens it directs applications to obtain |
Note
An empty advertised list is not silence: it claims the authorization server supports none of that thing, and a conforming application will believe it.
[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.bind port, and it is plain HTTP, an internal surface. |
[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 /rest/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 /rest/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 (or other ECC) signing key
Any OpenPGP key algorithm is accepted, but an RSA signing key 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 or ECDSA 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 and the server signs with it
automatically, with no retired_key_paths entry needed: the certificate keeps
the previous subkey, so past signatures continue to verify.
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 and the four optional integrations: [query], [events],
[fhir], [terminology], [multimedia]. Everything except the query knobs 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. The HTTP request timeout
cannot stand in for it: answering the client by dropping the handler does not
cancel the statement PostgreSQL is running, so overrunning queries would keep
holding pooled connections after their callers had been given up on. Keeping
this budget the tighter of the two means an overrun surfaces as the engine’s own
typed 408 rather than a driver error.
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.
[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. Together with fhir.outbound.enabled it also gates the per-commit outbox INSERT, so with both 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). |
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.
[fhir]
The FHIR connector: an inbound façade and an independent outbound emitter.
[fhir]: api_enabled (bool, false) mounts /fhir/r4/* plus the
/admin/fhir_mapping CRUD; the routes answer 404 while it is off.
[fhir.outbound]:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Emit mapped FHIR resources to the broker. |
url | secret URL | amqp://guest:guest@localhost:5672/%2f | AMQP broker URL; credentials redacted. |
url_file | path | unset | Read the broker URL from a mounted file instead. At most one of the pair. |
exchange | string | ferroehr.fhir | Topic exchange, deliberately distinct from the events exchange, for PHI isolation. |
tls | bool | false | Upgrade amqp:// to amqps://. |
batch_size | int | 128 | Outbox rows scanned per poll. |
poll_interval_ms | int | 1000 | Idle poll interval. |
publish_max_retries | int | 3 | Per-message publish retries before backing off. |
Warning
The outbound stream carries PHI: its payload is the mapped FHIR resource. That is why it is a separate switch and a separate exchange from the PHI-free change-event stream: broker-level access control can then restrict the PHI-bearing stream on its own. Enable it only against a TLS, access-controlled broker.
[terminology]
The terminology extension API and external FHIR terminology servers.
[terminology]: api_enabled (bool, false) mounts the terminology extension
API.
[terminology.external]:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Materialise the configured providers. With it off, no remote provider is built and validation stays on the in-process openEHR terminology bundle. |
fail_on_error | bool | false | What an unresolvable terminology lookup does to a commit: false accepts it (fail-open), true rejects it. |
Enabling [terminology.external] with no provider configured is a boot error.
[terminology.external.providers.<name>], conventionally at least default:
| Key | Type | Default | Description |
|---|---|---|---|
type | enum{fhir} | fhir | Server kind. Only FHIR R4B is supported. |
url | string | required | The server’s FHIR base URL. Empty is a boot error. |
operation | enum{validate_code,expand} | validate_code | The membership operation. validate_code is a direct yes/no with the least payload; expand plus a membership test is the fallback for servers without $validate-code. |
connect_timeout_ms | int | 2000 | TCP connect timeout. |
request_timeout_ms | int | 10000 | Overall request timeout. |
oauth2_client | string | unset ⇒ unauthenticated | Names an entry under [terminology.external.oauth2_clients]. A name with no such entry is a boot error. |
client_cert_path / client_key_path | path | unset | The mutual-TLS client identity; see below. |
ca_bundle_path | path | unset | The trust anchors this server’s certificate is verified against; see below. |
cache_ttl_secs | int | 300 | TTL of the per-provider response cache; a repeated validate/expand/subsumes/lookup within the window is served locally instead of one HTTPS round trip per validated code. 0 disables it. |
cache_capacity | int | 10000 | Maximum cached responses per provider. |
Cached entries are the decoded responses, not raw JSON: a server answer that
is not a valid FHIR R4B Parameters/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
External terminology servers need the
fhirbuild feature, which is on in the published binary and container images. A binary built with--no-default-featuresrefuses at startup ifterminology.externalis enabled with any provider configured; the in-process openEHR terminology bundle remains available.
Several terminology servers at once
Every entry under [terminology.external.providers] is materialised at
startup, so one instance can serve SNOMED CT from one server and LOINC or ICD
from others. [terminology.external.routes] maps a terminology to the provider
that answers for it: the key is a terminology id (SNOMED-CT) or a system URI
(http://snomed.info/sct), matched case-insensitively as a whole string, and
the value names a provider. A terminology with no route goes to the provider
named default, or to the sole configured provider when there is exactly one. A
route naming a provider that does not exist is a startup error, because a
dangling route would otherwise degrade silently into “ask the default server”.
[terminology.external]
enabled = true
fail_on_error = false
[terminology.external.providers.default]
type = "fhir"
url = "https://r4.ontoserver.csiro.au/fhir"
[terminology.external.providers.snomed]
type = "fhir"
url = "https://snowstorm.example.org/fhir"
oauth2_client = "ts-client"
[terminology.external.routes]
"SNOMED-CT" = "snomed"
"http://snomed.info/sct" = "snomed"
"http://loinc.org" = "default"
Routing applies everywhere terminology is consulted: the /terminology/*
extension API, AQL TERMINOLOGY(…) resolution, and the composition-commit
binding checks below.
Authenticating to a terminology server
[terminology.external.oauth2_clients.<name>] configures an OAuth2
client-credentials client; a provider references it by name with
oauth2_client. The access token is cached and re-requested shortly before it
expires, so a validation burst costs one token request per token lifetime.
| Key | Type | Default | Description |
|---|---|---|---|
token_url | string | required | The OAuth2 token endpoint. Empty is a boot error. |
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. |
scopes | list of string | [] | Scopes requested with the client-credentials grant. |
refresh_leeway_secs | int | 30 | How long before stated expiry the token is renewed. |
auth_method | enum{client_secret_basic,client_secret_post} | client_secret_basic | How the client authenticates at the token endpoint. |
[terminology.external.oauth2_clients.ts-client]
token_url = "https://idp.example.org/realms/ts/protocol/openid-connect/token"
client_id = "ferroehr-cdr"
client_secret_file = "/run/secrets/ts-client"
scopes = ["system/*.read"]
Mutual TLS to a terminology server
A terminology server that authenticates its clients with certificates instead of (or in addition to) a bearer token is configured per provider, because a client certificate is issued by that server’s PKI: a deployment enrolled with a national SNOMED CT service, a commercial value-set server and an in-house FHIR server holds three different certificates. Repeat the same paths in each provider table if one identity really does serve them all.
| Key | Meaning |
|---|---|
client_cert_path | PEM file with the client certificate (optionally a chain) presented to this server. |
client_key_path | PEM file with that certificate’s private key. |
ca_bundle_path | PEM bundle of the trust anchors this server’s certificate is verified against. |
[terminology.external.providers.snomed]
type = "fhir"
url = "https://snowstorm.example.org/fhir"
client_cert_path = "/run/secrets/ts-snomed-client.crt.pem"
client_key_path = "/run/secrets/ts-snomed-client.key.pem"
ca_bundle_path = "/run/secrets/ts-snomed-ca.pem"
client_cert_path and client_key_path are set together; one without the
other is a startup error, never a connection that silently presents no
certificate. Unreadable files, a certificate file with no certificate in it and
a key file with no key in it are startup errors too, so a broken identity never
waits until the first validated code to surface.
ca_bundle_path replaces the default trust anchors for that provider, so a
terminology server issued by a private PKI is pinned to that PKI instead of also
accepting the whole public web PKI. Leave it unset to use the platform’s default
trust store.
Warning
There is no option to disable certificate verification. Server-certificate and hostname verification are always on for every provider;
ca_bundle_pathchanges which anchors are trusted, never whether the server is verified.
The client identity applies to the connection to the terminology server itself.
An OAuth2 token endpoint (oauth2_client) is a different host in a different
trust domain and keeps the default TLS stack.
Kubernetes deployments mount the PEM files with the chart’s config.files map,
which materialises them under /etc/ferroehr/.
Archetype value-set bindings at commit
With [terminology.external] 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 query goes
to the server the binding’s terminology routes to.
- 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, no provider routes to that terminology) →
fail_on_errordecides:false(the default) accepts the commit and logs a warning;truerejects it with422.
With [terminology.external] 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 | string | unset ⇒ default AWS endpoint resolution | S3-compatible endpoint. Must be an absolute http/https URL when set. |
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 & subject proxy
The IHE ATNA audit trail ([audit] and its three sinks) and the FHIR systems a
subject-proxy frame may read from ([subject_proxy]). Precedence, the
environment-name grammar, and file discovery are on the
Configuration reference index.
[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"
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. |
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. |
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 and the ATX:FHIR Feed both carry a FHIR R4
AuditEventdocument, 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. |
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.
[audit.syslog]: the classic DICOM/syslog feed (ITI-20)
| 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 | 514 | Repository port (514 for UDP, 6514 for TLS, conventionally). |
transport | enum{udp,tls} | udp | Syslog transport: RFC 5426 UDP or RFC 5425 TLS. Use tls for PHI-adjacent audit. |
tls_ca_file | path | unset | PEM file with the repository CA to trust for the TLS transport. |
tls_identity_cert_file | path | unset | Client-certificate PEM for mutual TLS. |
tls_identity_key_file | path | unset | Client-key PEM for mutual TLS. |
[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. |
batch_size | int | 64 | Outbox rows shipped per poll. |
poll_interval_ms | int | 2000 | Outbox poll interval when idle. |
max_retries | int | 3 | Per-record POST retries before the record is left pending (local store on) or dropped and metered (store off). |
With the local store on, the feed drains the store’s outbox and is therefore loss-free across a repository outage. With the store off it ships in-drain, and a record that exhausts its retries is dropped and counted.
[subject_proxy]
The named FHIR systems a subject-proxy API_CALL/fhir_get data frame may
retrieve from. Empty by default and fail-closed: no external FHIR system is
reachable until one is named here, and a frame whose system_id matches no
configured system is a typed rejection rather than an arbitrary outbound
request. The per-system key table and examples live on
Subject Proxy — Connecting FHIR systems.
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 validationErrors[] entry per finding,
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 | refused: there is no namespace to mint into | link_as_subject derives the pseudonym itself, in the first declared namespace |
Where FerroEHR itself makes a party the subject of an EHR
(service::linkage::link_as_subject), the pseudonym is minted by the server: a
keyed derivation over the party id under the linkage key, so no
caller-supplied value enters the subject reference on that path and a national
identifier cannot become one. The rule above still governs every value that
arrives from elsewhere, a client writing EHR_STATUS directly, an EHR-Extract,
an archive load, which is why declaring the namespace remains the deployment’s
act: minting needs a namespace to mint into, and the rule is what makes a value
from outside meet the same bar.
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 | Digital and Population Data Services Agency |
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.
[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 leaves the surface off. |
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 demographic.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 demographic.identifier_scheme registry; an unregistered code is refused at the write rather than stored in the clear. |
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, or with an empty schemes list, is a boot error.
A server that believes it seals national identifiers and does not is worse than
one that never claimed to.
Three 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.
- 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. - 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 the search order. 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, the binary boots the server.
ferroehr config …
ferroehr config default # print the annotated default ferroehr.toml
ferroehr config check [--config P] # validate file + environment + --set
config default writes the fully-commented template every key’s default comes
from, the starting point for a real ferroehr.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
Probes the running server’s status endpoint and exits 0 on a 2xx, 1 otherwise:
the container HEALTHCHECK and the Kubernetes exec-probe fallback.
| Variable | Type | Default | Description |
|---|---|---|---|
FERROEHR_HEALTHCHECK_URL | URL | derived: http://127.0.0.1:<server.bind port><REST root>/status (http://127.0.0.1:8080/ferroehr/rest/status with the defaults) | The URL the subcommand probes; also settable as --url. Not part of ferroehr.toml. Unset, the subcommand loads the same configuration as the server (file, environment, --set) and follows server.bind and server.base_path, so a shortened base path moves the probe with it. |
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; 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; rate limiting on; logs in auto format at info; and every
integration 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.oidc]
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
[auth.oidc]. log.format = "json"for cluster log collectors.server.cors_permissivestaysfalse;server.swagger_uiper posture.server.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),audit.syslog.transport = "tls",events.tls,fhir.outbound.tls, HTTPS for the object store. - 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), FERROEHR_HEALTHCHECK_URL (the container healthcheck), 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 compliance 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["Native service API<br/>(SM Platform Service Model)"]
core["Platform: PG18 storage · versioning ·<br/>AQL→SQL engine · validation · signing"]
ext["Optional integrations<br/>(FHIR · 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 (FHIR R4, 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.
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 compliance 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 service the openEHR Service Model calls 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, the association metadata the
Service Model defines, 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 "canonical fragment, children pruned"
timestamptz context_start "promoted, COMPOSITION root only"
}
Supporting tables not drawn above: stored_query (stored AQL, qualified name
plus SemVer), archetype_store and adl2_artefact (the two DEFINITION
dialects), the restriction and retention registers, and the
sp_* family (Subject Proxy Service), which keys its rows by a derived opaque
subject key rather than by the caller’s own subject identifier. 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 stores the node’s canonical openEHR JSON fragment
verbatim (the ITS-JSON encoding) with its structure children pruned out: no
alias compaction, no synthetic fields, so what sits in node.data is
byte-identical in shape to what the API serves. Storage equals wire.
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 dashboard ordering from a partial index;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, leaf values are
extracted from the canonical 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.
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
- 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
All clinical API routes hang off a configurable base path, which defaults to:
/ferroehr/rest/openehr/v1
Every path in these chapters is relative to that base. So “POST /ehr” means
POST http://your-host:8080/ferroehr/rest/openehr/v1/ehr. The base path is set
with FERROEHR__SERVER__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": "FerroEHR project",
"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 (
[server.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, or simply wrong |
Authorization header malformed | 400 | an unparsable header, an unknown scheme, or a bearer token outside the RFC 6750 §2.1 b64token grammar. 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.
Four 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; omitting either is a 422. The server fills intime_committed, andsystem_idwhen you do not supply one. lifecycle_stateis required on every version and is not defaulted. Omitting it is a 400. The one exception is an attestation entry (see below), which commits no new version and therefore has no lifecycle state.- 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.
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 /ehr/{ehr_id}/contribution (no uid) lists the EHR’s contributions, newest
first, a FerroEHR extension (the openEHR REST API defines only the by-uid
read). 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. - 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 uniform
JSON body shape: the openEHR Error object’s members plus a
machine-readable error reason phrase:
-
Validation errors (a composition that fails its template) populate the list:
{ "error": "Unprocessable Entity", "message": "Composition validation failed", "validationErrors": [ "/content[0]/data/events[0]/data/items[1]/value/magnitude: value out of range", "/content[0]/data/events[0]/data/items[2]/value/defining_code: code not in group" ] }Each entry is
"<path>: <message>", so a client can point the user at the exact offending node. A refusal from the data-minimisation rules uses the same list and the same shape, 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. -
All other errors carry the same shape with an empty list:
{ "error": "Not Found", "message": "No EHR with id …", "validationErrors": [] }This shape is used consistently, including for
405 Method Not Allowedand501 Not Implemented, which some servers leave bodyless, and for the two refusals that come from the transport layer rather than a handler:408 Request Timeout(the request exceeded the server’s request-execution limit) and413 Payload Too Large(the request body exceeds the accepted size).
Match on the HTTP status first; read the body for the human-readable detail and, for validation, the per-node list.
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/json
{ "error": "Method Not Allowed", "message": "the request method is not allowed on this resource", "validationErrors": [] }
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.
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
- 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.SELECTprojects values by path. Paths use archetype node ids (at0004) and RM attribute names (value/magnitude);ASnames a column.WHEREfilters on typed leaf values, with comparisons,EXISTS,LIKE,MATCHES, and boolean combinators. Comparisons over multi-valued paths use any-match semantics: when a path matches several nodes or several elements of a list attribute (links,participations,identifiers, …), the predicate holds if any matched value satisfies it (the AQL specification is silent here; any-match is this engine’s documented convention, deterministic and index-friendly). Projecting a path that crosses a list-valued attribute returns every match as one JSON array cell, andnullwhere nothing matches.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
Three details about the URL form: the leading $ is optional ($name and name
bind the same parameter); a 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 /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.
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/openehr/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. An unbound
predicate name, a malformed age band (the form is <min>-<max> whole years,
both inclusive) or a request with no predicate at all is a 400. An empty
cohort runs no AQL and answers an empty result set.
Measured. The committed record docs/conformance/ferroehr/cohort-bench.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.
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.
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 external terminology is not configured, the in-process openEHR bundle is used. See Terminology servers for wiring an external 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. |
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,VERSION(LATEST_VERSION/ALL_VERSIONS), and the RM structure classes, with archetype and name predicates;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 sources.
FROM PARTY/ROLE/ACTORand the other demographic classes are out of the query engine’s scope; the demographic API serves them directly. 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. 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, …) invalidationErrors, each with a human-readable detail.
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 validationErrors. 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. validationErrors
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/rubentalstra/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
validationErrorslist 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, where SMART is off. Under
[smart] require_smart_scopes = true, the posture the conformance stack runs, the template family is scope-governed and a Basic caller answers403: only a Bearer token carrying auser/template-*(or broader) SMART resource scope reaches these endpoints. See[smart].
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 /admin/template/{template_id}, behind the admin gate (see Admin & messaging APIs); - an ADL 2 artefact is removed with
DELETE /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:
| 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).
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 422. 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. 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). |raw is write-only: retrieval always decomposes to regular FLAT
keys.
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.
If a composition is well-formed but breaks its template, the commit fails with
422 Unprocessable Entity and a validationErrors list (one entry per
offending node, as "<path>: <message>") 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, bridging to FHIR, 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.
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 and TDD 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, Subject Proxy FHIR systems, change events, the FHIR connector and its outbound emitter, 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 two that move clinical content off this system are the outbound FHIR emitter (its payload is the mapped clinical resource) and multimedia offload (the blob bytes land in your bucket). Change-event envelopes carry identifiers and metadata only. Treat enabling either of the two 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 & subject proxy.
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 more than the connector: the external FHIR terminology
providers and the FHIR AuditEvent audit sinks need it too, and enabling fhir
also enables events, because the outbound emitter drains the same commit
outbox. See
From source → Build features
for the exact list of settings a slim binary rejects.
Note
One gap worth knowing if you build slim:
fhir.api_enabledandterminology.api_enabledare route switches, 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, import an extract into an existing record, and import Template Data Documents (TDDs) as compositions.
- 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.
- Subject Proxy: read facts about a subject (“date of birth”, “latest blood pressure”) through named variables backed by data frames: AQL against this CDR, reads from configured external FHIR servers, or values pushed in manually. A service-layer capability; it has no REST endpoints.
- 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.
- FHIR connectors: mapping-driven ingestion of FHIR R4 resources, a patient-scoped read façade that returns openEHR data as FHIR, and event-driven outbound emission of mapped FHIR resources.
- 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, multi-tenancy, 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), and Template Data Document (TDD) import.
Note
The openEHR service model defines a Message component, but the released REST API publishes no extract, message, or TDD endpoint at all. FerroEHR therefore serves these operations under a
/messagegroup of its own design; 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 six routes, with their bodies, parameters, and status codes, are in Admin & messaging APIs.
- Exporting an EHR
- Importing and cloning across systems
- Importing TDDs
- Divergent copies and version branches
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.
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.
Importing TDDs
A Template Data Document (TDD) is a template-shaped XML document carrying the data for one composition. TDD import converts a TDD into a composition against the operational template its root names and commits it, returning the new version’s object version id.
The template must already be provisioned through the definition API, and the commit goes through the same validated write path as any other composition (see Templates & validation) so a malformed document, an unknown EHR, or an unknown template is rejected rather than partially stored.
A batch variant imports several TDDs in one call and is all-or-nothing: every document is converted before any is committed, so one unconvertible document rejects the whole batch and commits nothing. An empty array is a fulfilled no-op: the target EHR is still checked, and nothing is created.
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.
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).
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:
| 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 can additionally validate coded values against any number of external FHIR terminology servers at once.
- The bundled openEHR terminology
- External FHIR terminology servers
- 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 a configured external 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 API base path, /ferroehr/rest/openehr/v1. An
unknown terminology or value set is a 404; a missing required query parameter
is a 400.
External FHIR terminology servers
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.
External terminology is off by default, and while it is off nothing is
requested: validation uses the in-process bundle alone. The keys live under
[terminology.external]; the full table, including timeouts, caching, OAuth2
and mutual TLS, is on
Integrations. The minimum
is a master switch and one provider:
[terminology.external]
enabled = true
[terminology.external.providers.default]
type = "fhir"
url = "https://tx.example.org/fhir"
Enabling the section with no provider configured is a boot error, not a
silent fall back to the bundle. So is an empty provider URL, an
oauth2_client naming no configured client, and half a mutual-TLS identity:
a control you configured either works or the server refuses to start.
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 external terminology enabled, committing a composition resolves those bindings for every bound coded value the instance actually carries, against the server the binding’s terminology routes to:
- 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) only picks the provider. 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 external terminology server configured at all: nothing is asked and nothing is refused.
Which FHIR operation is used
Membership is tested with ValueSet/$validate-code by default: one direct
yes/no with the least payload. A server that does not offer it can be switched to
$expand plus a membership test with operation = "expand" on that provider.
This is a per-provider configuration choice, not an automatic fallback: the
server does not retry a failed $validate-code as an $expand, so set the
operation your server actually supports.
Responses are cached per provider (decoded, not raw), so a burst of commits validating the same codes does not become one HTTPS round trip per code. A response that is not a valid FHIR resource is treated as an upstream fault rather than partially read, and takes the same path as an unreachable server.
Several terminology servers at once
Real deployments bind to more than one terminology: SNOMED CT from one server,
LOINC or a national code system from another. Every entry under
[terminology.external.providers] is materialised at startup, and
[terminology.external.routes] decides which one answers each call:
[terminology.external]
enabled = true
[terminology.external.providers.snomed]
type = "fhir"
url = "https://snomed.example.org/fhir"
[terminology.external.providers.loinc]
type = "fhir"
url = "https://loinc.example.org/fhir"
# Terminology id or system URI -> provider name.
[terminology.external.routes]
"SNOMED-CT" = "snomed"
"http://snomed.info/sct" = "snomed"
"http://loinc.org" = "loinc"
Selection is deliberately mechanical, so you can predict which server answers:
- The caller offers candidate keys in priority order: a terminology id, a system URI, a value-set URL, the AQL service flavour.
- The first candidate with a route entry wins. Keys are matched case-insensitively and whole-string, never as a prefix.
- Otherwise the provider named
defaultanswers, or, when exactly one provider is configured, that one. - With two or more providers and no
default, an unrouted terminology has no server at all, and the call falls back to local behaviour.
Step 4 is a useful way to make routing mistakes loud instead of silent: add a
default only when you genuinely want a catch-all server. A route naming a
provider that does not exist fails at startup, never at request time.
Routing applies everywhere terminology is consulted: the /terminology/*
extension API, AQL TERMINOLOGY(…) resolution, and the commit-time binding
checks above.
Authenticating to a server
A provider that needs a bearer token references a client-credentials client by
name; 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 under
[terminology.external.oauth2_clients.<name>]. A provider that authenticates
with a certificate instead gets its mutual-TLS identity per provider, because
the certificate is issued by that server’s PKI. Both are configured on
Integrations.
There is no option to disable certificate verification. Server-certificate and hostname verification are always on; a private-PKI trust bundle changes which anchors are trusted, never whether the server is verified.
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.external] 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 still runs its own server: from a checkout of the repository, the conformance stack can start a HAPI FHIR JPA server beside the CDR, seeded with the same synthetic code systems and value sets over its FHIR API (the lane moves to FerroTERM under #3085):
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 the terminology server (host port 8090 by default,
FERROEHR_TERMINOLOGY_PORT) plus a one-shot seeding container that uploads the
fixtures over the server’s own FHIR API and verifies $validate-code and
$expand before exiting, so a misconfigured server fails there rather than
inside a later run. The overlay file is what points the CDR at it, by switching
on the [terminology.external] providers 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 providers at a real server, and for SNOMED CT hold the appropriate licence, for anything beyond experimentation.
The terminology container mounts no volume, so its seeded content lives inside the container only: re-create it and the seed is gone. Re-run the profile (or just the seeding container) afterwards.
Tip
Seeding your own server is the same shape: upload the CodeSystem and ValueSet resources your templates reference over plain FHIR REST (
PUTto/fhir/CodeSystem/<id>and/fhir/ValueSet/<id>). A FHIR terminology server starts empty, and a value set it does not hold is an unresolved binding, which yourfail_on_errorsetting then decides the fate of.
On Kubernetes
Providers and routes are maps, so they are supplied as chart values rather than
environment variables; the config passthrough renders them verbatim into the
server’s configuration file
(Any server setting is reachable):
# values.yaml
config:
terminology:
api_enabled: true
external:
enabled: true
fail_on_error: true # fail-closed: an unresolvable binding rejects the commit
providers:
default:
type: fhir
url: https://tx.example.org/fhir
secrets:
# only when a provider uses OAuth2; keyed by client name
terminologyOauth2ClientSecrets: {}
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.external.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.
Subject Proxy
The Subject Proxy Service lets an application read facts about a subject (“date of birth”, “latest systolic blood pressure”, “current medications”) without knowing which system holds them, what standard it speaks, or what query language it uses. You register variables describing what you want and bind them to data frames describing how to fetch it (an AQL query against this CDR, a FHIR read against a remote server, or a manual feed). The service runs the frames, keeps a sample history per variable, and serves fresh values out of that history without re-querying the source.
Important
The Subject Proxy is a service-layer capability, not a REST API: no HTTP endpoints expose it. What you can configure today is the set of external FHIR systems its frames are allowed to reach (below), and the server builds that executor at startup when you name at least one system. The openEHR service model defines the operations; the wire exposure is future work.
The model
- Subject: the person (or other entity) the variables are about, registered
by an external subject id, with a free-text category (default
individual). The id you register is never stored: the server keeps an opaque key derived from it, so the clinical database holds no subject identifier of its own. For openEHR-backed variables the subject id is resolved to an EHR: a literal EHR id first, then a lookup in the EHR id / subject cross-reference, which lives in the linkage domain and is read through its own pool. - Variable: a named, typed fact about a subject: a
name(optionally qualified by anamespace, giving a canonicalnamespace::nameidentity), a type, an optionalcurrency(how fresh a served value must be), and either a binding to a data frame (frame_id+frame_path) or theis_manualflag. - Data set: an application’s working set of variables for one subject,
under local aliases (your app can call the canonical
date_of_birthvariabledob). Data sets track which applications use them; when the last using application deregisters, the empty data set is dropped. - Binding: an environment’s catalogue of data frames. Each frame names a
retrieval method: an
API_CALL(for example a FHIR read) or aQUERY_CALL(an AQL query) against a named system, plus an optional fallback method.
Defining frames
A binding is a plain document; YAML and JSON are interchangeable. Frames name
their system with system_id, and $subject_id inside a query_text is
substituted with the subject’s id at retrieval time:
env_id: prod
description: deployment environment
data_frames:
- id: "openEHR::vital_signs"
model_type: openEHR-EHR
primary_method:
_type: QUERY_CALL
system_id: ehr1.nhs.org.uk
call_name: aql_query
query_text: SELECT c FROM EHR e CONTAINS COMPOSITION c
- id: "fhir::demographics"
model_type: HL7-FHIR_DSTU4_UK
primary_method:
_type: API_CALL
system_id: pas
call_name: fhir_get
query_text: Patient/$subject_id
fallback_method:
_type: QUERY_CALL
call_name: aql_query
query_text: SELECT e/ehr_id/value FROM EHR e
A variable then points at a frame and a path within its result, for example a
dob variable bound to fhir::demographics with the frame path /birthDate.
Primary, then fallback. The primary method runs first; if it yields data, that is the sample. If it is unavailable (the source is down, answers a non-2xx status, times out, or returns a body that will not parse) and a fallback is defined, the fallback runs and its outcome wins, available or not. Every attempt produces a sample either way, so “the source was unreachable at 14:02” is itself recorded history rather than a gap.
Sample history and currency
Every retrieval attempt is persisted as a sample: the retrieve time, the
real-world effective_time the data pertains to (for FHIR reads, the resource’s
meta.lastUpdated), and the value, or an unavailability marker carrying the
reason. The most recent hundred samples per variable are kept, newest first, so a
variable read returns a value with its recent history and provenance. The
history is a bounded ring by design, not an unbounded log.
A variable’s currency is an ISO 8601 duration saying how fresh a served
value must be. On a read, if the newest stored sample’s effective time falls
inside the currency window, it is served without touching the source;
otherwise the frame runs again. Freshness is judged against the moment of
evaluation, which is the only reading that makes a duration with nominal parts
(months, years) decidable. A variable with no currency means “the most recent
available value is valid”, so any stored sample serves. An unparseable timestamp
counts as stale rather than fresh.
When an application registers a data set whose variables ask for a tighter currency than the stored definition, the variable’s currency is tightened to the stricter value: registration can only make data fresher, never staler.
Connecting FHIR systems
Frames of kind API_CALL / fhir_get read from remote FHIR servers (the
FHIR release is the remote’s property; the proxy relays fhir+json bodies
and decodes nothing release-specific). Which
servers are reachable is opt-in and fail-closed: only systems named in
configuration can ever be called, and a frame naming an unconfigured system_id
is a typed rejection, never an arbitrary outbound request. By default no system
is configured and every FHIR frame is rejected.
Systems are a map keyed by the name frames use as their system_id, under
[subject_proxy.systems.<name>]:
| Key | Type | Default | Description |
|---|---|---|---|
base_url | string | required per system | The remote FHIR server’s base URL. The frame’s query text is resolved relative to this. Blank or absent is a boot error naming the system. |
connect_timeout_ms | int | 2000 | TCP connect timeout. |
request_timeout_ms | int | 10000 | Overall request timeout. |
[subject_proxy.systems.pas]
base_url = "https://pas.example.com/fhir"
To let the fhir::demographics frame above reach a patient administration
system:
export FERROEHR__SUBJECT_PROXY__SYSTEMS__PAS__BASE_URL=https://fhir.example.org/r4
Tip
The environment form takes a double underscore after
FERROEHRand between every segment, and the map key is just another segment, so the system namedpasbecomes…__SYSTEMS__PAS__BASE_URL. Getting it wrong is not a silent no-op: any unrecognised variable in the reservedFERROEHR_namespace is a boot error with a did-you-mean suggestion, so a setting that never arrived cannot masquerade as a setting that had no effect.
Requests are sent with Accept: application/fhir+json, and the frame’s
query_text (after $subject_id substitution) is resolved relative to the
system’s base URL. A timeout, an error status, or a body that does not parse
becomes an unavailable sample, which is exactly what triggers the frame’s
fallback.
On Kubernetes
Systems are a map, so they are supplied as chart values and rendered verbatim into the server’s configuration file (Any server setting is reachable):
# values.yaml
config:
subject_proxy:
systems:
pas:
base_url: https://fhir.example.org/r4
request_timeout_ms: 10000
Before you enable it: a reachable FHIR server per system, and (if the chart’s default-deny egress policy is on) an egress rule that admits it, or the calls fail as timeouts. To turn it off, remove the systems: with none configured every FHIR frame is rejected, which is the fail-closed default.
Manual variables
A variable marked is_manual has no frame: its values are pushed in by a
notifier (typically a worker or a device observing the subject) through the
service’s sample-notification call. Reads then serve the stored history; until a
first sample arrives, a read returns an unavailable sample saying so.
Pushing is accepted only for variables marked manual (or flagged ask_user).
Pushing to a frame-bound variable is refused, so a notifier cannot quietly
override a value the service is supposed to retrieve.
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 (the FHIR
outbound emitter, when enabled): the prune’s floor is the lowest active
reader cursor in
event_outbox_reader, 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
/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 /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).
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.
FHIR connectors
Many systems around a CDR speak FHIR. FerroEHR ships a set of FHIR R4 connectors so it can take FHIR resources in, hand openEHR data back out as FHIR, and emit FHIR resources to downstream systems, all driven by mappings you control. It is not a full FHIR server; it is a focused, mapping-driven bridge between the FHIR and openEHR worlds.
There are two independent switches, an inbound/read-façade one and an
outbound-emission one, because the two have very different data-exposure
characteristics. All FHIR routes are relative to the API base path
(/ferroehr/rest/openehr/v1) and speak application/fhir+json; every response
on this surface (success or failure) is a FHIR resource, so an error arrives as
an OperationOutcome rather than the openEHR error body.
Note
R4, and what that means if you run R4B. The connector’s routes are
/fhir/r4/…, and the resources it exchanges (Bundle,OperationOutcome,AuditEvent) are identical in R4 and R4B, so an R4B client can use this surface unchanged: HL7 states that “implementers that do not use the specific portions where changes have been made can continue to use either R4 or R4B without any functional difference” (what R4B changed). Which resource types you may actually exchange is set by your mappings, not by the release. One neighbouring subsystem is deliberately different: the external terminology servers FerroEHR calls out to are R4B, because there the release belongs to the server you point it at.
- Inbound ingestion
- Validating without committing (
$validate) - Read façade
- The EHDS priority categories, and what round-trips
- Outbound emission
- Mappings are data you manage
- The one route here that is not the connector
- Enabling the connectors
Inbound ingestion
POST /fhir/r4/{resource_type} takes a FHIR resource and stores it as a
validated openEHR composition. The connector resolves the mapping for the
resource type (and its meta.profile, when the resource declares one), resolves
or creates the EHR from the resource’s subject, builds a composition from the
mapping, stamps it with a FEEDER_AUDIT recording the FHIR origin, and commits it
through the normal validated write path.
Outcomes worth designing against:
- A successful ingest is
201, withETagandLocationheaders pointing at the openEHR composition that was created. - A mapped composition that fails validation is
422, and nothing is stored. - A resource type outside the connector’s starter set (
Patient,Observation,Condition,DocumentReference) is501, refused before the backend is touched. - A type inside the starter set with no enabled mapping stored for it is
404. This is the common first-run surprise: the connector is on, but nothing is mapped yet.
Provenance is not optional: the composition the CDR stores carries a
FEEDER_AUDIT naming the FHIR origin and the source resource’s own id, so an
ingested record is always distinguishable from one authored in openEHR.
Validating without committing ($validate)
POST /fhir/r4/{resource_type}/$validate is the ingest door’s dry twin,
following FHIR R4’s own
validation operation
convention. It runs the whole ingest pipeline (mapping resolution, the FLAT
build, the FEEDER_AUDIT stamp, and the same validation the real commit
runs) and commits nothing: no composition, no version, and no EHR is
created (the target EHR is resolved and reported, never touched).
The response is a FHIR OperationOutcome, and a completed validation is
200 whichever way the verdict falls:
- Valid:
informationissues, the verdict naming the resolved template, plus the EHR disposition (would commit into existing EHR <id>, orwould create a new EHR for subject '<id>'). - Invalid: an
errorissue carrying the openEHR validator’s rejection verbatim (the exact text the real ingest would refuse with as a422) plus the same disposition issue.
Operation-level failures mirror the ingest door: no enabled mapping is 404,
a type outside the starter set is 501, a malformed body is 400, and the
disabled connector is 404. This is what makes mapping development safe:
iterate on a mapping with $validate against real sample resources, and only
switch to the real POST once the outcome reads valid.
curl -s -X POST "$CDR/fhir/r4/Observation/\$validate" \
-H 'Content-Type: application/json' \
--data-binary @observation.json | jq '.issue[].diagnostics'
Read façade
GET /fhir/r4/{resource_type}?patient=<subject> returns openEHR data
reverse-mapped into a FHIR searchset Bundle. Each entry is produced from a
stored composition by running the mapping in reverse.
The patient parameter is mandatory; a missing or blank one is a 400. This
is a targeted façade, not a general FHIR search engine: there is no free-text
search, no chained parameter, and no _include. An optional _count caps the
entries returned per mapping.
The EHDS priority categories, and what round-trips
The six priority categories of personal electronic health data are Annex I of the EHDS regulation, and Annex II 2.1 to 2.3 require an EHR system to provide and receive them in the European electronic health record exchange format. That format is set by implementing acts under Article 36 which have not been adopted, so this table is not a conformance claim against it. What it says is narrower and checkable: which category has a committed template whose example composition round-trips through this façade today.
| Annex I | Category | Committed template | A profile mapping would target | Transform proven |
|---|---|---|---|---|
| 1 | Patient summaries | corpus/templates/ckm/international-patient-summary.opt | Bundle (IPS-shaped) over Patient, Condition, AllergyIntolerance, MedicationStatement | Yes |
| 2 | Electronic prescriptions | corpus/templates/ckm/eprescription-fhir.opt | MedicationRequest | Yes |
| 3 | Electronic dispensations | — | MedicationDispense | No committed template |
| 4 | Medical imaging studies and related imaging reports | corpus/templates/ckm/ccta-report.opt | DiagnosticReport (+ ImagingStudy for the study itself) | Yes |
| 5 | Medical test results, including laboratory and other diagnostic results | corpus/templates/ckm/generic-lab-test-result.opt | DiagnosticReport + Observation | Yes |
| 6 | Discharge reports | — | Composition (discharge summary) + Encounter | No committed template |
“Transform proven” means exactly what the test asserts, and no more
(app/ferroehr/tests/it/fhir_priority_categories.rs): the category’s
committed operational template builds a Web Template, its committed example
composition flattens against that template to a non-empty map, and a mapping
entry over a leaf taken from that map drives the reverse transform to a FHIR
resource carrying the composition’s own value. The leaf is derived from the
map rather than written into the test, so a corpus refresh moves it and the
case still holds.
Three things it deliberately does not establish, each of which a reader could otherwise assume:
- No profile mapping ships. The rightmost column says what a mapping for
that category would target, not what exists. The test maps its leaf onto
Observation.note.text, a free-text element: it proves the machinery carries real clinical content, not that an IPS-shapedBundleor aMedicationRequesthas been authored and reviewed. Mappings are data a deployment registers, as the section above describes. - It exercises the transform, not the endpoint. The test calls the reverse transform directly, so routing, authorization and the stored-mapping lookup are covered by other tests rather than by this table.
- It is not conformance to the exchange format, which does not exist yet.
The connector this table measures is planned to leave: FerroBRIDGE (https://github.com/rubentalstra/FerroBRIDGE) is the FHIRconnect and OMOP bridge, and #3080 retires the in-tree connector once it ships. The EHDS readiness question does NOT leave with it — it is asked of the EHR system — so this table moves to the compliance chapter at that point rather than being deleted with the page it currently sits on.
Two of the example compositions this rests on — the patient summary and the imaging report — were patched by hand rather than regenerated against a running server, which their pack’s provenance records and #1724 tracks. They are real CKM templates either way; the caveat belongs beside a claim that leans on them.
The decision on profile mappings: wait for the implementing acts
No profile mapping ships for any priority category, and none will be authored here. That is a recorded decision (#3206), not an omission, and it rests on two things being unfixed at once.
The target format is unfixed. Annex II 2.1 to 2.3 require the categories in
the European electronic health record exchange format, and that format’s
content is set by implementing acts under
Article 36 which have not
been adopted. An IPS-shaped Bundle, a MedicationRequest or a
DiagnosticReport authored today would be authored against a guess at what
those acts require.
The place is unfixed too, and settled the other way. Mappings belong to
FerroBRIDGE, which is the
FHIRconnect and OMOP bridge, and
#3080 retires the
in-tree connector once it ships its first round trip. A mapping written here
would be written against ehr.fhir_mapping rows and the FHIRPath-lite dialect
this façade reads, both of which leave with the connector.
What reopens the question. The Article 36 implementing acts being adopted reopens it, because the target stops being a guess. FerroBRIDGE shipping its first round trip settles where the work lands. Either event is a reason to revisit #3206; neither has happened.
What stays in FerroEHR regardless is the readiness statement: which categories are carried, by what, and where the gaps are. That is the table above and the EHDS readiness page.
The two categories with no committed template
Electronic dispensations (Annex I 3) and discharge reports (Annex I 6) have no template in the curated CKM pack, so nothing demonstrates them end to end. That is an adjudicated boundary rather than a backlog item. A CDR stores whatever an operational template defines, so this is a corpus gap and not a storage one. The pack is vendored verbatim from the openEHR CKM, every file being CKM’s own export with its provenance recorded, so filling the two rows would mean authoring a template here and putting it in a pack whose value is that nothing in it was authored here. A deployment holding dispensations or discharge summaries uploads its own operational template, and the façade maps it like any other.
Outbound emission
Outbound emission publishes the mapped FHIR resource for every relevant commit,
but the target is an AMQP broker (RabbitMQ), not an HTTP FHIR server. A
background task drains the same commit outbox used by
change events, reverse-maps each committed composition through
every enabled mapping bound to its template, and publishes each resulting
resource to a topic exchange (default ferroehr.fhir) with a routing key of
<resource_type>.<template_id>, both segments sanitised the way the change-event
keys are. Delivery is at-least-once.
Only composition versions produce messages: an EHR_STATUS or a FOLDER carries
no mappable template. A row that fails to reverse-map deterministically (a
defective stored mapping or template) is retried a few times and then parked:
logged at error level, naming the row, and skipped, so one bad commit cannot
head-of-line-block every later one. Broker and database failures are treated as
transient and never park a row.
Warning
Outbound FHIR messages carry PHI: the payload is the mapped clinical FHIR resource, unlike the PHI-free change-event envelopes. That is exactly why they are a separate switch on a separate exchange (
ferroehr.fhir, notferroehr.events): broker access control can then isolate the PHI-bearing stream. Enable it only against a TLS, access-controlled broker, and treat every consumer as a PHI processor.
Note
The change-event publisher has a health indicator; the outbound emitter does not have one of its own today, so treat its broker as something to monitor at the broker rather than through the CDR’s readiness surface. See Operations.
Mappings are data you manage
There are no bundled mapping files. Each mapping is a stored definition managed through an admin API (classed under admin authorization):
| Method | Path | Purpose |
|---|---|---|
GET | /admin/fhir_mapping | list mappings |
POST | /admin/fhir_mapping | create a mapping (201) |
GET | /admin/fhir_mapping/{mapping_id} | get a mapping |
PUT | /admin/fhir_mapping/{mapping_id} | update a mapping |
DELETE | /admin/fhir_mapping/{mapping_id} | delete a mapping (204) |
A mapping definition binds one FHIR resource type (optionally scoped to a
meta.profile URL) to one openEHR template, and lists field bindings, each
mapping an openEHR FLAT path to a FHIR path, or to a constant, shaped by a
transform.
Resolution is two-step and deterministic. An incoming resource resolves by
its type plus the first entry of meta.profile (only meta.profile[0] is
consulted): an enabled mapping whose profile_url exactly matches that URL
wins; otherwise the type’s enabled mapping with no profile_url (the
type default) applies. A resource declaring no profile matches only the type
default. When neither exists, the ingest (and $validate) answer 404.
The stored definition is the deployable artifact: the CDR stores it verbatim and interprets it at ingest time, so a mapping deploys, updates, and rolls back without a server release. Its shape (this is the whole contract, with no openEHR specification governs FHIR interop; the wire vocabulary follows HL7 FHIR R4):
{
"resource_type": "Observation",
"profile_url": "http://hl7.org/fhir/StructureDefinition/bp",
"template_id": "blood_pressure.en.v1",
"subject": {
"reference_path": "subject.reference",
"namespace": "fhir",
"strip_prefix": "Patient/"
},
"context": {
"ctx/language": "en",
"ctx/territory": "US",
"ctx/composer_name": "fhir-connector"
},
"entries": [
{ "openehr_path": "blood_pressure/blood_pressure:0/systolic",
"fhir_path": "component.where(code.coding[0].code = '8480-6').valueQuantity.value",
"transform": { "kind": "quantity",
"unit_path": "component.where(code.coding[0].code = '8480-6').valueQuantity.unit" },
"required": true },
{ "openehr_path": "blood_pressure/blood_pressure:0/diastolic",
"fhir_path": "component.where(code.coding[0].code = '8462-4').valueQuantity.value",
"transform": { "kind": "quantity",
"unit_path": "component.where(code.coding[0].code = '8462-4').valueQuantity.unit" },
"required": true }
]
}
The example binds the HL7 FHIR R4 core
blood-pressure profile (systolic LOINC
8480-6, diastolic 8462-4, each a component of one Observation) to a
blood-pressure template’s two quantity leaves. subject names where the
patient identity lives in the resource and how it becomes the EHR subject
(Patient/p-42 → subject id p-42 in namespace fhir); context supplies
the FLAT ctx/ defaults every built composition carries (an omitted
ctx/time defaults to the ingestion instant). The transforms:
| Transform | What it writes |
|---|---|
| plain text | the bare FLAT leaf |
| date | an ISO 8601 date or date-time leaf |
| quantity | the magnitude and unit leaves, the unit read from the resource or fixed |
| coded | the code, the resolved openEHR terminology id, and optionally the display text |
A coded transform carries its own FHIR-code-system-to-openEHR-terminology map,
with * as the fallback for any unmatched system. An entry can be marked
required, which turns an absent source value into an error instead of a skipped
field.
A coded transform can also declare translate, asking for cross-terminology
code translation at ingest time:
{ "openehr_path": "…/problem",
"fhir_path": "code.coding[0].code",
"transform": { "kind": "coded",
"system_path": "code.coding[0].system",
"translate": { "target_system": "http://snomed.info/sct",
"concept_map": "http://example.org/ConceptMap/my-map" } },
"code_map": { "http://snomed.info/sct": "SNOMED-CT" } }
The server resolves each such code through a configured FHIR terminology
server’s ConceptMap/$translate (routed by the openEHR terminology the
code_map binds the target system to; concept_map optionally pins one map).
Only a strictly equivalent match is taken; a wider, narrower, or
relatedto mapping is treated as no translation, because writing a
non-equivalent code would silently change clinical meaning. When no
translation exists, a required entry refuses the ingest and an optional one
writes nothing, and the untranslated source code is never passed through under
the target terminology. A mapping that declares translate on a deployment
with no terminology server configured is refused as a server configuration
error rather than silently skipped.
The FHIR-path support is a deliberate subset of
FHIRPath: object-field navigation, zero-based
array indexing, first(), and single-condition where(path = literal)
filters, for example code.coding[0].code,
code.coding.where(system = 'http://loinc.org').code, and
component.where(code.coding[0].code = '8480-6').valueQuantity.value, not
the full FHIRPath language (no other functions, unions, or arithmetic).
FHIRPath’s where() filters a collection; because a FLAT leaf holds a single
value, this subset takes the first matching element. The mapping
is symmetric (the same definition drives inbound ingest, the read façade, and
outbound emission) so a field you can ingest is a field you can serve back
(a translated entry serves back the stored, translated coding).
Each mapping also carries an enabled flag (default on). Only enabled mappings
resolve, for ingest, for the façade, and for outbound emission, which makes
disabling one a reversible way to take a resource type out of service.
Note
The template a mapping references must already be ingested (see Templates & validation); creating a mapping against an unknown template is a
400. A mapping’s name is immutable once set, because it is its deployable identity, and a duplicate name is a409.
The one route here that is not the connector
GET /fhir/r4/AuditEvent sits under the same path prefix but is not part of
the FHIR connector: it is the audit trail’s own retrieval surface, returning
stored audit records as FHIR AuditEvent resources. It is gated by the local
audit record repository rather than by the connector switch. The unscoped
retrieval is admin-only; a caller holding the configured
authz.rbac.subject_audit_role reads it scoped to one subject, with the
patient parameter required. See Audit trail (IHE ATNA).
Enabling the connectors
Both switches are off by default. The inbound/read-façade switch lives under
[fhir] and the outbound emitter under [fhir.outbound]; the full table with
every default is on
Integrations. The essentials:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__FHIR__API_ENABLED | false | mount inbound ingest, the read façade, and the mapping API |
FERROEHR__FHIR__OUTBOUND__ENABLED | false | run the outbound emitter (carries PHI) |
FERROEHR__FHIR__OUTBOUND__URL | a local development broker | outbound broker URL |
FERROEHR__FHIR__OUTBOUND__URL_FILE | unset | read the broker URL from a mounted file instead |
FERROEHR__FHIR__OUTBOUND__EXCHANGE | ferroehr.fhir | outbound topic exchange, kept distinct from the event stream |
FERROEHR__FHIR__OUTBOUND__TLS | false | upgrade an amqp:// URL to amqps:// |
Batch size, poll interval, and publish retries are tunable as well.
When the inbound switch is off, /fhir/r4/* and /admin/fhir_mapping answer
404 without touching the backend. When the outbound switch is off, no emitter
task runs. With authentication on, an unauthenticated request to a disabled group
is answered 401 first: the group gate sits behind authentication.
Note
The connectors are also a cargo feature (
fhir), on in the published images and any default build, and enabling it also enableseventsbecause the emitter drains the commit outbox. A slim--no-default-featuresbuild contains none of their code and refuses to boot whenfhir.outbound.enabled, a configured external FHIR terminology provider, or a FHIRAuditEventaudit sink asks for it.fhir.api_enabledis the exception: those routes are simply not compiled in, so the setting has no effect rather than failing loudly. See From source → Build features.
On Kubernetes
Both switches are reachable through the chart’s config passthrough
(Any server setting is reachable).
The outbound broker URL carries credentials, so it goes through
secrets.fhirOutboundUrl, which the chart mounts as a file:
# values.yaml
config:
fhir:
api_enabled: true # the read façade + mapping API
outbound:
enabled: true # the emitter; carries PHI
exchange: ferroehr.fhir
tls: true
secrets:
fhirOutboundUrl: "amqps://user:pass@broker.example:5671/%2f"
Before you enable outbound: a reachable broker, an egress rule that admits it
if the chart’s default-deny egress policy is on, and a deliberate decision: it
carries PHI off this system. To turn either off, set its switch to false and
upgrade: the inbound routes go back to answering 404, and no emitter task runs.
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
[server.limits] body_bytes(16 MiB by default); a composition that exceeds it is refused413before offload is ever considered. See[server.limits].
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 block:
- HTTP Basic is active when a
basicblock with a user list is configured. Each user has a username, an Argon2 password hash (a PHC string beginning$argon2id$) or apassword_hash_filepointing at one, and a set of roles (defaulting to["USER"]). Because it is a list of users, the Basic block is normally supplied through the TOML configuration file rather than environment variables. - OAuth2/OIDC bearer tokens are active when an
oidcblock is configured. The server validates the token’s signature, issuer, and audience.
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) |
[auth.oidc] with no audiences | error: a server with no declared audience cannot reject another server’s token (RFC 7519 §4.1.3) |
[auth.oidc] issuer not https, or carrying a query/fragment | error: RFC 8414 §2, §6.2 (allow_insecure_issuer = true opts a dev issuer out of the scheme rule only) |
[auth.oidc] clock_skew_leeway_seconds above 300 | error: leeway may be “no more than a few minutes” (RFC 9068 §4 step 6) |
[auth.oidc] hmac_secret under 32 bytes | error: RFC 8725 §3.5 forbids memorizable passwords as keyed-MAC keys |
[auth.oidc] with both hmac_secret and jwks_json (or their *_file forms) | error: two competing key sources, never resolved by silent precedence |
[auth.oidc] algorithms naming none, or empty | error: an unsigned token proves nothing |
[auth.oidc] algorithms disagreeing with the key source | error: HS* verifies only against a symmetric secret, RS*/ES*/PS* only against public keys |
[auth.oidc] hmac_secret set at all | boot warning: a symmetric key is a development posture (see below) |
a password_hash below m=19456,t=2,p=1 argon2id | error: the OWASP Argon2id floor |
Successfully verified Basic credentials are cached for
FERROEHR__AUTH__VERIFIED_CACHE_TTL_SECONDS (default 60; 0 disables
the cache) so a busy client pays the deliberately-expensive Argon2
verification once per TTL instead of on every request. The cache stores only
a SHA-256 digest of the presented credential, never a plaintext password,
and an entry exists only after a successful verification; the TTL bounds how
long a revoked credential can still authenticate, exactly like a session
lifetime.
The OIDC settings:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__AUTH__OIDC__ISSUER | — (required to enable OIDC) | expected iss, and the OIDC discovery base; an https URL with no query/fragment |
FERROEHR__AUTH__OIDC__AUDIENCES | — (required, non-empty) | accepted aud values |
FERROEHR__AUTH__OIDC__ALGORITHMS | ["RS256"] | accepted signing algorithms; must match the key source |
FERROEHR__AUTH__OIDC__CLOCK_SKEW_LEEWAY_SECONDS | 60 | leeway on exp/nbf; capped at 300 |
FERROEHR__AUTH__OIDC__REQUIRE_AT_JWT | false | refuse a token that does not claim the RFC 9068 at+jwt access-token profile |
FERROEHR__AUTH__OIDC__ALLOW_INSECURE_ISSUER | false | accept a non-https issuer (development/testing only) |
FERROEHR__AUTH__OIDC__HMAC_SECRET | unset | an HS256 symmetric secret, min. 32 bytes (development/testing) |
FERROEHR__AUTH__OIDC__JWKS_JSON | unset | a static JWKS document |
FERROEHR__AUTH__OIDC__CONNECT_TIMEOUT_MS | 3000 | discovery/JWKS connect budget |
FERROEHR__AUTH__OIDC__REQUEST_TIMEOUT_MS | 5000 | discovery/JWKS request budget |
FERROEHR__AUTH__OIDC__NEGATIVE_CACHE_TTL_SECONDS | 10 | how long a failed key fetch is remembered (0 = off), so an issuer outage does not mean one discovery attempt per request |
There is no separate JWKS or discovery URL to set: the server discovers the
JWKS URI from the issuer’s .well-known/openid-configuration unless you supply
a static JWKS_JSON or an HMAC_SECRET; setting both of those is a boot
error rather than a precedence rule.
REQUIRE_AT_JWT is off by default because RFC 9068 §2.1 makes the at+jwt
type a SHOULD for the authorization server, so requiring it would reject
conforming issuers. A token that does claim the profile is held to the whole
of §2.2 either way: iat, jti and client_id become mandatory for it.
Tip
Keycloak example. Point the issuer at your realm and let discovery do the rest:
export FERROEHR__AUTH__OIDC__ISSUER=https://keycloak.example/realms/ferroehr export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-apiThe 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. Prefer JWKS/discovery over a shared HS256 secret in production. 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 is refused with 403. Two
outcomes are neither: a malformed Authorization header is a 400 (the
server never read a credential), and an unreachable token issuer is a 503
with Retry-After (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. Rendering the rejection
told an unauthenticated caller whether a token was expired or forged, which is
exactly the distinction an attacker probes for; the reason stays in the log,
where the operator can read it and the caller cannot. The
WWW-Authenticate challenge still carries the RFC 6750 §3.1 error code, and a
request that carried no credential at all deliberately gets no code: it has not
made a mistake yet.
Two limits worth planning around
A symmetric hmac_secret is a development posture, not a production one.
The key is shared with the authorization server, so this CDR holds everything
needed to mint the tokens it accepts (an asymmetric key source never gives it
that power) and it cannot be rotated without a restart. The server logs a
warning at boot whenever one is configured. Use the issuer’s OIDC discovery
document (the default when no static key material is set) or jwks_json.
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. Query (AQL) results are not filtered by privacy level, because query
execution carries no per-row principal context; the per-EHR gate still applies
to every query route that binds an ehr_id.
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__USER_ROLE | USER | names the baseline clinical role |
FERROEHR__AUTHZ__RBAC__READONLY_ROLE | READONLY | role marking a principal read-only: refused on every write |
FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS | ["roles","groups","entitlements","realm_access.roles"] | JWT claim paths mined for 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), followed by the widely deployed nested realm_access.roles. A claim path may be dotted, so an issuer that
nests them differently 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. USER_ROLE records what the baseline clinical role is
called rather than being required by the gate: a Basic user with no roles
list gets ["USER"], which satisfies the Clinical class and no Admin
operation. 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 and template, 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.
A request whose patient or template 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 additionally carries the browser set with a real CSP, because
it serves HTML and hydrates WebAssembly. Its Cache-Control has one scoped
exception to no-store: the hydration bundle under /pkg/ is served
public, max-age=31536000, immutable. Those filenames carry a content hash, so
a rebuilt asset is a different URL and a cached copy can never be stale, and the
bundle holds nothing clinical — the viewer reaches the CDR through its own
server functions, never from the browser. Every document still carries
no-store, because documents carry patient data and a per-request CSP nonce.
A /pkg/ response that is not a served body (a 404, a redirect) is never
cached 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.connection]. The limits that apply before a
request exists, because a client that opens a socket and trickles headers reaches
none of the others: an HTTP/1 header-read timeout, and an HTTP/2 concurrent-stream
cap with keep-alive PINGs.
Body size: [server.limits]. Two tiers: the clinical surface, and the routes
that accept bulk by design (template upload, /message/import, /message/tdd).
Over-limit is 413. The defaults are sized against the largest operational
template and example composition in the vendored corpus rather than round
numbers, and a deployment whose compositions embed large DV_MULTIMEDIA data
raises body_bytes deliberately.
Request rate: [server.rate_limit], on by default. The address tier sits
outside authentication so a flood is refused before the server verifies a
signature per request; the principal tier sits inside it, 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 and the x-ratelimit-* headers the limiter
computed.
Concurrency: [server].max_in_flight, the admission cap. Refusal is
503 with Retry-After.
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
| Surface | Default | Notes |
|---|---|---|
/health, /health/liveness | always on, unauthenticated | Deliberate: orchestrator probes must not need credentials. Both answer a plain-text OK with no I/O behind them. |
/health/readiness | always on, unauthenticated | A status per registered component, 200 while the aggregate is up or degraded and 503 when a required component is down. Each component’s detail is a fixed string, never a driver error, a DSN, or a panic payload. Causes are logged for the operator instead. |
/management/* | not mounted at all (management.enabled = false) | With the master switch off, every route is 404. |
/management/{info,metrics,prometheus,env,loggers,flamegraph} | 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. |
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 OIDC symmetric key | auth.oidc.hmac_secret_file |
| a static JWKS document | auth.oidc.jwks_json_file |
| the PGP signing-key passphrase | signing.key_passphrase_file |
| a terminology OAuth2 client secret | terminology.external.oauth2_clients.<name>.client_secret_file |
| the object-store secret key | multimedia.secret_access_key_file |
| the AMQP URLs (events and FHIR outbound) | events.url_file, fhir.outbound.url_file |
| the national-identifier root key | demographic.identifier_protection.key_file |
(The TLS cert_file / key_file / client_ca_file settings 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__AUTH__OIDC__JWKS_JSON_FILE: /run/secrets/oidc_jwks
secrets:
- oidc_jwks
secrets:
oidc_jwks:
file: ./secrets/oidc_jwks.json # 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 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 demographic.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.
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 /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.
- What a release publishes
- A release binary
- Prove your verification can fail
- A container image
- The Helm chart
- Three SBOMs, three questions
- 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
v4.3.0) 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 |
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 rubentalstra/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 rubentalstra/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 rubentalstra/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 rubentalstra/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 rubentalstra/FerroEHR \
--signer-workflow rubentalstra/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
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 rubentalstra/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/rubentalstra/ferroehr:4.3.0 \
-R rubentalstra/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/rubentalstra/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 rubentalstra/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/rubentalstra/ferroehr:4.3.0 \
| awk '/^Digest:/{print $2}')
gh attestation verify "oci://ghcr.io/rubentalstra/ferroehr@${digest}" \
-R rubentalstra/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/rubentalstra/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/rubentalstra/charts/ferroehr:<chart-version> \
--certificate-identity-regexp '^https://github\.com/rubentalstra/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/rubentalstra/charts/ferroehr:<chart-version> \
-R rubentalstra/FerroEHR \
--signer-workflow rubentalstra/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.
Three SBOMs, three questions
A release involves three SBOM documents. They are not redundant: they describe different things for different readers, and both kinds of review genuinely happen for a clinical deployment:
| Document | Where | Format | Answers |
|---|---|---|---|
ferroehr-<tag>-<arch>-unknown-linux-gnu.cdx.json | a release asset, one per architecture | 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 rather than guessed. This is what a vulnerability scanner consumes. |
ferroehr-<tag>.spdx.json | a release asset, one 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 image SBOM | written onto each container 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. |
CycloneDX 1.5 is the highest version the generator emits (cargo-cyclonedx
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 repository SPDX
document is checked at publish time for being real SPDX, naming itself
ferroehr, and listing at least one package: an SBOM that answers no question
is worse than none.
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 eight 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.
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 |
postgres-gosu.openvex.json | Go standard-library findings in the gosu helper the upstream postgres image ships |
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.
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, and a fixable high-or-critical finding both fails that run and files a tracker issue, because a red scheduled run nobody looks at is not a control.
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 the Service Model places
authorization out of band, 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 OAuth2/OIDC bearer tokens validated against the
issuer. Configuration is validated at boot, not on the first request: a
mandatory audience list, an https issuer, an algorithm set bound to its key
source (with none refused outright and two competing key sources refused as a
contradiction), an HMAC entropy floor, 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.
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. 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.
- Only the newest release receives fixes. There is no maintenance branch to backport to; 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 and created_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 |
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 SM’s own 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. The
one crossing, resolve_ehr_for_identity, runs in the application over two
pools, so no single statement performs the join and no credential could issue
one. Each resolve, link, merge and split 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 EHR that came back.
link_as_subject derives the subject pseudonym server-side and writes it onto
EHR_STATUS itself, so no caller-supplied value becomes a subject reference
on that path. A merge or a split closes a period rather than deleting a row,
and the temporal key admits one mapping in force per party. Erasure is the one
exception, 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 crossing exists, and an actor entitled to call it re-identifies a record. That is its purpose. The access log records who did it; recording is not prevention.
- Nothing populates the map. What it holds is what a consumer wrote, so a
deployment that never calls
linkhas no map and no crossing, 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 demographic.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.
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. - The FHIR façade: inbound ingestion and outbound emission of mapped
resources, when
[fhir]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 /rest/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)
- 7. The FHIR façade (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 |
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 ehr schema and its cold archival tier. 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 demographic 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 |
7. The FHIR façade (off by default)
| Art. 30 field | What the software does |
|---|---|
| Purpose | Exchanging mapped resources with FHIR systems, when [fhir] is enabled |
| Categories of data subject | Record subjects |
| Categories of personal data | Whatever the registered mappings project, which is clinical content and therefore Art. 9 data. Outbound emission is PHI-bearing, unlike the change-event envelope |
| Recipients | The configured broker or FHIR peer |
| Third-country transfers | yours |
| Retention | Nothing is stored by the façade beyond the mappings themselves and an outbound cursor |
| Security measures | Mappings are data an administrator registers, so what leaves is what someone configured rather than a default; the façade is a targeted surface with no free-text search and no _include; TLS to the peer |
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 /rest/status):
- shared_credential: ...
The gaps are shared_credential, shared_cluster, open_subject_namespace,
audit_off and migrate_on_runtime_credential. shared_cluster is read from
pg_control_system().system_identifier on each pool rather than from the DSN
text, so two names for one cluster do not pass it.
A gap you have decided to run with goes in deployment_accepts by name. It is
then stated on every boot and on GET /ferroehr/rest/status, so it is run
rather than hidden.
-
deployment_profile = "production"and the server starts. - Every entry in
deployment_acceptsis a decision someone recorded, not a leftover. -
GET /ferroehr/rest/statusshows the profile and an accepted-gap list you recognise.
3. Identifier scanning is on and strict
[privacy.identifier_scan]
mode = "strict"
rules = ["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. Where FerroEHR mints them
itself, link_as_subject uses the first declared namespace.
-
subject_namespacesnames at least one namespace. - The
open_subject_namespacegap no longer appears onGET /ferroehr/rest/status.
5. Backups, one per domain
Backups leave the database with none of the grants attached, so one dump per
cluster undoes the split. Configure one per domain, each with its own
credential and its own volume. On Kubernetes that is the backup values:
backup:
enabled: true
clinical:
existingSecret: ferroehr-backup-clinical
persistentVolumeClaim: ferroehr-backup-clinical
demographic:
existingSecret: ferroehr-backup-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
transport = "tls"
# or, for a RESTful ARR
[audit.fhir_feed]
enabled = true
Decide fail_mode deliberately. open logs a dropped record and serves the
request; closed refuses an auditable operation with 503 when the record
cannot be taken. closed is the posture that makes “no unaudited access” true.
Retention has a jurisdictional floor. Where one of your active identifier rules
names a jurisdiction with a registered floor, a shorter retention_days is a
boot error naming the floor. The Netherlands is the one registered today, at
1830 days, from the
Besluit vaststelling bewaartermijn logging.
A deployment elsewhere sets its own floor by hand and records why.
Verify the chain works before you need it:
SELECT * FROM audit.verify_audit_chain();
-
[audit] enabled = truewith a durable sink. - A copy is forwarded off the box, to a sink the server’s own identity cannot rewrite.
-
fail_modeis a decision, not a default. -
retention_daysis0or at or above the floor that applies to you. -
audit.verify_audit_chain()returns no rows on a healthy trail.
7. Schema preparation runs on its own credential
Preparing the schema spans every schema at once: the DDL of all five migration
sets under migrate = "apply", all five _sqlx_migrations bookkeeping tables
under "verify". No least-privilege runtime role can do either, ferroehr_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 OIDC 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 a bootstrap/dev convenience,
not a user store; production deployments authenticate with OIDC bearer
tokens.
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
Two configuration groups do all the work:
- Token validation (
[auth.oidc]): the server discovers the JWKS from the issuer’s.well-known/openid-configurationand validates each bearer token’s signature,iss,exp/nbf, andaud; the audience list is mandatory, so a server with none refuses to boot rather than accepting another service’s token. See the OIDC settings table. - Role mining (
[authz.rbac]):FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS(default["roles","groups","entitlements","realm_access.roles"], the RFC 9068 §2.2.3.1 carriers, then the Keycloak shape) 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.
Everything below is just those two groups 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__OIDC__ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0 export FERROEHR__AUTH__OIDC__AUDIENCES=api://ferroehr export FERROEHR__AUTHZ__RBAC__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 the operation needs: a clinical call needs at least one role, an admin call needs the admin role 400the Authorizationheader itself is malformed; no credential was ever read503the CDR could not reach your issuer’s JWKS, so the token was never judged: check network egress and the discovery document, 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. Do not reach forALLOW_INSECURE_ISSUERor a sharedHMAC_SECRETto get past them; both are development-only postures.
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__OIDC__ISSUER=https://adfs.example.com/adfs export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-api export FERROEHR__AUTHZ__RBAC__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.oidc] block does double duty: the CDR must be able to validate the tokens
those apps come back with, so SMART cannot be enabled without it, and the issuer
the CDR advertises to apps must be the same one it accepts tokens from; a
mismatch is refused at boot. See SMART App Launch.
SMART App Launch
FerroEHR can act as the resource server in a SMART App Launch setup: a clinical app is launched with an OAuth2/OIDC token from your authorization server (Keycloak or any standards-compliant IdP), and the CDR advertises that server’s endpoints, understands SMART resource scopes in the token, and binds the launch context (the selected patient/EHR) to what the token may touch. FerroEHR never issues tokens, registers clients, or serves the OAuth2 endpoints itself; those remain your authorization server’s job.
Support is off by default. A stock server serves no discovery document and runs no scope gate, so the wire is byte-identical to a non-SMART deployment until you opt in.
- Enabling it
- Configuration keys
- The discovery document
- The scope grammar
- What the gate enforces
- Launch context: binding to one EHR
- How scopes compose with RBAC and ABAC
Enabling it
Enabling SMART is not a single switch. The discovery document you start publishing is read by third-party applications to decide where to send an authorization request and where to exchange a code, so the server refuses to boot on a configuration that would publish an unusable or unsafe one. Six things must be true together:
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
# Where apps obtain tokens. Both required, both absolute https.
export FERROEHR__SMART__ENDPOINTS__AUTHORIZATION_ENDPOINT=https://as.example/authorize
export FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT=https://as.example/token
# What the document claims the authorization server supports.
export FERROEHR__SMART__ENDPOINTS__RESPONSE_TYPES_SUPPORTED='["code"]'
export FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED='["client_secret_basic"]'
export FERROEHR__SMART__ENDPOINTS__CODE_CHALLENGE_METHODS_SUPPORTED='["S256"]'
# And the CDR must be able to validate the tokens apps come back with.
export FERROEHR__AUTH__OIDC__ISSUER=https://as.example/realms/ferroehr
export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-api
The boot rules, and why each one exists:
| Rule | Why |
|---|---|
smart.public_base_url is an absolute http(s) origin | every services.*.baseUrl in the document is an absolute URL, and it cannot be built without knowing this server’s external origin |
authorization_endpoint and token_endpoint are set | an enabled platform without them publishes a document an app cannot act on |
[auth.oidc] is configured | the CDR directs apps to an authorization server, so it must be able to validate the tokens they return; without this every app would obtain a valid token and every request would be refused |
smart.endpoints.issuer, when set, equals auth.oidc.issuer | one says where apps get tokens, the other which tokens this server accepts; a mismatch is silently broken in the most confusing way available |
every advertised endpoint (and public_base_url) is absolute and https | a relative endpoint is unusable and a plaintext one exposes the authorization code and the access token (RFC 6749 §3.1.2.1, RFC 8414 §6.2). allow_insecure_endpoints = true opts a development authorization server out |
response_types_supported is non-empty | RFC 8414 §2 marks it REQUIRED |
token_endpoint_auth_methods_supported names at least one method | an empty list is not silence: it claims the authorization server supports none, and an app that reads it complies |
code_challenge_methods_supported includes S256 | SMART App Launch requires PKCE (RFC 7636), and plain alone is not sufficient |
grant_types_supported names neither implicit nor a password grant | both are deprecated in SMART and must never be advertised |
the advertised issuer carries no query or fragment | the RFC 8414 §2 issuer-identifier rules, the same ones auth.oidc.issuer is held to |
SMART scopes ride only Bearer tokens, so the OIDC bearer requirement above is also what makes the scope gate able to see a scope at all (see Security).
Configuration keys
The full key set lives in the [smart] section of ferroehr.toml; each key
can be overridden with the shown FERROEHR__SMART__* environment variable (__
separates nested fields).
| Key | Default | Meaning |
|---|---|---|
FERROEHR__SMART__ENABLED | false | Master switch. Off = no discovery document (404) and an inert scope gate. |
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 (the configured base path without its /openehr/v1 tail, i.e. /ferroehr/rest). A leading path is honoured (/gateway/v1 → /gateway/v1/.well-known/smart-configuration). |
FERROEHR__SMART__EHR_ID_CLAIM | ehrId | Token claim carrying the launch context’s openEHR EHR id. |
FERROEHR__SMART__PATIENT_CLAIM | patient | Fallback launch-context claim when the EHR-id claim is absent. |
FERROEHR__SMART__REQUIRE_SMART_SCOPES | false | Fail-closed switch; see Advisory vs required below. |
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 + accept episode launch context (experimental; advisory only, no episode filtering). |
FERROEHR__SMART__ENDPOINTS__ISSUER | unset | Advertised token issuer. Unset = falls back to the configured OIDC bearer issuer; set, it must equal it. |
FERROEHR__SMART__ENDPOINTS__JWKS_URI | unset | Advertised jwks_uri. |
FERROEHR__SMART__ENDPOINTS__AUTHORIZATION_ENDPOINT | unset (required when enabled) | Advertised OAuth2 authorization endpoint. |
FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT | unset (required when enabled) | Advertised OAuth2 token endpoint. |
FERROEHR__SMART__ENDPOINTS__REGISTRATION_ENDPOINT | unset | Advertised dynamic-client registration endpoint. |
FERROEHR__SMART__ENDPOINTS__INTROSPECTION_ENDPOINT | unset | Advertised token introspection endpoint. |
FERROEHR__SMART__ENDPOINTS__REVOCATION_ENDPOINT | unset | Advertised token revocation endpoint. |
FERROEHR__SMART__ENDPOINTS__MANAGEMENT_ENDPOINT | unset | Advertised user management endpoint. |
FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED | [] (must be non-empty when enabled) | Advertised client auth methods (e.g. client_secret_basic, private_key_jwt). |
FERROEHR__SMART__ENDPOINTS__GRANT_TYPES_SUPPORTED | [] | Advertised grant types. implicit and the password grant are rejected at boot. |
FERROEHR__SMART__ENDPOINTS__RESPONSE_TYPES_SUPPORTED | [] (must be non-empty when enabled) | Advertised response types (e.g. code). |
FERROEHR__SMART__ENDPOINTS__CODE_CHALLENGE_METHODS_SUPPORTED | [] (must include S256 when enabled) | Advertised PKCE methods. |
FERROEHR__SMART__ENDPOINTS__SCOPES_SUPPORTED | [] | Advertised scopes. Empty = a default list reflecting what the CDR enforces; set = emitted verbatim. |
FERROEHR__SMART__ENDPOINTS__CAPABILITIES | [] | Extra HL7-defined base capabilities to advertise (e.g. launch-ehr, sso-openid-connect), the ones your external framework owns. Appended to the CDR’s own, deduplicated. |
FERROEHR__SMART__ENDPOINTS__ALLOW_INSECURE_ENDPOINTS | false | Accept a non-https advertised endpoint. Development/testing only. |
Warning
Everything under
[smart.endpoints]is published to third-party apps, so an empty list is a claim, not a silence: it says the authorization server supports none of that thing, and a compliant app will believe it. Advertise only what your authorization server really offers.
The discovery document
When enabled, the server serves the standard SMART configuration document, unauthenticated, at:
GET /ferroehr/rest/.well-known/smart-configuration
(relative to the REST root, or to PLATFORM_BASE_URL when set). A launching app
reads it to find your authorization server. It looks like:
{
"issuer": "https://as.example/realms/ferroehr",
"authorization_endpoint": "https://as.example/authorize",
"token_endpoint": "https://as.example/token",
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"capabilities": ["context-openehr-ehr"],
"scopes_supported": [
"openid", "profile", "offline_access",
"launch", "launch/patient",
"patient/composition-*.cruds", "patient/aql-*.rs",
"user/composition-*.cruds", "user/template-*.cruds", "user/aql-*.cruds",
"system/composition-*.cruds", "system/aql-*.cruds"
],
"services": {
"org.openehr.rest": {
"baseUrl": "https://cdr.example.com/ferroehr/rest/openehr/v1",
"description": "The openEHR REST API baseUrl"
}
}
}
servicesis an object keyed by service type. It always names the openEHR REST service, and adds the FHIR façade (org.fhir.rest) when the FHIR routes are enabled. EachbaseUrlis absolute, built by prefixing the CDR’s own base path withPUBLIC_BASE_URL, which is why that key is required.capabilitiesalways containscontext-openehr-ehr(the CDR binds theehrIdlaunch context).openehr-permission-v1is advertised only in fail-closed mode (REQUIRE_SMART_SCOPES=true): the capability announces fine-grained scope enforcement over openEHR resources, and advisory mode does not enforce against a scope-less caller, so advertising it there would over-claim.context-openehr-episodeandlaunch-base64-jsonappear when their switches are on, and your ownCAPABILITIESentries are appended.scopes_supportedis the default list above unless you configure your own, which is then emitted verbatim. Enabling episode context addslaunch/episodeto the default list.- Every unset optional endpoint is simply omitted from the document rather than emitted as null.
With SMART disabled the path is not mounted at all (404).
The scope grammar
SMART 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 (order-free, e.g..crud,.rs).
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 |
|---|---|
patient/composition-*.crud | Create, read, update, and delete any composition, but only in the launched patient’s EHR. |
patient/aql-*.rs | Read and execute any stored query. |
patient/composition-MyHospital::Template.v0.r | Read compositions of exactly that template, in the launched patient’s EHR. |
user/composition-MyHospital::*.r | Read compositions of any template in the MyHospital namespace (not sub-namespaces). |
user/template-*.cruds | Full access to template definitions. |
system/aql-org.openehr::bloodpressure.v1.rs | A backend service may read and execute that one stored query. |
Scopes the server does not recognise are retained but inert: never granted,
never fatal; the identity scopes (openid, profile, offline_access, …) and
the launch/launch/patient context scopes pass through untouched.
What the gate enforces
All three resource families are enforced. An operation is mapped to its family and to the CRUDS permission it exercises, and at least one of the caller’s scopes for that family must permit it:
| Family | Operations | Resource id taken from |
|---|---|---|
composition-… | composition and versioned-composition reads and writes | the resolved template id |
template-… | operational-template operations | the {template_id} path parameter |
aql-… | stored-query execution and AQL definition management | the {qualified_query_name} path parameter |
EHR, EHR_STATUS, CONTRIBUTION and DIRECTORY operations have no SMART
resource type (the specification defines exactly three) so the SMART gate
does not deny them; they stay governed by the RBAC/ABAC layers and the
per-EHR EHR_ACCESS gate.
Note
An unresolved resource id matches only a broad wildcard. A template upload or list, and an ad-hoc (non-stored) query, carry no id, so a scope naming a specific id cannot match one, which is refused rather than assumed. Grant
*/**scopes for those operations deliberately.
Launch context: binding to one EHR
When an app is launched for a patient, your authorization server puts the
resolved openEHR EHR id in the token: by default in an ehrId claim, with
the standard SMART patient claim as fallback (both claim names are
configurable). A composition operation permitted only by a patient/…
scope is then bound to that one EHR: a request against any other EHR is
refused, and a token holding only patient-compartment scopes but no
launch-context claim is refused outright. user/ and system/ scopes carry no
such binding.
Template and AQL operations are not per-EHR resources (templates are unscoped
and queries are cross-EHR) so a patient/ scope permits them without a
per-request EHR binding. Per-row patient scoping for queries is the ABAC
subject-scope layer’s job, not the scope gate’s.
How scopes compose with RBAC and ABAC
The SMART gate is one more layer in the authorization chain, evaluated
after authentication, the per-EHR EHR_ACCESS gate, RBAC, and ABAC (see
Security). Every active layer
must allow the request; SMART never overrides a denial from another layer, and
it can only narrow. A scope denial is a 403 Forbidden.
Granted scopes are also visible to the ABAC layer as a subject attribute, so a Cedar or external policy can reason about them; the built-in gate is the floor, not the ceiling.
Advisory vs required
- Advisory (default,
REQUIRE_SMART_SCOPES=false): the gate enforces only when the token actually carries SMART resource scopes for the resource family in question. A non-SMART token, a Basic-auth caller (which has no scopes), and a server with authentication disabled are all unaffected. Once a token does carry, say, composition scopes, at least one of them must match the operation or the request is refused. - Required (
REQUIRE_SMART_SCOPES=true): fail-closed. A caller with no matching SMART resource scope for a scope-governed operation is denied, including a caller with no token at all. Use this where every app is a SMART app. This is also the mode in whichopenehr-permission-v1is advertised.
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 openEHR itself points at (the platform Service Model names the System Log component “IHE ATNA-compliant system log”). 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)
- 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:
- FHIR R4
AuditEventfollowing the IHE BALP (Basic Audit Log Patterns) content profiles: the modern RESTful-ATNA form and the canonical stored form. Patient-centric operations carry the resolved EHR subject as the patient entity (PatientRead/PatientCreate/… profile claims); query executions carry the search expression; Bearer-authenticated requests record the token’sjti(and never the token itself) perOAUTHaccessTokenUse.Minimal. - DICOM Audit Message (DICOM PS3.15 §A.5 XML): the classic ATNA form,
shipped over syslog per IHE ITI-20 when the syslog sink is enabled.
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 Login
EventTypeCode110122).
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. |
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 |
| 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 ITS-REST operation id |
| 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 source participant |
| On whose authority, and why | purpose and legal_basis |
| Which system, and from where | AuditSourceID and the source participant’s network address (client_ip) |
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) | a second agent with who referencing Organization/… | Recorded |
| (b) the specific natural person or persons having accessed | principal (+ token_id, client_ip) | ActiveParticipant/@UserID | agent.who | Recorded |
| (c) the categories of data accessed | resource_class + domain (+ result_count) | ParticipantObjectIdentification type and role codes | entity.type / entity.role | Partial |
| (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 per origin, named “origin of the served data” | 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 a second agent referencing 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 (c) is partial for a vocabulary reason rather than a plumbing one. The record says what kind of object was read and which pseudonymisation domain it came from, so “was this clinical content or an identity” is answerable per event. What it does not say is which Annex I priority category the data falls in; openEHR resource classes are a different vocabulary, and the mapping between them is not one this project should invent.
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.
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 reaps hourly
everything older. 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 rather than a technical one: EHDS sets no retention period for the access log, while national law does — 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 retention_days, naming the configured value
and the floor. 0 always passes. The floors registered:
| Jurisdiction | Floor | Source |
|---|---|---|
| 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 |
The other shipped jurisdictions (DE, FI, GB, NO, SE) have no floor registered: an
unknown requirement is never guessed at, and a deployment there sets its own.
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, 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 over syslog (RFC 5424; UDP or TLS transport) 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. |
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.
Three 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, records deleted with no retention record for the removal, or records removed from the end of the chain where no successor would have noticed. Run it on a schedule and alert on any output.
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 served at the FHIR façade:
GET /ferroehr/rest/openehr/v1/fhir/r4/AuditEvent
It returns a FHIR searchset Bundle of the stored AuditEvent documents,
newest first, with the full match total. Supported search parameters:
| Parameter | Meaning |
|---|---|
date | event-time bound, ge/le-prefixed RFC 3339 instants, repeatable |
patient | the recorded patient (EHR subject) id |
agent | the authenticated principal |
entity | the touched resource id |
outcome | the outcome indicator: 0, 4, 8 or 12 |
action | the action code: C, R, U, D or E |
_count / _offset | paging; page size defaults to 50, capped at 1000 |
Other FHIR search parameters are ignored (lenient search); a malformed value on
a supported one is a 400 carrying a FHIR OperationOutcome. Under RBAC the
unscoped retrieval is admin-only; a caller holding the configured
authz.rbac.subject_audit_role reads it scoped to one subject, patient
required, and 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. 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/openehr/v1/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]
enabled = true
cert_file = "/etc/ferroehr/server.pem"
key_file = "/etc/ferroehr/server.key"
min_version = "1.3" # 1.3 (default) | 1.2
client_auth = "required" # off | optional | required
client_ca_file = "/etc/ferroehr/client-ca.pem"
The protocol floor is TLS 1.3 only by default, following the OWASP
Transport Layer Security cheat sheet. 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 not selectable at all.
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 keep [server.tls] off and enforce mTLS there instead.
Warning
The separate-port management listener (
management.port) stays plain HTTP even with[server.tls]enabled, and it binds all interfaces. Treat it as an internal surface and keep it off any publicly routed port.
Complete the posture with time synchronisation (IHE Consistent Time): run NTP/chrony on every node so audit timestamps align across systems.
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 / transport / 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 {base}/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 by capability. If the certificate
carries a subkey flagged for data signing
(RFC 9580 §5.2.3.29,
key flag 0x02), the server signs with that subkey; otherwise it signs with
the primary key. Position in the file decides nothing, so an encryption
subkey is never used to sign.
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. Ed25519 or ECDSA 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. The server picks the signing-capable subkey, the certificate retains the previous one, and verification tries the primary key and every subkey. 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.
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 (Leptos SSR + WebAssembly); there is no hand-written
JavaScript anywhere, including its browser tests.
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/rubentalstra/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. Sign-in is served fully rendered and works
with JavaScript disabled.
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 viewer ships a full dark theme (the toggle persists per browser), and every screen in this chapter has one, and Dark mode is the gallery. The user menu opens the access drawer:
The access drawer
“View scopes” answers what may this session do, and who says so:
- the authenticated principal and how it signs 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 rendered as its parsed grant: the
compartment it delegates to (
patient/user/system), the resource family and id pattern it reaches, and the create/read/update/delete/search operations it permits, with a broad access marker on a bare*; - a previewer: paste any scope string, or a whole space-separated claim, and read the same rendering. A scope shaped like a resource scope but malformed explains what the grammar expected instead of quietly reading as nothing.
The reading is not the viewer’s own interpretation: it parses with the same module the CDR’s SMART scope gate enforces with, so the two can never drift. Scopes narrow access and never grant it; the CDR remains the enforcer, and a previewed grant is an upper bound.
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). The viewer notices on its own. Every call it makes to its backend is
checked for a signed-out answer, and the shell re-checks the session on its
own schedule as well, so a session that ends while a screen is open is
detected within seconds even if you touch nothing. The authenticated screens
then unmount and you land on the sign-in page with a notice that the session
ended. Signing in again, with Basic or with OIDC, returns you to the page you
were on. There is no in-between state where the header reports the CDR as
offline while the rest of the screen still accepts input.
The idle window slides on any authenticated request, and the shell’s own status poll is one, so a tab you leave open stays signed in while it is open. Closing the browser ends the session.
The deployment profile
The header’s status chip names the profile the connected CDR declares
(deployment_profile in its configuration, reported on GET /rest/status):
CDR UP · v<server version> · production, or · sandbox. A production deployment says
so quietly and nothing else changes. A sandbox deployment raises a persistent
notice under the header on every authenticated screen: this deployment has
not made the production separations and must not hold real patient data,
followed by the 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). The quickstart and the
composed stacks run as sandbox, so the notice is what you see there; 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, searched in the working directory
and /etc/ferroehr/viewer.toml, or pointed at with
FERROEHR_VIEWER_CONFIG), with FERROEHR_VIEWER__<SECTION>__<KEY> environment
overrides. Unknown keys are refused at startup, exactly as on the CDR:
| Key | Default | Meaning |
|---|---|---|
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 server.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.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 the Operations panel. |
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.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 (sliding; 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. |
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 backend; the browser stores only the encrypted session cookie — CDR credentials and bearer tokens never reach it in readable form.
The screens
- Dashboard: record counts, per-namespace stored-query match tiles, and a commit-activity trend. See Dashboard & queries.
- Templates: upload and inspect operational templates. See Templates & EHR browsing.
- Queries: the point-and-click Query Builder, the raw AQL editor, and stored-query management. See Dashboard & queries.
- EHRs: browse EHRs, folders, compositions, version history, and the item tags on any of them. See Templates & EHR browsing.
- Demographics: browse and edit the five demographic party kinds, their relationships, version history, and tags. See Demographics.
- FHIR: the connector’s mapping-store editor, a read-path viewer, and a validate-only dry-run panel; appears only when the CDR’s FHIR API is enabled. See FHIR connector admin.
- Terminology: browse the terminologies the CDR serves, define a code, expand a value set, and test membership or subsumption. See Terminology.
- Audit log: browse the CDR’s ATNA security audit trail (see below).
- System: CDR status, the openEHR conformance manifest (what the server advertises about itself through the System API: product, vendor, claimed conformance profile, and the API groups it actually mounts), SMART discovery, repository usage, the server’s own OpenAPI documents. Pick the complete surface or one API family, and the choice stays in the URL, so the redacted runtime configuration, and a shortcut into the audit browser.
- Operations: dependency health, build and spec provenance, the metric registry, and runtime log control. Appears only when the CDR serves its management surface. See Operations panel.
- Subscriptions: the event subscriptions that decide which committed versions the CDR publishes to a message broker. Appears only when the CDR serves its subscription API. See Event subscriptions.
Every one of them is themed twice; Dark mode shows the dark half of the viewer screen by screen.
Paging
Every listing is paged, and the page lives in the URL: a page is
shareable and bookmarkable, a reload lands on the same rows, and the browser’s
back and forward buttons walk the pages. The tables the viewer holds in full
(Templates, Queries) share one footer under the table: which rows are on
screen out of how many (26–50 of 137 templates), previous/next, and a
rows-per-page choice of 25/50/100 (?page= and ?size=). The AQL-backed
listings (EHRs, an EHR’s compositions) page through ?offset= links, and the
audit browser through ?page= beside its filters. Every one of these controls
is a plain link, so paging works before the page’s WebAssembly loads, and a
page link carries the screen’s other parameters (the tab you are on, the
filters you set) across with it.
Audit log
The Audit log screen browses the CDR’s security audit trail (who
accessed what, with what outcome) through the standard IHE ITI-81
retrieval (GET /fhir/r4/AuditEvent; see the
Audit trail chapter). Filter by event-time window, patient,
principal, outcome, or action; every filter lives in the URL, so a filtered
view is shareable and refresh-safe. Each row opens the full stored FHIR R4
AuditEvent record.
The audit trail is an operator surface: under role-based access control the screen requires the CDR’s admin role, and when the CDR’s local audit store is disabled the screen says so instead of erroring.
Each row’s view disclosure opens the full stored AuditEvent record:
exactly what the ITI-81 API serves:
A filter that matches nothing renders a distinct empty state, so “no records” is always visibly different from “records you haven’t found”:
Dashboard & queries
The viewer’s query surfaces are three views of the same thing: the dashboard summarises what the repository holds, the Query Builder assembles AQL from a template’s own paths, and the raw editor runs AQL you wrote yourself. All three save to, and run against, the CDR’s own stored-query registry; nothing is kept on the viewer’s side.
Dashboard
The landing screen shows EHR / composition / template / stored-query counts, one tile per stored-query namespace (the summed match counts of the queries in it), and a commit-activity trend rendered as pure SVG.
The namespace tiles are live: each one runs every stored query in that namespace as a count. If one of them fails, the tile reports no number rather than a misleading total. Run that query on the Queries screen to read the CDR’s own diagnostic.
The Query Builder
Build AQL without writing it: pick a template, pick paths from its tree, and add typed conditions: each data type gets the right widget, populated from the template’s own constraints (coded value sets, ordinal scales, quantity units). Conditions combine into arbitrarily nested ALL/ANY groups with per-condition and per-group negation. The generated AQL is previewed live and is always grammatically valid, since the builder assembles the same query syntax tree the server parses, then prints it, so it never hand-builds query text.
A coded condition can go further than the template’s own list. Its
terminology field is backed by the terminologies the CDR serves, offered as
suggestions while staying free text: an AQL author must be able to name a
terminology this server does not host. Beside the code box, look up asks the
CDR what the code means and adds it as code — text; a code the terminology
does not define is still added, marked unvalidated, because the builder never
refuses a code the CDR happens not to know. Expand a value set and its
members become one-click chips. Typing a code and pressing add works exactly
as it always has, and whichever route a code took, the query carries the bare
code, and the rubric is only how it reads on screen. When the CDR does not serve
its terminology surface the field is simply a plain text box.
Choose what comes back: whole compositions, projected data points (with column aliases), a bare match count, or the distinct EHR ids matching the criteria tree (EHRs (cohort)). That shape is a plain AQL projection over the clinical record and is unrelated to the server’s cross-domain cohort query, which the viewer does not drive. Run pages through the result set; save the query to the CDR’s stored-query registry under a namespace and a name (see Grouping is the namespace).
The raw AQL editor
The same run/save surface for hand-written AQL: grammar validation before anything reaches the CDR, JSON parameter bindings, paged results. The builder’s “open in raw editor” hands its generated query across. When the editor was opened from a stored query, it also links back the other way, with Open in builder and Run with parameters, so the three surfaces are reachable from each other for the same stored definition.
Every non-empty result set offers a Table | Chart toggle. The chart draws one line per numeric column, named by that column’s alias, and its legend switches a series on and off; the last visible series stays on, so the chart never empties. When a column holds ISO-8601 date/times it is offered as the X axis and used by default: a real time scale, where points sit at their true distance apart whatever order the rows came back in. The row order stays available as the fallback axis, and a single numeric column still draws as one plain line. A result set with nothing to chart (no numeric column, or a single row) says so in the chart pane rather than showing a blank box.
Exporting results
Both results panes (the builder and the raw editor) offer Export CSV
and Export JSON, a plain form download that works even before the
page’s WebAssembly loads. The export runs the query’s own LIMIT window,
or the server’s default fetch limit when the query has none. CSV cells
hold scalar values verbatim; structured values are embedded as compact
JSON.
Stored queries & namespaces
Fresh repositories start empty, with the action that fills the screen:
List the CDR’s stored queries, inspect a query’s AQL, and jump into the editor to run it.
The table is paged by the shared footer under it: rows on screen out of how many, previous/next, and 25/50/100 rows per page, all in the URL (see Paging). The namespace panel beside it is derived from the same listing and shows every namespace, whichever page you are on.
Each stored query row offers three hand-offs:
| Action | What it opens |
|---|---|
| Run | The stored-query runner, which executes that stored query on the CDR, with its parameters prompted |
| Open in editor | The raw AQL editor, seeded with that version’s query text |
| Open in builder | The query builder, with the query loaded back into its controls, when it fits (see Opening a stored query in the builder) |
Both editing hand-offs pre-fill the namespace, name, and version fields, with the version set to the next one, so saving again publishes a new version instead of colliding with the one you opened (see Versions).
Opening a stored query in the builder
The builder writes one shape of AQL, and it will only load a stored query back
into its controls when it can reproduce that query byte for byte; it
re-lowers what it recognised and compares the result against the stored text.
Anything else opens with a notice naming exactly what the builder cannot
express (a $parameter it has no field for, a query over other RM classes, an
aggregate outside its output shapes) beside a link to work on it in the raw
editor instead. There is no partial load: a builder that showed most of a
stored query would quietly rewrite it on the next save.
A query that does load arrives complete (template, conditions including nested ALL/ANY groups and negation, output shape, ordering, limit) with its condition labels and value lists taken from the template’s own constraints, and the next version proposed in the save field.
Running a stored query
Run on a stored-query row opens the runner for that query. It shows the stored AQL, prompts one field per parameter the query declares, and executes the stored definition on the CDR, not a copy of its text, so what runs is what every other openEHR client would get.
Values are read as JSON first: 38.5 is sent as a number and true as a
boolean, while at0037, 2026-07-01 and 1.0.0 are sent as text without any
quoting. To force a numeric-looking value to stay text, quote it: "0123". A
field left blank is not sent at all, so the CDR can apply its own default or say
what is missing.
Results render in the same results pane as the other query screens: table or
chart, with previous/next paging. A query that sets its own row window (an AQL
LIMIT or TOP) is run as stored and the pane says so rather than paging it,
because openEHR does not allow a request window and a query window together.
Choosing how the version resolves
openEHR defines three ways to name the version of a stored query you are reading, and the runner offers all three. The line under the picker always states the exact request your choice sends:
| Resolution | Request | Which version runs |
|---|---|---|
| Latest version | POST /query/{name} | the latest version of that query |
| Version prefix | POST /query/{name}/1 or …/1.2 | the latest version matching the prefix |
| Exact version | POST /query/{name}/1.2.0 | exactly that version |
A version that does not fit the chosen form (a full 1.2.0 typed as a prefix, or
a bare 1 typed as exact) is refused with an explanation before anything is
sent.
Grouping is the namespace
A stored query is identified by a qualified name, namespace::name, the
namespace optional and, when present, a reverse domain name whose purpose in
the openEHR REST specification is exactly “separation of use of stored
queries by teams, companies, etc.”
The viewer therefore does not invent a grouping of its own: a query’s
group is its namespace, chosen when you save it. The right-hand panel on
Queries and the cohort tiles on the Dashboard are both derived live
from GET /definition/query. There is nothing to create, edit, or remove:
and nothing kept on the viewer’s disk, so the grouping is durable in the
CDR and reads identically for every openEHR client and every viewer
replica. Queries saved without a namespace collect under unqualified.
Both save surfaces (the builder and the raw editor) therefore offer the
Namespace field beside the Query name, and show the exact qualified
name the save writes. Typing the whole namespace::name into the name
field works too.
Versions
A stored query is identified by its qualified name and a version, and the
version is SEMVER-style, major.minor.patch. The save surfaces expose it as
an optional Version field, and the line under the fields always states
which of the two openEHR store operations a click will perform:
| Version field | What a save does |
|---|---|
| empty | PUT /definition/query/{name}; the CDR files it at the default slot 1.0.0 and replaces whatever was stored there |
1.2.0 | PUT /definition/query/{name}/{version}; stores a new, immutable version; if that exact (name, version) pair already exists the CDR refuses it (409) and the viewer says so |
Because an explicit version is immutable, Open in editor and Open in
builder both propose the next minor version (opening 1.0.0 fills the field
with 1.1.0): edit, save, and both versions are then listed side by side.
Which part to bump is yours to change; the field is free text and only checks
that a version you type is a complete triple.
A shorter pattern like 1 or 1.0 is a read form, not a store form: when
fetching or executing a stored query, openEHR resolves a partial version to
the latest one matching that prefix, and omitting the version entirely means
the latest of all. The viewer therefore refuses a partial version in the save
field (with an explanation) rather than filing a definition under a string that
later lookups would treat as a pattern. The CDR refuses one too, with a 400:
a prefix names no version a store could create, and openEHR assigns the write
no other outcome.
The default slot 1.0.0 is the CDR’s own choice; openEHR does not say which
version a version-less store mints, so the version-less form always writes and
reports that one slot, even when higher versions of the same name already
exist. If you need a specific version, type it.
Deleting a stored query
Delete from CDR (on a stored-query row) deletes that version of the
query from the CDR’s stored-query store, for every client. The only
destructive action on the screen. It appears only when the CDR’s admin API is
enabled (admin.enabled / FERROEHR__ADMIN__ENABLED, off by default; see
[admin]); the delete itself
additionally needs the ADMIN role, and a session without it is refused with
a message naming what is missing.
It opens a confirmation dialog that names the exact query and version before anything is sent, and a refused delete is reported with the CDR’s own diagnostic and the next action to take. Deleting the last query of a namespace simply makes that namespace stop appearing.
Templates & EHR browsing
These are the viewer’s read-and-write screens over clinical content: the templates the CDR validates against, and the EHRs, folders, compositions and contributions committed under them. Every screen here is a view of the CDR’s public API, so anything you change is a normal openEHR write that every other client sees.
Template Manager
Upload operational templates (the CDR’s validation diagnostics surface
verbatim on rejection) and browse what is installed. The screen serves both
archetype-model families, switched by the ADL 1.4 / ADL 2 pills under
the title. The choice is in the URL (/templates is the ADL 1.4 listing and
/templates?family=adl2 the ADL 2 one) so either is a shareable link, and
the filter and the paging footer work the same in both.
Both families upload the same way: the button at the top right of the screen (Upload OPT or Upload ADL2, depending on the family you are looking at) opens a dialog where you either choose a file or paste the source. Both inputs feed one editor, so a chosen file can be read over, corrected, and sent from there. Upload template stays disabled until there is something to send, a refusal keeps the dialog open with the server’s diagnostic beside the source it rejected, and a successful upload closes the dialog and refreshes the list. Each time you open the dialog it starts empty, so a refused source is yours to correct while the dialog is up, and closing it discards the attempt.
The template detail screen shows the path catalog (the template’s tree with each node’s archetype path, RM type, and constrained value sets) plus the raw OPT XML and a CDR-generated example composition in any supported format.
The list filters as you type, matching a template’s id, its concept, or its root archetype id, and is paged by the shared footer under the table; rows on screen out of how many, previous/next, and 25/50/100 rows per page, all in the URL (see Paging). The filter narrows the rows; the footer counts what the filter left.
The list also shows each template’s root archetype id, and the detail screen opens with an identity card (concept, version, default language, languages, and the template UID) read from the operational template itself.
Deleting a template
When the CDR’s admin API is enabled, each list row and the detail screen offer Delete. It opens a confirmation dialog naming that template, and nothing is sent until you confirm there. The CDR refuses a template that a committed composition still uses; the refusal is shown with the referencing count, so delete or migrate those compositions first, and it likewise refuses a session without the ADMIN role, naming what is missing. If the admin API is off, no delete button is shown at all: the viewer asks the server which API groups it serves (the openEHR System API conformance manifest) before offering any of them.
Warning
This is a physical delete of the template registration, not a versioned one. The server-side switch is
admin.enabled(FERROEHR__ADMIN__ENABLED), off by default; see[admin].
ADL 2 templates
The ADL 2 family lists the operational templates the CDR compiled from
ADL 2 sources. An ADL 2 artefact is identified by its archetype HRID
(openEHR-EHR-COMPOSITION.vitals.v1.0.0) whose trailing .v1.0.0 is the
artefact’s own release version, so several versions of one template appear as
separate rows and the list shows all of them.
Uploading works exactly as it does for ADL 1.4, through the same dialog; what
differs is the artefact. The CDR ingests the ADL 2 source as plain text
rather than an XML document, so the dialog accepts .adls files and the
source you paste is sent verbatim. If the openEHR-ADL engine refuses it, its
diagnostics (the AOM 2 rule codes with their line and column) appear in full
under the editor as well as in the failure notification, so the source can be
corrected in place and re-sent.
Opening a row shows the artefact’s three server-side representations plus a derived view:
- Source: the stored ADL 2 text, exactly as the CDR holds it.
- AOM2 JSON: the same operational template as canonical JSON
(
OPERATIONAL_TEMPLATE), which is where the constraint structure, node ids and occurrences are readable. - Example: a composition the CDR generates from the template, in canonical JSON, canonical XML, FLAT or STRUCTURED.
- Path catalog: the same expandable tree and node inspector the ADL 1.4 detail shows, built by the viewer from the AOM2 JSON.
The Version bar above the panes pins the Source and AOM2 JSON reads to a
particular release version. The chips are the versions the CDR actually holds
for that HRID family, and As stored returns to the artefact the link named.
The box beside them also accepts a prefix (1 or 1.0) which the CDR
resolves to the highest matching version, so 1 on a family holding 1.0.0 and
1.1.0 shows 1.1.0. Whichever you pick lands in the URL as ?version=, so a
pinned view is shareable. The example is generated from the artefact the link
named and does not follow the version bar: the CDR publishes no versioned
example resource.
Note
The ADL 2 path catalog is built by the viewer itself: the CDR serves no Web Template representation of an ADL 2 artefact, so the viewer reads the AOM2 JSON and derives the same tree the ADL 1.4 detail shows. The CDR wire stays exactly the released REST API.
Deleting an ADL 2 template
ADL 2 rows carry the same Delete affordance the ADL 1.4 rows do, and it behaves the same way: a confirmation dialog naming the artefact, nothing sent until you confirm, the refusal shown with its referencing count when a committed composition still uses the template, and no button at all when the CDR’s admin API is off.
What differs is the resource underneath. An ADL 1.4 delete removes the template registration from the Admin API’s template store; an ADL 2 delete removes the whole artefact (archetype, template or OPT) from the definition store, which keeps no version history of it, so the deleted release version is simply gone. Other versions of the same HRID family are untouched: each is its own artefact, deleted from its own row. The route is admin-gated like the rest, so a session without the ADMIN role is refused with a message naming what is missing.
EHR browser
Find an EHR by id (or browse the most recent), then work through its tabs:
EHR status, the status version history, the folder directory, the composition
list, contribution lookup, and the EHR’s item tags. Find-by-id is a plain
form: it works in a browser with JavaScript disabled, and
/ehrs?find=<ehr_id> is a shareable shortcut straight to an EHR.
The EHR detail screen opens with a summary header. Its top line answers “whose record is this, and what may be done with it”: the subject, the external id and namespace the EHR status references, or an explicit “self — no external subject reference” when the EHR is bound to no outside identity, next to the queryable and modifiable badges. Below it are the EHR resource’s own facts: its id, the system that created it, when it was created, and the reference to its current EHR status. A mistyped or unknown id is reported there, once, instead of once per tab.
The identity line and the Status tab read the same EHR status document, so they can never disagree: saving a status change updates both at once.
Filtering an EHR’s compositions
The compositions tab lists the EHR’s compositions newest first, with their template, context start time and composer, and narrows on four filters:
- Template: matches anywhere in the composition’s template id.
- From / To: bound the composition’s context start time. Each is a date and covers its whole UTC day, so a From and To of the same day keeps everything recorded during it.
- Composer: matches anywhere in the composer’s name.
Every filter lives in the URL (?template=, ?from=, ?to=, ?composer=),
so a filtered view is a link you can share or bookmark, a reload keeps it, and
the browser’s back button walks the filters you tried. Applying a filter starts
again at the first page. Leave them all empty for the plain list.
The filtering happens in the CDR, as one AQL query: what you type is bound as a query parameter, never pasted into the query text, so an id containing quotes, wildcards or anything else is matched literally. An empty result says whether the EHR holds no compositions at all or simply none that match.
Clicking a composition opens it in the viewer’s Rendered clinical reading (below) rather than the raw document; the other views are one click away.
Deleting an EHR
With the CDR’s admin API enabled, the EHR detail screen offers Delete EHR above the tabs. The confirmation dialog spells out the EHR id and what goes with it: this is the CDR’s physical delete, so every composition, contribution and audit record under the EHR is removed, and it cannot be undone. On success the viewer returns to the EHR list; a session without the ADMIN role is refused with a message naming what is missing. Without the admin API the button is not rendered at all.
Warning
Use this for test data. It is not the openEHR logical delete: nothing stays readable afterwards.
Creating EHRs and committing compositions
The EHRs screen can create an EHR (empty, or bound to an external subject: id + namespace) and find an existing one by subject id as well as by EHR id. The create card also takes an optional EHR id: leave it blank and the CDR mints one, or supply a UUID to create that exact EHR. A value that is not a UUID is refused before anything is sent (openEHR strongly recommends a UUID for a client-supplied EHR id), and an id that is already in use comes back as the CDR’s own conflict; nothing is silently overwritten.
The EHR detail screen’s compositions tab includes a
Commit composition form: paste a canonical JSON, canonical XML, or
FLAT document (FLAT requires the template id, sent as the
openehr-template-id header) and the CDR’s validation diagnostics are
shown verbatim on rejection.
The contributions tab opens with a contribution activity timeline: writes to this EHR per calendar day, bucketed from a wider window of the same contribution data the paged list below it shows.
Committing several changes at once
Each form above commits one thing. When changes belong together (a new composition and the EHR status that goes with it) the Commit tab commits them as one openEHR contribution: an atomic change set. Every staged change is committed together, or none of them is. Nothing is written halfway.
Build the change set one entry at a time. Each entry is one of three things:
- Composition — create: pick the template and paste the canonical JSON document. This commits a brand-new composition.
- Composition — amend: pick one of the EHR’s existing compositions. Its current version is loaded into the editor for you, and the change carries that version as the one it supersedes, so a concurrent write is refused rather than overwritten.
- EHR status — modify: the EHR’s current status is loaded the same way; edit it and it commits as a new status version.
The change type offered for each entry is exactly what the openEHR wire accepts for it: a creation for a new composition, an amendment or a modification for anything that supersedes an existing version. The contribution audit below the list carries the change set’s description and, optionally, a committer name; leave it blank and your viewer identity is used. The commit button always states what it is about to do (“Commit 2 changes as one contribution”).
Note
Staged changes live in the open browser tab only. The viewer stores nothing of its own, so leaving the screen discards them, and nothing reaches the CDR until you press commit.
On success the tab names the new contribution and every version it created, and links straight to the Contributions tab, where the contribution opens with all of its versions. On refusal nothing at all was committed: the staging list is left exactly as it was, and the CDR’s own diagnostic is shown verbatim so you can correct the offending document and commit again.
Directory editing
The Directory tab creates the EHR’s FOLDER directory when none exists: it commits the empty root folder, which the tree editor then fills. There is no viewer-side library of folder shapes; the viewer stores nothing of its own, and every folder you build is an ordinary directory version the CDR owns and every other openEHR client can see.
Once the directory exists, the tab is a full structured tree editor:
add, rename, and remove sub-folders at any node, and attach or remove
OBJECT_REF items: a picker lists the EHR’s compositions, and a manual
form covers arbitrary references. Edits accumulate locally until the sticky
save bar commits them as one new version (If-Match concurrency: a
concurrent change never silently overwrites; a conflict banner keeps your
unsaved edits and offers an explicit reload-or-overwrite choice). An
advanced mode still edits the canonical JSON directly.
The toolbar adds the read-side tools: version history (every directory version, read-only preview, one-click restore of an older tree), an At time lookup that resolves the directory as it stood at a chosen instant, a path query for one sub-folder, and the two-step directory delete (a logical delete: the history stays readable, and a new directory can be created afterwards).
Composition viewer
Any composition renders in canonical JSON, canonical XML, FLAT, or STRUCTURED. Switch freely; the CDR converts. The version dropdown walks the revision history, and each version’s audit (committer, time, change type) is shown alongside.
Every document pane in the viewer (the composition viewer, the EHR status tab, the directory raw mode, a contribution, a template’s OPT and example tabs) is the same viewer:
- Highlighted (the default): the document exactly as the CDR returned it, with JSON and XML syntax highlighting. The highlighter is pure Rust, like everything else in the viewer; very large documents are shown unstyled rather than tokenized.
- Raw: the same text with no highlighting.
- Rendered: a template-free clinical reading of a canonical openEHR JSON
document: RM section headings with their type and archetype node id, and
one label/value row per
ELEMENT(quantities with their units, coded text with its terminology code, a null-flavoured leaf saying so). It needs no operational template, so a composition whose template has since been removed still reads normally. The tab appears only for canonical JSON; bookkeeping (language, territory, category, uid) is folded away, and the raw views remain the complete record. - Copy puts the raw document text on the clipboard.
Which view a composition opens in is part of its link: ?view=rendered,
?view=raw or ?view=highlighted on the viewer’s URL. That is how the
compositions tab’s rows land straight on the clinical reading, and it makes
“open this composition the way I am looking at it” a shareable link.
Edit as new version opens the currently displayed canonical JSON in
an editor and commits it as the next version (If-Match on the latest
version, and a concurrent change is reported instead of overwritten).
A version timeline strip walks the revision history at a glance, and
the At time picker resolves whichever version was current at a chosen
moment (version_at_time).
A Versioned object card below the audit reads the versioned composition itself and the selected version directly: the versioned-object id, the owning EHR, when the object was first created, and (for whichever version the selector shows) its lifecycle state, its preceding version, the contribution it was committed under, whether it carries a signature, and whether it still carries content.
Tags on a composition
Below the versioned-object card, Tags lists the composition’s item tags (free key/value markers any openEHR client can attach) and sets or deletes one. Three things about them are worth knowing before you use them:
- The panel edits the collection it names. The line under the heading says which one: with the version selector on Latest that is the versioned composition’s own collection; pin a version and the panel edits that version’s tags instead. openEHR keeps the two apart (a tag belongs to exactly one target) so a tag set on the container is not visible on any version, and vice versa.
- Saving re-sends the whole collection, because that is what the openEHR tag update does. The viewer reads the current tags and merges yours in, so nothing is lost by accident, but the tag operations carry no version check at all, so a tag another client added between your load and your save can be. Reload before editing a busy composition.
- A tag is identified by its key and target path together, so the same key on two different paths is two tags; deleting addresses the key alone and removes both.
A tag write is not a versioned write: it commits no contribution, mints no new version, and never appears in the revision history.
Deleting a composition
Delete composition on the viewer performs the openEHR logical delete of the composition’s latest version, with a confirmation dialog first. The CDR commits a deleted version on top of the current one: the composition stops resolving as current and leaves the EHR’s composition list, while every earlier version and the audit trail stay readable. It is a normal versioned write, so it needs no admin API, but it does need the version to still be the latest one: if it moved on since the screen loaded, the CDR refuses the delete and the message says to reload the history and retry.
Note
This is not the same operation as Delete EHR above, which is the CDR’s physical admin delete and leaves nothing readable.
The EHR detail’s contributions tab lists the EHR’s contributions (id, commit time, committer, change type) with the by-uid lookup kept underneath.
The commit form accepts canonical JSON, canonical XML, or FLAT:
EHR status
The Status tab renders the EHR’s current EHR_STATUS: the queryable and
modifiable flags as badges, the subject, the version the document is, and the
full document itself. A non-queryable EHR is called out: AQL over it returns
nothing.
Below the document, Edit status changes the two flags and other_details:
- tick or untick is_queryable to include the EHR in population queries (AQL), and is_modifiable to allow new content to be committed to it;
- other_details takes a canonical-JSON
ITEM_STRUCTURE(for example anITEM_TREE); leaving it blank removes the attribute. A value that is not a JSON object is refused before anything is sent.
Saving commits a new EHR_STATUS version on top of the one the screen loaded, and every other attribute (the subject included) is sent back exactly as the CDR served it, so nothing the form does not show can be lost.
Note
The save is conditional on the loaded version. If another client committed a new status in the meantime, the CDR refuses the write and the viewer says so (“EHR status changed on the server”) instead of overwriting the change: reload the tab and reapply your edit. A rejected document keeps the CDR’s own diagnostic on screen, beside the form.
Tags on the EHR status
The Status tab ends with its own Tags panel, the same editor as the composition one. It always edits the versioned EHR status’s collection, so a tag stays put when you edit the status into a new version; the status tab has no version selector, and a tag pinned to a superseded version would quietly disappear. Saving re-sends the whole collection and carries no version check, exactly as on a composition.
EHR status history
The Status history tab is the versioned view of the same object: the
VERSIONED_EHR_STATUS container and the selected version’s envelope facts
(lifecycle state, preceding version, contribution, whether it is signed), the
revision history newest-first, and a date-and-time lookup that resolves the
version extant at that instant. Opening any row, or a resolved instant,
shows that version’s EHR_STATUS document exactly as it stood at that commit.
Tags in this EHR
The EHR detail’s Tags tab is the whole EHR’s tag list in one place: every tag on every object under it (compositions, the EHR status, the directory) grouped by the object it sits on. Filter by key, value or target path; the filter lives in the address bar, so a filtered view is shareable and refresh-safe, and the shared footer pages the groups.
A tag names its target by identifier but not by kind, so each group’s Open asks the CDR which object holds that id before going there: a composition opens in the viewer, the EHR status and the directory open on their own tabs. If nothing in the EHR holds it any more (the object was deleted) the tab says so instead of guessing.
The container form and one version of the same object appear as two groups, because openEHR stores them as two separate collections.
Demographics
The Demographics screens work with the people, organisations, groups, agents and roles the CDR holds (openEHR calls all five of them parties) plus the relationships between them, the tags on them, and the commits that changed them. Everything here is a view of the CDR’s public API, so any change you make is an ordinary openEHR write that every other client sees.
- Finding a party
- Creating a party
- Reading and editing a party
- Version history
- Relationships
- Tags
- Contributions
Important
The openEHR demographic API is published in the development state within the REST release this server implements. It works, and this server implements it as specified, but the next openEHR release may change it, so treat these screens as less settled than the EHR ones, and expect the relationship surface in particular to move (see Relationships).
Finding a party
Pick a kind with the switcher across the top (People, Organisations, Groups, Agents, Roles) then open a party by its id.
There is no party list, and that is the API rather than a gap in the viewer: openEHR’s demographic API publishes no “list all people” endpoint, and AQL queries EHRs, not parties. A party is reached by its id. Two id forms work:
- a versioned object uid (
8849182c-82ad-4088-a07f-48ead4180515) opens the latest version; - a full version uid (
8849182c-…::your.system::2) is reduced to the object it belongs to, because every screen here addresses the object.
Find-by-id is a plain form: it works before the page’s WebAssembly has loaded, and it works with JavaScript switched off entirely.
The one demographic collection the API does publish is the tag index, at the bottom of the screen; see Tags.
Creating a party
The create card opens with the smallest document that kind accepts. Every attribute in it is required by openEHR:
- the party’s
name, which openEHR uses for the party’s type (PERSON,ORGANISATION, …) rather than for a person’s name; the actual names live inidentities; - an
archetype_detailsblock, and a rootarchetype_node_idequal to the archetype id inside it; - at least one
identity; - for a role, a
performer, the party playing it.
Replace the archetype ids and the identity details with the ones your own demographic archetypes use, then create. The document is sent exactly as you wrote it, so nothing the viewer does not display can be lost; if the CDR refuses it, the refusal is shown verbatim with the offending path.
On success the viewer opens the new party.
Reading and editing a party
A party opens on four tabs. The tab is in the address bar, so a link to a tab opens on that tab.
Party shows its facts (type, name, archetype, current version, how many identities and inline relationships it carries) the whole document, and the edit form. The document pane offers the same three views as everywhere else in the viewer (highlighted, raw, and a rendered reading) and a copy button.
The edit form changes exactly two things: identities (which openEHR requires,
so it can never be emptied) and details (optional: clear the box to remove
it). Everything else in the document travels back to the CDR byte for byte as
it was served, so an edit can never silently drop an attribute this screen does
not show. Saving commits a new version on top of the one loaded, and the
CDR refuses the save if someone else committed in between, and you are told to
reload and reapply rather than overwriting their change.
Delete party is above the tabs. It is openEHR’s logical delete: it commits a deleted version, so the party stops resolving as current while every earlier version stays readable in History. The dialog spells that out before anything is sent.
Version history
History walks the versions of one party (or one relationship):
- the versioned object’s own facts, plus the selected version’s envelope: lifecycle state, preceding version, whether it is signed, and the contribution that committed it, linked to its own screen;
- the revision history, newest first, each row opening that version;
- an at a point in time lookup, which resolves an instant to the version that was current then and opens it;
- the document exactly as it stood at the opened version.
The current party and its past versions come from different endpoints on purpose, which is why they live on different tabs: the Party tab is the one reader of “what this party is now”, and History never touches it.
Relationships
A relationship joins two parties, from a source to a target: an employment, an authority, a care relationship.
Note
Relationship endpoints are this server’s own extension. The openEHR release publishes no relationship API at all, so a different openEHR server will not serve them, and the viewer reports the resulting “not found” plainly rather than hiding it.
openEHR models a relationship in two ways, and both are visible here:
- inside the source party. A party document carries the relationships it is the source of, and the party’s Relationships tab lists exactly those.
- as its own object, with its own id, versions and history, the shape this server’s relationship endpoints serve.
The two are separate records; neither is a view of the other.
One consequence is worth knowing before you rely on the screen: the target side cannot be listed. openEHR defines “relationships pointing at this party” as a derived attribute, and the CDR does not populate it, so no request can answer “who is related to this person”. The tab says so where you would look for the answer. What you can always do is open a relationship by its own id, or follow it from either party it names.
Relate this party on the tab opens the create form with this party already
filled in as the source. Give the relationship a type (openEHR stores it as the
relationship’s name), name the target party and its kind, optionally add a
details document, and create.
A relationship’s own screen shows both ends as links to those parties, its facts, an edit form for its type and details, its version history, and a delete, all with the same versioning behaviour as a party. The two ends are not editable: a relationship between different parties is a different relationship.
Tags
A tag is a free key/value marker a client can attach to a party: a follow-up flag, a migration marker, a local cross-reference. The party’s Tags tab lists what it carries, sets a tag, and deletes one.
Two openEHR behaviours shape the panel:
- Saving a tag re-sends the whole collection, because that is what the openEHR tag update does. The viewer reads the current tags and merges yours in, so nothing is lost by accident, but a tag another client added between your load and your save can be. Reload before editing a busy party.
- A tag is identified by its key and target path together, so the same key on two different paths is two tags. Deleting addresses the key alone and removes both.
The tag index at the bottom of the browser screen is the demographic space’s whole tag list, filterable by key, value and target path, with the filter in the address bar. It spans every kind, because a tag names its target without naming that target’s kind, which is why each row’s Open party asks the CDR where that id lives before opening it.
Contributions
Every write on these screens commits a contribution: openEHR’s record of one change set, with who committed it, when, and why. Open one from the contribution link on any version’s envelope in History.
The contribution screen shows those audit facts, the versions the change set touched (each linked to the party or relationship it belongs to) and the whole record. It is read-only: contributions are made by writing parties, not by authoring change sets here.
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.
- Switching it on
- Picking a terminology
- Defining a term
- Expanding a value set
- Testing subsumption
- Picking codes in the Query Builder
Note
The terminology API is this server’s own extension. openEHR’s REST release publishes no terminology contract, so the operations come from the openEHR Service Model (
I_TERMINOLOGY_SERVICE) while the URLs and JSON envelopes are this server’s. 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. Binding an external FHIR terminology server is a separate setting, and it changes what these lookups can answer; both are covered in Terminology servers.
Picking a terminology
The list on the left is exactly what the CDR reports: the internal openehr
vocabulary and the external code sets beside it (languages, countries,
character sets, media types). Selecting one puts it in the address bar, so a
terminology is shareable and survives a reload, and the choice works before the
page’s WebAssembly has loaded.
The Descriptor card beside it shows what the CDR publishes about that terminology: publisher, identifying URI, available versions, and the meta-model attributes an extract request may ask for. Fields the server does not publish are not shown at all rather than filled in with a guess.
Defining a term
Type a code and the screen asks the CDR what it means. A defined term comes back
as code — text, with the language it is written in and whether it is the
preferred term among alternatives; a code the terminology carries without any
display text comes back as the bare code, which is the honest answer rather than
a blank.
An effective date is optional. Supplied, it asks for the definition as it stood on that date; left empty, it asks for the current one. The openEHR bundle is a single pinned release, so a date does not change its answer; an external terminology server can.
A code the terminology does not define is reported as a plain note on the card that asked, naming the code and the terminology. It is not an error: asking whether something exists is a legitimate question, and “no” is an answer.
Note
openEHR terminology codes are scoped to their group, not global:
532iscompletein one group andcompletedin another. A code lookup treats theopenehrterminology as flat and returns the first group’s rubric, so use the value-set card below whenever the group matters.
Expanding a value set
A value set is addressed by its id: for the openehr terminology, an openEHR
vocabulary group such as audit_change_type or version_lifecycle_state, by
its identifier or its display name. Expanding one lists its members as
code — text.
Under it, Validate answers one question: is this code a member of that value set? Both verdicts read as a sentence, and a value set the CDR does not know simply has no members, so a code is reported as not a member rather than the question being refused.
Testing subsumption
Subsumption asks whether one code is an ancestor of another. The test is strict, so a code never subsumes itself, and the openEHR vocabulary is flat (it defines no is-a hierarchy) so the honest verdict for any pair of openEHR codes is “does not subsume”. Hierarchical answers come from an external terminology server when one is bound.
Picking codes in the Query Builder
The same lookups back the Query Builder’s coded condition editor, so a coded criterion no longer has to be typed from memory; see Dashboard & queries. The model is unchanged: whatever route a code took into the criterion, the query carries the bare code.
Operations panel
The Operations screen is the viewer’s operator view of a running CDR: dependency health, what exactly is deployed, the live metric registry, and runtime log control. Everything on it comes from the CDR’s own operational endpoints over HTTP; the viewer has no privileged channel.
When it appears
The panel is probe-and-hide: on every page load the viewer asks the CDR for
GET /management/info, and the sidebar entry appears only if that endpoint
exists. A 404 (the CDR’s answer when the management surface 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 card that asked rather than as a missing
screen.
To get the panel, enable the surface on the CDR and give each endpoint you want
an access level (Operations → The management
surface). Every endpoint ships
off, so nothing is mounted until you name it:
[management]
enabled = true
[management.endpoints]
info = "admin_only" # the availability probe — enable it alongside the rest
metrics = "admin_only"
env = "admin_only"
loggers = "admin_only"
The levels are off (not mounted, 404), private (any authenticated
principal), admin_only, and public (served outside authentication). A card
whose endpoint is off says so in place instead of failing, but info is the
probe, so leaving info at off hides the whole panel.
If the CDR serves management on its own internal listener
(management.port) or under a renamed base path (management.base_path), point
the viewer at it with one setting, the full prefix, including the path:
FERROEHR_VIEWER__CDR__MANAGEMENT_BASE_URL=http://cdr.internal:9100/management
Unset, the viewer derives {cdr.base_url}/management.
Note
The management endpoints are gated server-side at the level you chose. The viewer shows the panel whenever the surface exists; being allowed to read a particular endpoint is the CDR’s per-request decision, and a refusal is reported on the card that asked, naming what to do about it.
Dependency health
The health card reads the CDR’s public readiness probe
(GET /health/readiness, always served, no configuration): the aggregate state
plus one row per dependency the server checks (the database ping, the
migrations probe, and any optional components) with the CDR’s own detail text
where it gave one.
This is deliberately a different question from the status pill in the
topbar, which polls the product status document (GET /ferroehr/rest/status):
| Reader | Question it answers |
|---|---|
| topbar pill | is the API answering at all, at which version, and under which deployment profile? |
| health card | are the CDR’s dependencies healthy enough to serve? |
The card states that split on screen, and nothing else in the viewer re-reads either claim.
Build & spec provenance
Straight from GET /management/info: the CDR’s version, the git commit the
binary was built from, its build timestamp and rustc version, the PostgreSQL
target, the audit posture the deployment runs (whether auditing is on, its
fail_mode, the local store and the effective retention), and under their own
heading the deployment’s active
spec_profile with the openEHR
specification versions it selects. This is the card to screenshot into
an incident report: it says exactly what is running.
Metrics
Four headline tiles (in-flight requests, compositions committed, AQL queries, database connections in use) sit above a browser over the CDR’s whole metric registry: pick a metric, and the panel renders its current samples with their labels: the same numbers Prometheus scrapes, without a Prometheus.
The selection lives in the URL (/operations?metric=db_pool_connections), so a
view is shareable and survives a refresh, and the picker works before the
browser app has loaded. A tile reads — when the deployment records nothing for
that metric yet.
For dashboards and alerting, scrape GET /management/prometheus instead; the
panel is for looking, not for collecting.
Runtime configuration
The CDR serves its redacted effective configuration on both its management surface and its admin API, and it is the same snapshot, so the viewer reads it in exactly one place: the System screen. This card links there rather than rendering a second copy.
Log level
The log card shows the filter in effect right now and the boot filter a reset
restores, and lets an operator change the live filter without a restart
(POST/DELETE /management/loggers):
Type tracing-style directives (ferroehr=debug,sqlx=warn), press Apply
filter, and confirm: the dialog spells out that logging changes immediately
for every request the instance serves. Reset to boot filter puts the startup
value back. Both outcomes are reported as a toast, and the card re-reads the
CDR’s answer, so what you see is what the server confirmed, not what was asked
for.
Important
A log-filter change applies to that CDR instance only and lasts until it restarts. Behind a load balancer, each instance is set separately; for a permanent change, set
log.filterin the server configuration.
FHIR connector
The FHIR screen administers the CDR’s FHIR mapping store (the definitions that translate between FHIR resources and openEHR compositions) and gives you two ways to check a mapping does what you meant, without writing anything. Everything on it comes from the CDR’s own FHIR API over HTTP; the viewer has no privileged channel and keeps no mapping state of its own.
Note
No openEHR specification governs FHIR interoperability; the connector is FerroEHR’s own extension, and its wire vocabulary follows HL7 FHIR R4. What the connector does with a mapping, and every status code it answers, is described in FHIR connectors; this page is about driving it from the viewer.
The viewer never sends a resource for real
The connector’s inbound door (POST /fhir/r4/{type}) maps a FHIR resource,
validates it, and commits it as an openEHR composition. That is an
integration act (something a sending system does, with its own credentials and
its own audit trail) so the viewer deliberately offers no path to it. You
cannot ingest a resource from this screen, by design.
What the viewer does offer is the two read-only ways to verify a mapping:
- the read path, which shows what a stored mapping produces when openEHR data is read back out as FHIR;
- the dry run, which runs a resource through the whole ingest pipeline including validation and reports the verdict, committing nothing.
When it appears
The screen is probe-and-hide: on every page load the viewer asks the CDR
for GET /admin/fhir_mapping, and the sidebar entry appears only if that route
exists. A 404 (the CDR’s answer when the connector 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 connector on
([fhir]):
[fhir]
api_enabled = true
Reaching /fhir on a deployment without it is not an error either: the screen
renders one card naming that switch instead of a store that cannot be read.
Note
The mapping store 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.
The mapping store
The table lists every stored mapping with its name, the FHIR resource type and profile it binds, the openEHR template it builds under, whether it is enabled, and its store id, newest first, paged by the shared footer under the table (see Paging).
A mapping is edited as a JSON document, and that is deliberate. The definition is a deep, open-ended structure (the subject binding, the commit context, and one entry per mapped field with its own path and transform) whose shape the CDR owns. A form built out of boxes here would be a second model of it that drifts the moment the connector grows a field, so the viewer sends the document you wrote, verbatim, and shows the CDR’s answer, verbatim. The definition’s shape is documented under Mappings are data you manage.
- Store a mapping with the card above the table: a name and the definition
document. The button stays disabled until the name is addressable (letters,
digits,
_,.and-) and the definition parses as a JSON object; that much the viewer checks before spending a round trip; everything else is the CDR’s judgement, and its rejection is shown in full beside the failure notification. The most common one on a fresh deployment is an unknowntemplate_id: upload the operational template first. - Enabled decides whether the connector resolves the mapping at all. A disabled mapping stays stored and editable but takes part in nothing, the way to retire a mapping without deleting it.
- Edit on a row opens the document in an editor, seeded with what is currently stored. The name is not editable: the CDR treats it as the mapping’s deployable identity, so a rename is a new mapping.
- Delete on a row asks for confirmation naming the mapping, and nothing is sent until you confirm there. Deleting a mapping stops the connector accepting and serving that resource type; data already committed through it is untouched, and stays readable through the openEHR API as normal.
Every one of those writes reports both outcomes, and a success and a failure are equally visible, so a refused change never looks like nothing happened.
Warning
Two enabled mappings for the same resource type resolve by
meta.profile: an exactprofile_urlmatch wins, and a mapping with no profile is the type’s default. A resource that declares no profile only ever matches the default, so a store with profile-scoped mappings and no default answers404for plain resources.
Read path
Enter a resource type and a patient, then Read. The viewer calls the CDR’s
read façade (GET /fhir/r4/{type}?patient=…) and shows the FHIR Bundle it
answers with, each entry produced by running a stored mapping in reverse over a
committed composition.
The scope lives in the URL, so a read is shareable, survives a reload, and works before the page’s WebAssembly has loaded. Both fields are required: the façade serves this explicit scope only, never a general FHIR search.
Two answers are worth recognising:
- An empty Bundle (
"total": 0) means the mapping resolved and nothing is stored for that patient: an answer, not a failure. Check the patient identifier against the mapping’ssubjectbinding: the connector strips the configured prefix (Patient/) and matches the remainder in the configured namespace. - An
OperationOutcomeis the connector refusing the read: an unsupported resource type, a missing scope. It is rendered exactly as the CDR wrote it, inline, because itsdiagnosticstext is the answer to what went wrong.
Dry run
Paste a FHIR resource, name its type, and Validate. This calls the CDR’s
$validate operation,
which runs the full ingest pipeline (mapping resolution, the composition build,
the provenance stamp, and the same validation the real commit runs) and then
throws the result away.
Nothing is committed: no EHR, no composition, no version. The target EHR is resolved and reported, never created.
The panel reports one of three verdicts, and the difference matters:
| Verdict | What it means |
|---|---|
| Valid | The validation ran and the resource maps to a composition the CDR would accept. The outcome names the resolved template and says whether the commit would land in an existing EHR or create one. |
| Invalid | The validation ran and the CDR would refuse the mapped composition. The outcome carries the openEHR validator’s own rejection, the exact text a real ingest would fail with. |
| Not validated | No verdict was reached at all: no mapping matches the type, the type is outside the connector’s set, or the resource could not be mapped. The outcome says which. |
The full OperationOutcome is shown under the verdict either way. This is the
loop mapping development is meant to run in: edit the definition above, dry-run a
real sample resource, read the rejection, repeat, and only point the sending
system at the real ingest door once the verdict reads valid.
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 renders one card naming that switch instead of 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 four 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. The viewer says so in every cell (an
unset predicate reads any, never a blank) and each row carries a
plain-words line saying what it selects, so “matches every committed version”
is visible rather than inferred from four empty boxes.
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 -. The create
button stays disabled until the name you typed is one the CDR can accept, and
the name 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 table lists every stored subscription, newest first, paged by the shared footer under the table (see Paging).
- Create with the card above the table: a name, and as many predicates as you want to narrow by. Leave them all empty for a feed of everything.
- Edit on a row opens an editor seeded with that subscription’s current
values. Saving replaces every predicate, so a field you clear becomes
anyon the CDR: that is the whole update, not a patch. The name is shown and never editable. - Delete on a row asks for confirmation naming the subscription and what it matches, and nothing is sent until you confirm there.
Every one of those writes reports both outcomes, and a success and a failure are equally visible, so a refused change never looks like nothing happened, and the CDR’s own words (a duplicate name, a rejected value) are shown in full beside the failure notification.
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 whatever it already holds and stops receiving new messages, so reap orphaned queues on the broker side as part of the same change.
Dark mode
Every viewer screen is themed twice, and the pictures below are the proof:
the same screens the rest of this chapter documents, captured with the topbar’s
dark-mode toggle on. The choice persists per browser (localStorage), so it
survives a reload and follows you across screens; it is a display preference
and nothing else: no screen, control, or permission changes with it.
- Dashboard
- Templates
- Query Builder
- EHRs
- Demographics
- Terminology
- Operations
- System
- Audit log
- Subscriptions
- FHIR connector
Dashboard
Templates
Query Builder
EHRs
Demographics
Terminology
Operations
System
Audit log
Subscriptions
FHIR connector
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
- 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 and EXECUTE on the ext helper functions, 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).
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). One constraint:
the linkage migration set revokes a function the party set creates, so those
two are prepared in the same database, and a layout that splits them is refused
at boot with the remedy rather than failing partway through a migration.
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 on its own DSN
instead, because migrate_url names one database and a relocated domain is not
in it. 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`
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 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 domain actually relocated to another database, as opposed to a second credential on the same one. The layout, the per-database preparation and the refusals are covered in process; no suite runs two PostgreSQL databases.
- 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
{"status":"DOWN","components":{"db":{"status":"UP"}, "migrations":{"status":"DOWN","detail":"core schema tables missing (migrations not applied)"}}}on
/health/readiness(503), 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.
Recovering a partially wiped database
A wipe that removes some of the server’s schemas is not a fresh start, and the server refuses to migrate over one rather than doing something plausible with it.
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 the clinical schemas (clinical, ext, audit) while party and
linkage survive is a supported reset: the clinical migration set rebuilds its
schema on the next boot.
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.
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.
# 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
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 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.
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 {base}/admin/integrity/verifyreports each such version asstale_decompositionandPOST {base}/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. -
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, because it is short and it is deliberate:
- Only the most recent release receives security fixes. There are no maintenance branches, no long-term-support line, and no backports. A version stops receiving fixes the moment a newer release exists.
- 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 at its newest published version only. A chart-only fix ships as a new chart version between server releases.
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) each line stamped with the trace and span id. Shipping and rotation are the platform’s job.
FERROEHR__LOG__FORMAT(auto/json/pretty) andFERROEHR__LOG__FILTER(orRUST_LOG, defaultinfo,ferroehr=info) control them, and the level can be changed at runtime through theloggersendpoint below. 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__LOG__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 an endpoint; with none set, the tracing layer is not installed at all (zero overhead). Root spans are named by route template, never by a path containing ids.
-
Metrics come from one OpenTelemetry meter provider with up to two readers: a Prometheus reader behind
/management/prometheus, and (whentelemetry.metrics_pushis on) 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, active requests and body sizes; 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 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 (layer not installed) | OTLP collector endpoint |
FERROEHR__TELEMETRY__SERVICE_NAME | ferroehr | reported service name |
FERROEHR__TELEMETRY__ENVIRONMENT | dev | reported deployment environment |
FERROEHR__TELEMETRY__TRACES_SAMPLE_RATIO | 1.0 | head sampling ratio (start at 0.1 in production) |
FERROEHR__TELEMETRY__METRICS_PUSH | false | add the periodic OTLP metrics reader beside the Prometheus one |
FERROEHR__TELEMETRY__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
both otlp_endpoint and metrics_push. 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 two 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:
otlp_endpoint: http://otel-collector.observability:4317
environment: production
traces_sample_ratio: 0.1
metrics_push: 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 telemetry off, drop otlp_endpoint; the tracing layer is
not installed at all when it is unset.
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 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 (
{base}/message/…, always mounted): EHR Extract export and import, and Template Data Document 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 | constant 200 OK (plain text OK), touches nothing | load balancers, docker HEALTHCHECK, anything that must never be auth-gated |
GET /health/liveness | identical to /health, the same constant answer under the orchestrator-conventional path | Kubernetes livenessProbe and startupProbe |
GET /health/readiness | 200 when the aggregate is up or degraded, 503 when a required component is down; JSON body with every indicator, each bounded to one second | Kubernetes readinessProbe, ops dashboards |
GET /ferroehr/rest/status | product status document: status, server_version, openehr_rest_api_version, timestamp, licence (the grant in force: state, use, licensee, not_after, configured_token) and deployment (the declared profile, the separations still open and the ones accepted by name) | version/identity checks; the URL the container’s ferroehr healthcheck subcommand probes |
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. /health and
/health/liveness are two conventional names for the same constant answer (a
load balancer wants the bare path, an orchestrator wants the liveness/
readiness pair); /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 |
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 |
fhir_outbound | the FHIR outbound emitter’s broker delivery (present only when the emitter is enabled) | no: reports DEGRADED, never 503, since unemitted rows are retained and re-emitted |
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.
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, and the audit posture (enabled, fail_mode, local_store, retention_days) | 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 → Operations panel.
Next
- Admin & messaging APIs — the operator-facing HTTP surfaces, route by route.
- 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 and accepts documents in the template-data (TDD) form. 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
- Storage integrity
- Dump and load
- EHR Extract and TDD import
- Other routes under
{base}/admin
Where these routes live, and what gates them
Both groups are mounted under the API base path, so every path below is
relative to it. With the default server.base_path, {base} reads
/ferroehr/rest/openehr/v1, and {base}/admin/dump is
/ferroehr/rest/openehr/v1/admin/dump.
The two groups are gated differently, and the difference matters operationally:
| Group | Switch | Authorization class | While switched off |
|---|---|---|---|
{base}/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 |
{base}/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 {base}/admin/template/{template_id} | 204 | 404 unknown template, 409 a committed version still references it |
DELETE {base}/admin/query/{qualified_query_name}/{version} | 204 | 404 unknown name, or a known name with no such version |
GET {base}/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; the subject proxies of a subject whose only record this was go in the same transaction, and an erasure tombstone is appended to the change-event stream 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.
What it keeps: the audit records naming the ehr_id. 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, HMAC and signing-key secrets and S3 secret keys render as
***; connection URLs (database, AMQP) keep their host and path and mask the embedded credentials (postgres://***@host:5432/db). 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 {base}/admin/report/contribution | the matching CONTRIBUTION ids as a JSON array, ordered by commit time then id |
GET {base}/admin/report/contribution/count | how many there are, as a bare JSON number |
GET {base}/admin/report/versioned_composition/count | how many distinct COMPOSITION version containers had a version committed in the interval |
GET {base}/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 {base}/admin/archive/ehrs | {"ehr_ids": ["…"]} | 204: every named EHR and all its versioned content is archived |
POST {base}/admin/archive/parties | {"party_ids": ["…"]} | 204: every named demographic party is archived |
POST {base}/admin/archive/ehrs/restore | {"ehr_ids": ["…"]} | 204: every named EHR’s archived content is back in the primary tier |
POST {base}/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 {base}/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
Eight routes behind the same switch and role, carrying the three marks the law asks a repository to hold beside its clinical content. None of them deletes anything.
| Route | Body or parameter | Success |
|---|---|---|
POST {base}/admin/restriction | {"ehr_id": "…", "vo_id": "…"?, "ground": "…", "note": "…"?} | 204: the restriction is recorded and in force |
GET {base}/admin/restriction | ?ehr_id=… | 200: the register, newest request first, lifts included |
POST {base}/admin/restriction/lift | {"ehr_id": "…", "vo_id": "…"?} | 204: every in-force restriction at that grain is lifted |
POST {base}/admin/research-objection | {"ehr_id": "…", "objected": true, "ground": "…"?} | 204: the objection, its override, or its withdrawal is recorded |
PUT {base}/admin/retention/policy | {"kind": "…", "jurisdiction": "…", "period": "…", "anchor": "…", "source": "…"} | 204: the period is declared |
GET {base}/admin/retention/policy | — | 200: the whole retention register |
PUT {base}/admin/retention/anchor | {"ehr_id": "…", "jurisdiction": "…", "anchored_at": "…"?, "hold_at": "…"?, "hold_ground": "…"?} | 204: the EHR’s anchor and any whole-record hold |
POST {base}/admin/retention/hold | {"vo_id": "…", "held": true} | 204: the per-object exemption is placed or released |
GET {base}/admin/retention/due | ?limit=100 | 200: what has run out, oldest first, with the due and held object counts |
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.
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. What each mark means, which provision it serves and what the deploying organisation still has to decide are on Retention, restriction and objection.
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 {base}/admin/integrity/verify | the sweep report |
POST {base}/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 {base}/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 {base}/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 {base}/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.
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 and TDD import
Six routes under {base}/message that move whole records between systems and
accept documents in the template-data form. Unlike the admin extensions above,
these are not admin-gated; see
the gate table.
EHR Extract
GET {base}/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 {base}/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 {base}/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 {base}/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.
TDD import
POST {base}/message/tdd/{ehr_id}with anapplication/xmlbody — import one Template Data Document. It is converted against the operational template its root names and committed through the ordinary validated composition path, so 201 with{"uid": "<version_uid>"}. The template must already be uploaded through the definition API (404 otherwise, as for an unknown EHR); a root that is not in the template-data namespace, carries no template id, or does not conform to the template is 400; a document that is not well-formed XML (or a produced composition that fails validation at commit) is 422. A body sent as anything butapplication/xmlis 415.POST {base}/message/tdd/{ehr_id}/batchwith a JSON array of TDD documents — import several at once. 201 with the created version ids in input order. The batch is all-or-nothing: every document is converted before any is committed, so one bad document rejects the whole batch and commits nothing. An empty array is a fulfilled no-op (200 with[], since nothing was created) but the target EHR is checked for every batch, the empty one included, so an unknown one is 404 whatever the batch holds. The batch has no limit on how many documents it may carry; the only bound is the server-wide request-body limit, which answers 413 when exceeded.
Note
The whole
{base}/messagegroup is a FerroEHR extension: the openEHR service model defines a Message component, but the released REST API publishes no message, extract, or TDD 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
Three further families share the /admin path prefix but not the
admin.enabled switch: each has its own, and each answers 404 (not
405) while its own 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) |
{base}/admin/fhir_mapping… | fhir.api_enabled | FHIR connectors |
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.
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 this server, against another CDR, or against any deployed endpoint you point it at) and how to read the artefacts it produces: the report, the statement, the certificate, and the cross-server comparison matrix.
- What is measured
- The current result
- Any server can be assessed
- Running the suite yourself
- Reading the artefacts
- What conformance does not cover
What is measured
Conformance is checked by Veredictum,
an independent CNF 2.0 reference runner developed as its own project and
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-09-15).
- Profile verdicts — Core: PASS. Standard: PASS. Options: PASS. Security (SEC-BASIC): PASS.
- 43/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.
Any server can be assessed
The runner is deliberately not tied to FerroEHR — it 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 and emits the same artefact set for each system under test, into its own directory:
- FerroEHR (the default): the composed stack built from the current sources. This is the project’s own gate: the committed artefacts are regenerated and diff-checked, so a change that moves a verdict cannot land quietly.
- EHRbase:
CONF_SUT=ehrbasecomposes the officialehrbase/ehrbaseimage (with its companion PostgreSQL) on fresh volumes and runs the same catalogue with EHRbase’s own committed party set. Its measured artefacts feed the comparison page. - Bring your own endpoint: point the runner at any deployed CDR by URL and credentials, with its own party set: an ixit naming the instances and credential environment variables, and a statement (the ICS) declaring the capabilities and ambiguity-register options the vendor claims. Option branches the ICS does not declare are excused as not-applicable with a citation, in the ISO/IEC 9646 tradition of test selection. No code or adapter is needed; a target is a configuration entry.
The ixit is also where a deployment declares the facts no openEHR operation exposes, each of which switches on the cases that depend on it:
| Declaration | What it tells the runner |
|---|---|
environment | The hardware, cores, memory, storage and topology a measured run happened on — mandatory for a performance run |
containers | The composed containers, enabling database-side attribution and deterministic maintenance settling |
system_id | The identifier the server stamps into commit audits and the version ids it mints |
dump_location | A path on the server’s own file system the admin dump/load operations may use |
signing | The version-signing mode the deployment realizes (digest or openPGP) |
smart | That the deployment runs the SMART resource-server role, and which test issuer it trusts |
terminology | The terminology query servers it is wired to, the namespaces each answers for, and what it does with a bound value set it cannot resolve |
A party that declares none of these has the dependent cases recorded not-applicable rather than checked against a guess.
That silence has a cost, and the pipeline makes it visible before a run. The
instrument’s ixit schema also defines parameters per instance (the
administrative posture of a principal, an instance’s own signing,
terminology or spec_profile when it differs from the party’s), and an
absent one is undeclared, never a default: every case that requires it is
guarded out. scripts/checks/ixit-declarations.sh reads that parameter set
from the pinned schema itself and refuses to run the catalogue while an
instance leaves one undeclared, unless ixit-undeclared.json beside the ixit
records why that instance leaves it so (an unauthenticated instance has no
principal to be administrative, for example). A pin bump that introduces a
parameter therefore fails the pipeline until the party declares or adjudicates
it, instead of quietly losing the cases it guards.
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, never a re-wired in-process stub. From a checkout with Docker available:
# our server, from the current sources (the default)
bash scripts/conformance.sh
# EHRbase, from the official images
CONF_SUT=ehrbase bash scripts/conformance.sh
# any deployed CDR, by URL (credentials via the SUT_* variables the
# ixit references)
CONF_SUT=byo CONF_BASE_URL=https://your-host/ferroehr/rest/openehr/v1 \
SUT_USER=user SUT_PASS=password bash scripts/conformance.sh
The script brings up the selected stack on fresh volumes (for byo it manages
nothing), executes the committed catalogue, computes the verdicts through the pure
pipeline, and writes the artefacts to docs/conformance/<sut-name>/ before
tearing the stack down.
Useful knobs: a case-id filter as the first argument, CONF_IXIT /
CONF_STATEMENT for a custom party set, CONF_OUT for a different artefact root,
CONF_NO_COMPOSE to run against an already-deployed stack, SKIP_BUILD to
compose a published image instead of building from source, and the runner’s own
verdicts subcommand to recompute the documents from a previous results.json
without re-running.
The postures a run covers
Some behaviour exists only in a particular server configuration. Rather than splitting those into separate runs whose records would have to be merged by hand (which is exactly how a claim stops being reproducible) the pipeline brings up two deployments of the same image and covers both postures in the one committed record:
- the primary deployment runs the SMART resource-server role with fail-closed scopes and a trusted test issuer, digest version signing, and an external FHIR terminology server in the fail-open posture;
- a second deployment, in its own compose project on remapped ports, runs openPGP version signing and the fail-closed terminology posture.
The reason is the same in both cases: a running server realizes exactly one signing depth and exactly one unresolvable-value-set behaviour, so testing both claims means running both deployments. The ixit declares the second one as its own instance, and the cases that check those properties address it by name.
Two consequences worth knowing:
- SMART is the standard posture. The SMART discovery
document, the resource-scope grammar, and the fail-closed
403are executable cases in the same record as everything else, driven by principals presenting minted Bearer tokens with the roles and resource scopes each case needs. The tokens are signed by a committed test issuer: public test key material for the harness, never usable for anything else. A system under test whose ixit declares no SMART block (EHRbase) records those cases not-applicable with the citation instead. - External terminology is part of the standard 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 the
terminology-routed surface — AQL
TERMINOLOGY()resolved through the routed server, and commit-time validation of a bound value set, accepted for a member code and refused for a non-member. 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 posture rather than a verdict; both branches execute, one per deployment.
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 is emitted for every assessed system — FerroEHR, EHRbase,
or your own — and 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.
The comparison matrix
The multi-system record is fully generated from the two committed results/verdicts sets (ours and EHRbase’s): profile verdicts, the capability-by-capability evidence matrix, and failure tables in both directions: measured numbers only, no editorial adjustment, both directions always published. It renders as the comparison page.
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.
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.
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: the built-in CNF runner. 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 (
CONF_PERF_CLASS=… bash scripts/conformance.sh, or the runner’sperfsubcommand): 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 (the conformance pipeline's perf stage)
CONF_PERF_CLASS=POC bash scripts/conformance.sh
# 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/<sut-name>/; the published charts regenerate from them
(scripts/render/perf-assets.sh for one system,
scripts/render/comparison.sh for the cross-system overlay, which calls the
runner’s stress-compare renderer) and are diff-guarded in CI.
Fairness rules
The comparison methodology is enforced by construction:
- The same runner drives both servers against the same committed catalogue and the same ladder, each on its own freshly composed stack with its own committed party statement.
- Payload skeletons are byte-identical: both servers receive exactly the same bytes for the same journey stage.
- Database maintenance is settled deterministically before every measured window, by the same procedure on both sides, so neither run pays the other’s maintenance debt or trips an autovacuum mid-measurement.
- Rate limiting is off for measurement runs on the side that has it, 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. Version signing is the clearest case: it is a FerroEHR extension EHRbase does not perform, so it is switched off through its documented toggle for throughput comparisons and the record says so. Any other setting raised or lowered for parity is likewise 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.
- Both directions publish on equal footing. Where EHRbase sustains more, its curve says so exactly like the reverse; see Comparison.
Comparison with EHRbase
FerroEHR and EHRbase are two independent open-source openEHR CDRs. This page measures them side by side with the instruments FerroEHR applies to itself: the CNF 2.0 conformance runner executes the same committed catalogue against both servers, and the step-load stress instrument drives both with the same seeded clinical workload. Both directions are always published; a result that favours either server is reported exactly like one that favours the other.
EHRbase is prior art here, never an oracle. Every expected outcome comes from the openEHR specification text, so a row is red because the specification says otherwise, never because the two servers disagree.
Note
The two systems are not built or hosted alike, and the generated tables below say so. FerroEHR is built from this repository’s current sources; EHRbase runs from its official published container images. The two runs were also measured on different machines; each committed report records its own environment. Read the conformance columns as comparable (identical catalogue, identical runner, each side’s own declarations) and the performance columns as two separate measurements rather than a like-for-like hardware race.
Each side runs with its own committed party set: an ixit describing the reachable instances (EHRbase’s Basic auth carries one clinical and one admin principal and no read-only one, so its ixit declares none) and a statement (the ICS) declaring the capabilities, specification versions, and ambiguity-register options that party actually claims. A capability a party does not claim is dropped from its verdict scope and can never count against it; a case whose ground cannot exist on a party’s topology or technology profile is recorded not applicable with a machine citation, never fail. That is how the comparison stays fair without weakening a single case.
Every number and every curve on this page is generated at build time from the
committed run records (results.json, verdicts.json, stress.json under
docs/conformance/); nothing here is hand-typed, and a CI stale-numbers gate
rejects any attempt to hand-type it. To reproduce either side yourself, see
Conformance and
Benchmarks.
One difference between the two projects is not something a runner can score, so this page states it rather than tabling it. 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 names the legal sources, the control matrix is generated from the tracker, and the shared-responsibility page says which obligations the software cannot carry. None of that is a certification claim, and none of it is a statement about EHRbase, which publishes its own documentation on its own terms.
- Conformance
- Systems under test
- Methodology
- Profile verdicts
- In-scope outcomes
- Capability-by-capability
- Failures — both directions
- Performance
- Method, in one paragraph
Conformance
Systems under test
| ferroehr | EHRbase | |
|---|---|---|
| Product | ferroehr 4.3.0 | ehrbase 2.34.0 |
| Run date | 2026-09-15 | 2026-09-15 |
| Party statement | committed with the runner (declares ITS-REST pin, signing, terminology posture) | committed with the runner |
| Stack | the project’s own compose stack, built from the current sources | a dedicated compose stack of the official EHRbase images |
Methodology
Both systems execute the same committed CNF 2.0 catalogue (1145 case-by-format
executions) through the same instrument (veredictum), each on
fresh volumes with its own committed party set: the ixit names the reachable
instances (EHRbase declares no readonly principal), and the statement (the
ICS) declares the claimed capabilities, spec versions, and ambiguity-register
options — ISO/IEC 9646-style test selection excuses undeclared option
branches, unclaimed capabilities, and release-dated behaviour outside the
declared versions as N/A with a citation, never as silent skips. Verdicts are
pure functions of (statement, results, catalogue, capability matrix).
The declared-version delta matters and is stated, not hidden: ferroehr
declares ITS-REST 1.1.0
while EHRbase declares ITS-REST
1.0.3 —
the catalogue realizes 1.1.0, so every Release-1.1.0-dated behaviour (the
Demographic API, ITEM_TAGs, Simplified Formats on the wire, the admin EHR
delete, the weak-ETag/Location header forms, …) is cited N/A for the
1.0.3 declaration rather than driven against a release EHRbase never
claimed. The verdict-bearing comparison below is therefore each party’s
in-scope subset, never the raw record.
Profile verdicts
| Profile | ferroehr | EHRbase |
|---|---|---|
| CORE | pass | fail |
| STANDARD | pass | fail |
| OPTIONS | pass | not claimed |
| SEC-BASIC | pass | not claimed |
In-scope outcomes
Runs compared: ferroehr (run of 2026-09-15) vs EHRbase 2.34.0 (run of 2026-09-15) — the SAME catalogue through the same runner, each with its own committed party statement. Per the presentation rule, the headline is each party’s VERDICT SCOPE (the cases its own declarations select), never the raw record: a raw count would book release-dated and unclaimed surfaces against a party that never claimed them.
| verdict scope (selected) | driven | in-scope passed | in-scope failed | in-scope inconclusive | |
|---|---|---|---|---|---|
| ferroehr | 1145 | 1104 | 1104 | 0 | 0 |
| EHRbase | 692 | 571 | 148 | 148 | 275 |
An inconclusive row’s wire answered outside the operation’s bound outcome
map, or its required ground could not be established (e.g. a refused
provisioning exchange) — never counted as a failure of the behaviour under
test. Every not-run row in the full committed record
(docs/conformance/<sut>/results.json) carries a machine-readable
citation: an undeclared option branch, an unclaimed capability, a
release-dated behaviour outside the declared spec versions, or a ground the
party’s topology cannot establish.
Capability-by-capability
Evidence tokens from each party’s computed verdicts: passed (every gating case green), failed (at least one gating case red), inconclusive (a gating case neither passed nor failed cleanly), not_evidenced (claimed, but no gating case produced a verdict — there is no excused state: a required capability without passing evidence fails its tier, whichever party claims it), or not_claimed (absent from that party’s ICS, so no case naming it was ever selected — it can never count for or against that party). The whole capability matrix is listed for both columns, because the matrix is the profiles book as data, not a claim list.
| Capability | ferroehr | EHRbase |
|---|---|---|
| ActivityReport | passed | not_claimed |
| Adl14ArchetypeProvisioning | passed | failed |
| Adl14OptProvisioning | passed | failed |
| Adl2ArchetypeProvisioning | passed | not_claimed |
| Adl2OptProvisioning | passed | not_claimed |
| AdminApi | passed | not_evidenced |
| AnonymousEhrs | passed | not_claimed |
| AqlAdvanced | passed | inconclusive |
| AqlBasic | passed | failed |
| AqlTerminology | passed | not_claimed |
| ArchetypeValidation | passed | failed |
| AuditAccountability | passed | not_claimed |
| AuthenticatedAccess | passed | passed |
| AuthorizationSeparation | passed | not_evidenced |
| BulkEhrLoad | passed | not_claimed |
| ChangeSets | passed | failed |
| CompositionOps | passed | inconclusive |
| DefinitionApi | passed | failed |
| DemographicApi | passed | not_claimed |
| DemographicArchetypeValidation | passed | not_claimed |
| DemographicArchive | passed | not_claimed |
| DirectoryOps | passed | failed |
| EhrApi | passed | failed |
| EhrArchive | passed | not_claimed |
| EhrDemographicSeparation | passed | passed |
| EhrDumpLoad | passed | not_claimed |
| EhrExtract | passed | not_claimed |
| EhrOperations | passed | failed |
| EhrStatus | passed | failed |
| ItemTags | passed | not_claimed |
| MessageApi | passed | not_claimed |
| PartyOperations | passed | not_claimed |
| PartyRelationshipOperations | passed | not_claimed |
| PhysicalDeletion | passed | not_evidenced |
| QueryApi | passed | failed |
| QueryProvisioning | passed | failed |
| Signing | passed | not_claimed |
| SimplifiedFormats | passed | not_claimed |
| SmartAppLaunch | passed | not_claimed |
| SystemApi | passed | not_claimed |
| Tds | passed | not_claimed |
| TemplateExamples | passed | not_evidenced |
| Versioning | passed | failed |
Failures — both directions
ferroehr failures (with the EHRbase outcome on the identical case)
| Case | Format | Failure | EHRbase outcome |
|---|---|---|---|
| — | — | none — zero failing cases | — |
EHRbase failures by schedule chapter
| Chapter | failed cases |
|---|---|
| CONT | 67 |
| I_EHR_STATUS | 27 |
| I_EHR_CONTRIBUTION | 13 |
| I_EHR_DIRECTORY | 13 |
| I_DEFINITION_QUERY | 10 |
| I_DEFINITION_ADL14 | 8 |
| I_EHR_SERVICE | 4 |
| I_QUERY_SERVICE | 4 |
| I_EHR_COMPOSITION | 1 |
| I_ITS_REST_REVISION_HISTORY | 1 |
Every EHRbase-failed case, with the ferroehr outcome on the identical case
| Case | Format | EHRbase failure | ferroehr outcome |
|---|---|---|---|
| CONT-COMP-content_card_1plus-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_1plus-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3plus-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3plus-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3to5-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3to5-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_any-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_any-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_mand-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_mand-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_opt-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_opt-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMPOSITION-content_cardinality_count6 | — | expected created, observed validation_failed | passed |
| CONT-COMPOSITION-context_existence | — | expected created, observed validation_failed | passed |
| CONT-DV_CODED_TEXT-validate_open | — | expected bad_request, observed validation_failed | passed |
| CONT-DV_DATE-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE_TIME-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE_TIME-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_DURATION-validate_fields | — | expected created, observed validation_failed | passed |
| CONT-DV_DURATION-validate_fields_range | — | expected created, observed validation_failed | passed |
| CONT-DV_DURATION-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_IDENTIFIER-validate_all_list | — | expected created, observed validation_failed | passed |
| CONT-DV_IDENTIFIER-validate_all_pattern | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DURATION-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DURATION-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_ORDINAL-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_PROPORTION-validate_ratio_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_SCALE-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_range | — | expected created, observed validation_failed | passed |
| CONT-DV_MULTIMEDIA-validate_media_type | — | expected created, observed validation_failed | passed |
| CONT-DV_PARSABLE-validate_value_formalism | — | expected created, observed validation_failed | passed |
| CONT-DV_TEXT-validate_open | — | expected bad_request, observed validation_failed | passed |
| CONT-DV_TIME-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_TIME-validate_range | — | expected created, observed validation_failed | passed |
| CONT-EVENT-state_ex_mand | — | expected bad_request, observed validation_failed | passed |
| CONT-EVENT-state_ex_opt | — | expected bad_request, observed validation_failed | passed |
| CONT-EVENT-type_any | — | expected created, observed validation_failed | passed |
| CONT-EVENT-type_interval_event | — | expected created, observed validation_failed | passed |
| CONT-EVENT-type_point_event | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_1plus-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_1plus-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3plus-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3plus-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3to5-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3to5-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_any-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_any-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_mand-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_mand-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_opt-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_opt-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HISTORY-events_cardinality_count6 | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_any | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_list | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_single | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_table | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_tree | — | expected created, observed validation_failed | passed |
| CONT-OBS-state_ex_mand-protocol_ex_mand | — | expected bad_request, observed validation_failed | passed |
| CONT-OBS-state_ex_mand-protocol_ex_opt | — | expected bad_request, observed validation_failed | passed |
| CONT-OBS-state_ex_opt-protocol_ex_mand | — | expected bad_request, observed validation_failed | passed |
| CONT-OBS-state_ex_opt-protocol_ex_opt | — | expected bad_request, observed validation_failed | passed |
| I_DEFINITION_ADL14.delete_archetype-clinical_forbidden | — | expected forbidden, observed not_found | passed |
| I_DEFINITION_ADL14.upload_opt-invalid_opt | — | expected validation_failed, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-largest_published | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-valid_opt | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-valid_opt_twice_conflict | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-valid_opt_twice_no_conflict | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.validate_opt-invalid_opt | — | expected validation_failed, observed not_acceptable | passed |
| I_DEFINITION_ADL14.validate_opt-valid_opt | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_QUERY.list_queries-prefix_all_versions | — | [0]/name: path resolves to nothing | passed |
| I_DEFINITION_QUERY.list_queries-version_get_xml_not_acceptable | — | expected not_acceptable, observed ok | passed |
| I_DEFINITION_QUERY.list_queries-xml_not_acceptable | — | expected not_acceptable, observed ok | passed |
| I_DEFINITION_QUERY.store_query-default_slot_with_higher_version | — | header Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/org | passed |
| I_DEFINITION_QUERY.store_query-dotted_name | — | expected stored, observed bad_request | passed |
| I_DEFINITION_QUERY.store_query-unqualified_name | — | expected stored, observed bad_request | passed |
| I_DEFINITION_QUERY.store_query-update_in_place | — | header Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/org | passed |
| I_DEFINITION_QUERY.store_query-version_duplicate_case_variant_name | — | expected conflict, observed stored | passed |
| I_DEFINITION_QUERY.store_query-version_prefix_rejected | — | expected bad_request, observed stored | passed |
| I_DEFINITION_QUERY.store_query-version_prerelease_rejected | — | expected bad_request, observed stored | passed |
| I_EHR_COMPOSITION.get_versioned_composition-malformed_uid | — | expected bad_request, observed not_found | passed |
| I_EHR_CONTRIBUTION.commit_contribution-delete_directory | — | expected created, observed not_found | passed |
| I_EHR_CONTRIBUTION.commit_contribution-deleted_member_with_data | — | expected validation_failed, observed created | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_incomplete_lifecycle | — | expected validation_failed, observed created | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_type | — | expected conflict, observed validation_failed | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_type_deleted | — | expected conflict, observed not_found | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_valid_combinations | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.commit_contribution-fail_modify_non_existing_directory | — | expected validation_failed, observed precondition_failed | passed |
| I_EHR_CONTRIBUTION.commit_contribution-full_ehr_status | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.commit_contribution-minimal_ehr_status | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.commit_contribution-non_exiting_opt | — | expected template_not_found, observed validation_failed | passed |
| I_EHR_CONTRIBUTION.commit_contribution-update_existing_directory | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.commit_contribution-valid_directory | — | header Last-Modified: expected present, got none | passed |
| I_EHR_CONTRIBUTION.get_contribution-version_ref_shape | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.create_directory-ehr_not_modifiable | — | expected updated, observed precondition_missing | passed |
| I_EHR_DIRECTORY.create_directory-root_archetype_id_mismatch | — | expected validation_failed, observed created | passed |
| I_EHR_DIRECTORY.create_directory-versioned_id_items | — | expected created, observed bad_request | passed |
| I_EHR_DIRECTORY.create_directory-wide_id_items | — | expected created, observed bad_request | passed |
| I_EHR_DIRECTORY.delete_directory-ehr_with_directory | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.delete_directory-empty_ehr | — | expected not_found, observed precondition_failed | passed |
| I_EHR_DIRECTORY.delete_directory-etag_names_new_version | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.get_directory-deleted_head | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.get_directory_at_time-deleted_at_time | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.get_directory_at_version-deleted_version | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.update_directory-empty_ehr | — | expected not_found, observed precondition_failed | passed |
| I_EHR_DIRECTORY.update_directory-root_archetype_id_mismatch | — | expected validation_failed, observed updated | passed |
| I_EHR_DIRECTORY.update_directory-stale_if_match | — | header ETag: expected the latest version uid, got none | passed |
| I_EHR_SERVICE.create_ehr-committal_headers | — | commit_audit/description/value: path resolves to nothing | passed |
| I_EHR_SERVICE.create_ehr-invalid_status | — | expected validation_failed, observed created | passed |
| I_EHR_SERVICE.create_ehr-wrong_method | — | header Allow: expected a value matching “.*(GET.*POST|POST.GET).”, got none | passed |
| I_EHR_SERVICE.get_ehr-malformed_ehr_id | — | expected bad_request, observed not_found | passed |
| I_EHR_STATUS.clear_ehr_modifiable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_modifiable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_modifiable-invalid_body | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.clear_ehr_modifiable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_queryable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_queryable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_queryable-invalid_body | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.clear_ehr_queryable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_ehr_status-at_time_future | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_ehr_status-at_time_omitted | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_ehr_status_at_version-addressed_version | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_versioned_ehr_status-at_time_future | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_versioned_ehr_status-at_time_omitted | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_versioned_ehr_status-contained_uid_form | — | header Last-Modified: expected present, got none | passed |
| I_EHR_STATUS.get_versioned_ehr_status-container_shape | — | owner_id/type: “ehr” != expected “EHR” | passed |
| I_EHR_STATUS.get_versioned_ehr_status-xml | canonical-xml | header Last-Modified: expected present, got none | passed |
| I_EHR_STATUS.set_ehr_modifiable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-invalid_body | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.set_ehr_modifiable-missing_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-invalid_body | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.set_ehr_queryable-missing_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-rm_version_empty | — | expected validation_failed, observed bad_request | passed |
| I_EHR_STATUS.set_ehr_queryable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_ITS_REST_REVISION_HISTORY.versioned_ehr_status_revision_history-two_versions | canonical-json | expected updated, observed precondition_missing | passed |
| I_QUERY_SERVICE.execute_ad_hoc_query-bare_ehr_scope_header | — | row count 100 != expected 1 | passed |
| I_QUERY_SERVICE.execute_ad_hoc_query-bare_ehr_scope_param | — | row count 100 != expected 1 | passed |
| I_QUERY_SERVICE.execute_ad_hoc_query-unknown_ehr_scope | — | expected not_found, observed ok | passed |
| I_QUERY_SERVICE.execute_stored_query-fetch_with_top | — | expected stored, observed bad_request | passed |
Both servers’ capability conformance, from each party’s committed verdicts (generated, diff-guarded; see Conformance for how to read the grid):
And the per-chapter outcomes side by side. Both charts render the same
chapter-and-band taxonomy, so they read band-for-band: a band EHRbase did not
exercise shows as an explicit no cases row in the same position. Compare the
printed counts, not the bar lengths: each chart scales its bars to its own
widest band, and the legend states that scale.
How to read the EHRbase column
Three mechanisms decide what the EHRbase column can say, and all three are visible in the committed record:
- Selection. A case is in EHRbase’s verdict scope only if its capabilities intersect what EHRbase claims and its behaviour is dated at or below the specification versions EHRbase declares. EHRbase declares ITS-REST 1.0.3 while the catalogue realizes 1.1.0, so every Release-1.1.0-dated behaviour is cited out of scope rather than driven. Unclaimed surfaces drop out the same way, and they drop out before the request: a case whose capabilities a party’s statement does not claim is recorded not applicable with its citation instead of being driven, so an unclaimed surface cannot produce a red row at all. Anything a record still carries from before that rule reached the driver is excluded from the in-scope tally above and is not listed as a divergence below. The headline is the verdict scope for exactly that reason.
- Inconclusive, not failed. A row is inconclusive when EHRbase answered with a status the operation’s specification-cited outcome map does not contain, or when the exchange that would have established the case’s ground was refused. The runner never guesses a verdict, and an inconclusive row is never counted as a failure of the behaviour under test.
- Report-only grounds. Where a case rests on an openEHR silence our ambiguity register marks report-only (an upstream problem report still open) the row is recorded but does not gate either party’s verdicts.
Where the red rows do concentrate is the content chapter: no content case passed (each one either failed or never established its ground) and the direction is the opposite of a permissive server. The catalogue’s content cases are accept/reject decision tables committed against a template the case itself provisions, and in the ones that ran, EHRbase’s rejected rows pass while its accepted rows fail: it refuses documents its own template admits. Some rows require a plain bad request rather than a semantic refusal (the document is malformed, not merely invalid), and there EHRbase refuses too, in the wrong class.
The principal EHRbase divergences, stated plainly
Each item below is a red row in the committed EHRbase record, restated from what that record holds: the outcome the specification-cited expectation required versus the outcome observed. Except where marked, every one was selected for EHRbase’s own declared ITS-REST 1.0.3; the Release-1.1.0-dated behaviours never reach this list. The record carries the case id, the failing step, the rows driven and the reason string. It is not a wire transcript, so nothing is quoted here beyond what it actually captured.
- Every
EHR_STATUSwrite is refused. Setting or clearingis_queryable/is_modifiableis realized as a read-modify-write: read the status, flip the flag,PUTit back with the version identifier from the server’s ownETagin a quotedIf-Match, the exact form the released overview’s own example shows. Every one of those rows, including the plain happy path, answers400instead of updating: the status the same released section reserves for a client that did not provideIf-Matchat all. The rest of that path is therefore unreadable: a row meant to distinguish a semantic refusal from a malformed request gets that same400, so the record cannot say what EHRbase’s validation would have done. - No
Last-Modifiedanywhere. The released overview says bothETagandLast-ModifiedSHOULD be included for versioned resources; EHRbase sends noLast-Modifiedon any of them: contribution commits, contribution reads, directory reads, updates and deletes, and the versionedEHR_STATUSreads in JSON and XML alike. It is the single widest header difference in the record. Relatedly, a staleIf-Matchon a directory update is refused without theETagnaming the latest version that the refusal is supposed to carry. - Some model-invalid content is accepted.
POST /ehrwith anEHR_STATUSthat is an archetype root carrying noarchetype_detailsis created rather than refused, while the four sibling rows in the same case (missing mandatory members, an undecodable polymorphic slot) are refused correctly, so this is a validation gap, not a missing validator. A directory whose root archetype identifier does not match the one required is likewise accepted, on both the create and the update path, and a CONTRIBUTION member marked deleted that still carries data commits instead of being refused. - Contribution refusals land in the wrong family. A CONTRIBUTION whose
EHR_STATUSmember carries an invalid change type is refused as a semantic validation error where the catalogue requires a conflict; the variant that deletes the mandatoryEHR_STATUSanswers not-found instead. A CONTRIBUTION naming a template that does not exist answers a generic validation error rather than the template-not-found branch, and a modification against a directory that does not exist answers a failed precondition. - A missing directory is dressed as a failed precondition. Both the update
and the delete of a directory on an EHR that has none answer
412 Precondition Failedwhere the released text requires not-found. HTTP requires the opposite order, since a failure detectable before the precondition is evaluated takes precedence over evaluating it. On the same operation a genuinely staleIf-Matchanswers409, a status the operation’s outcome map does not contain at all, so that row is recorded inconclusive rather than red. - A malformed identifier in the path answers not-found, not bad request. Given a path segment that is not a UUID at all, both the EHR read and the versioned-COMPOSITION read report a miss instead of a client error. The treatment is not uniform: the malformed version-identifier rows pass.
- Stored-query names the released grammar admits are refused. The released
qualified-name grammar makes the namespace optional (it lists a plain
my_compositionsamong its valid examples) and puts the dot inside the query-name character set, with::as the only separator. Both a plain unqualified name and a namespace-less dotted name are refused as bad requests. A third store (a fully qualified name whose AQL carries aTOP) is refused as well, so the paging conflict that case exists to test never got driven. - The stored-query listing does not honour the prefix. The released list
operation’s own worked example lists “all versions of all queries with names
starting with
org.openehr”. After storing two names under one prefix, the case’s prefix listing comes back with no first row to read anamefrom at all, so a client cannot discover what a namespace holds. - Version-grammar refusals are missing at the store. A version that is only a prefix and a pre-release version are both stored where the catalogue requires a bad request, and a second store under a case-variant of an existing name is accepted where a conflict is required.
- An XML
Accepton a JSON-only operation is answered, not refused. The released stored-query listing declaresAccept: application/jsonand nothing else, and the overview requires406 Not Acceptablewhen an XML request cannot be fulfilled. Both the listing and its version-get sibling answer success instead. - A query scoped to one EHR is executed over all of them. A client
restricts an ad-hoc query to a single record with the
ehr_idquery parameter or theopenehr-ehr-idrequest header. The service model defines that input as the specific set of EHRs on which to execute the query, and the released REST query chapter says the same thing from the other side: a population query is one that does not use the parameter to constrain the scope. EHRbase discards both carriers. In the record, a bare projection over EHRs scoped to one freshly created EHR comes back at the request’s own row limit instead of the single scoped row, and a scope naming an EHR that does not exist answers an ordinary result set rather than reporting the unknown EHR. Reproduced live against a composed EHRbase, its own response metadata discloses the query it actually executed, with a row limit appended and no EHR predicate added; the header behaves exactly like the parameter. Naming the EHR inside the AQL works, so what is dropped is specifically the request-level scope. Stated at its true strength: the released REST text puts support for these parameters at SHOULD and the semantics are the service model’s, so this is a divergence from service-model semantics on a released carrier, not the breach of a REST-level MUST. - A
405carries noAllowheader, which RFC 9110 makes mandatory on that status; and the versioned-EHR_STATUScontainer names its owner with a lower-caseehrtype where the Reference Model’s object reference spells itEHR. - (1.1.0-grounded) The template upload refuses a JSON
Accept. Asked forapplication/jsonit answers406(the refusal body the record captured reads"No acceptable representation") and serves XML only, while the released parameter enumeration for that header lists JSON first. This single refusal is what makes most content-chapter rows inconclusive: the runner’s provisioning uploads ask for JSON, EHRbase refuses, and the case’s ground never exists.
Two things the record deliberately does not support, and therefore are not claimed here: it captures no response bodies beyond the few a case records, so no error text is quoted above that the record does not contain; and it says nothing about EHRbase’s canonical-XML root namespace: the case that would have established it never got past provisioning, and the negotiated-namespace siblings are out of scope for EHRbase’s declared options.
Where the difference is a reading of a silence
Some red rows are not EHRbase’s fault, and are called out rather than counted.
- Which version a version-less stored-query store lands on. A store with no version in the URL has to land at some version, and no released sentence says which. Our suite pins the lowest slot because a suite must pin something; EHRbase continues the existing series instead, so a store beside an already-stored higher version takes the next one up. Both are defensible readings, so those two rows record a difference of house convention. The open question is with openEHR, not with either implementation.
One half of the EHR-scope row above is a silence too, and a much narrower one than this page previously claimed: the service model declares an error for a query scoped to an EHR that does not exist, and the released REST text binds that error to no status, so which refusal a conformant server owes is genuinely open, and our suite resolves it to not-found. What the scope does is not open, which is why that row is published as a divergence rather than as a reading.
Every silence is recorded in the runner’s typed ambiguity register and reported upstream: a specification silence is never resolved privately, and never resolved by looking at what a server happens to do.
Performance
Both systems run the same committed step-load stress instrument
(veredictum stress) on their own freshly seeded cnf.scale.10k
corpus: the geometric ladder climbs until the system leaves the envelope
(p99 over the budget or errors past tolerance), then bisects to the
maximum sustainable throughput. Every number derives from the two
committed stress.json reports; each load step embeds re-checkable
histograms and, where sampled, per-container resource telemetry.
| max sustainable throughput | worst p99 at the knee | DB peak CPU at the knee | SUT peak RSS at the knee | |
|---|---|---|---|---|
| ferroehr | 512 req/s | 134 ms | 101 % | 247 MB |
| EHRbase | 0 req/s | — ms | — % | — MB |
A stress report is exploration evidence: it earns no conformance class (classes are earned exclusively by the hour-long measured class runs) and carries no class vocabulary — the chart shows where each system breaks.
Reading the stress table honestly
The step-load ladder declares a rate sustained only if the whole envelope holds at that rate: the latency budget and the error budget. EHRbase’s ladder found no sustained rung, and its own report records two simultaneous breaches on every rung it tried, down to the lowest one:
- The error tolerance. Composition commits and updates, contribution
commits,
EHR_STATUSupdates, directory creates and the item-tag operations error deterministically; these are the same wire refusals the conformance section above lists, met again under load. - The latency budget, on the ward-round query, by more than an order of magnitude.
Because both breaches are already present at the ladder’s lowest rung, neither is a throughput ceiling: EHRbase’s entry in the generated table says the envelope never held, not how fast the server can go, and it must not be read as a speed comparison. The per-operation latencies its report carries are honest observations from those same rungs.
Method, in one paragraph
The conformance instrument derives every expected outcome from the openEHR
specifications (never from either server’s observed behaviour) and runs
against real composed deployments of both systems (scripts/conformance.sh,
CONF_SUT=ehrbase for the EHRbase side). The stress instrument drives the same
hospital-simulation workload (admissions, observations, medication rounds, lab
contributions, chart reviews, corrections, discharges) built from official CKM
templates with seeded determinism, so both servers receive byte-identical
requests from the same corpus; latencies are coordinated-omission-corrected
from each request’s planned arrival instant, and the ladder bisects to the last
rate held inside the envelope. Each run records its own environment, and the
two runs’ environments differ. The full method chapters:
Conformance · Benchmarks.
Rust crates
FerroEHR’s openEHR specification layer is published on crates.io as eight 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, 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, and canonical printer |
openehr-its | ITS-JSON, ITS-XML, ITS-REST 1.1.0 | Canonical JSON + XML codecs, the generated ITS-REST contract, OPT 1.4, Simplified Formats (FLAT/STRUCTURED/Web Template) |
[dependencies]
openehr-rm = "0.0.64"
openehr-its = "0.0.64"
All eight are edition 2024 with an MSRV of Rust 1.97, and all eight
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-itsyou need - Releases
- 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 eight 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 three crates implement one specification each and do expose
one: openehr_its::SPEC_VERSION, openehr_query::SPEC_VERSION,
openehr_adl::SPEC_VERSION.
Taking only the part of openehr-its 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 → flat, each pulling in the one below it. Three
layers sit beside the spine and are declined independently: cache (the
async WebTemplate cache, the crate’s only moka user), schema-validation
(validation against the compiled-in ITS-JSON RM schema, its only jsonschema
user) and rest-server (the generated ITS-REST contract and its response
runtime, which is what brings in axum). The default feature full is all
seven, so an existing dependency line keeps the crate it had.
flat and opt14 build for wasm32-unknown-unknown, so a browser consumer
can call flat::build_web_template or read an OPT 1.4 template:
[dependencies]
openehr-its = { version = "0.0.64", default-features = false, features = ["flat"] }
With default-features = false and no feature at all, the crate 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 eight 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 eight resolve at the
same version, because while the line is 0.0.x a straggler makes its
siblings’ internal requirements unresolvable for every consumer.
Licensing
The eight crates fall into two groups.
The five generated model crates (openehr-base, openehr-rm, openehr-am,
openehr-lang, openehr-term) 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), which is Apache-2.0 as well, and their generated files name the openEHR
Foundation as a second copyright holder.
The three hand-written engines are under the Business Source License 1.1,
the licence of the FerroEHR application (each ships its LICENSE with the
parameters): openehr-query and openehr-adl declare BUSL-1.1 and ship
only their own Rust sources, the README and that text; openehr-its declares
BUSL-1.1 AND Apache-2.0, because its generated codecs and REST contract
derive from the Apache-2.0 openEHR XSD, OpenAPI and BMM artifacts and it
packages the vendored ITS-JSON schema as bytes, so it ships both texts and 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. Non-production use of these three 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; from 0.0.60 the three engines are BUSL-1.1 as stated above. 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.98.1), so rustup installs the right version automatically on your first
build. The declared minimum supported version is lower (Rust 1.97) 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
# clippy is three lanes: the viewer's `hydrate` and `ssr` features are
# mutually exclusive, so it is excluded from the workspace lane and linted
# per-feature on both of its targets.
cargo clippy --workspace --exclude ferroehr-viewer --all-targets --all-features -- -D warnings
cargo clippy -p ferroehr-viewer --all-targets --features ssr -- -D warnings
cargo clippy -p ferroehr-viewer --target wasm32-unknown-unknown --features hydrate -- -D warnings
# rustdoc lints + doctests (the rustdoc lint table is inert without a doc run)
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --exclude ferroehr-viewer \
--all-features --no-deps --document-private-items
RUSTDOCFLAGS='-D warnings' cargo doc -p ferroehr-viewer --features ssr --no-deps
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 an entry to the changelog 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
telemetry.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
This page is the complete licensing picture for FerroEHR: what the project’s own code is licensed under, which third-party material ships inside the repository and the container images, and the trademark and lineage acknowledgments. It is a summary for evaluators and deployers, not legal advice.
- Do you need a commercial licence?
- FerroEHR’s own code: the Business Source License 1.1
- Vendored third-party material
- Machine-readable licensing (REUSE 3.3)
- Rust dependencies
- Trademarks and lineage
- Questions
Do you need a commercial licence?
FerroEHR is source-available under the Business Source License 1.1, which is
not an OSI-approved open-source licence.
LICENSE is the
authority and names the Licensor, the Licensed Work, the Additional Use Grant
and the Change Date; this table is the same boundary in the order people ask
about it, and the sections below give the full text of each rule.
| 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.
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 /ferroehr/rest/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.
Each version becomes Apache License 2.0 four years after that version is
published. A commercial licence starts with a short conversation with the
maintainer named in
MAINTAINERS.md.
The eight openehr-* crates on crates.io are a separate question. The five
generated model crates are Apache-2.0, so any Rust project can use them without
a licence conversation; the three hand-written engines carry the same Business
Source License as the application. Full detail is under
FerroEHR’s own code
below.
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 three hand-written
specification engines openehr-query, openehr-adl and openehr-its) is licensed under the
Business Source License 1.1,
SPDX identifier BUSL-1.1. The Licensor is Ruben Talstra. The copyright holder
is stated as Ruben Talstra, identically in LICENSE, in REUSE.toml,
and in every first-party file header; a CI gate compares the three so they
cannot drift apart.
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.
What the licence allows without asking anyone. You can read the source, build it, modify it, and redistribute it without a fee. All non-production use is permitted: development, testing, evaluation and prototyping. Production use is permitted for Non-Commercial Purposes, which the licence defines as 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.
What needs a commercial licence from the Licensor. Any other production use, including the delivery of health care or any other service for payment. A hospital, clinic or care provider running FerroEHR for its patients needs a commercial licence, and so does a vendor, integrator or any company running it in production. Two uses need one in every case, whoever you are:
- offering FerroEHR, or a work derived from it, to third parties as a hosted, managed or embedded service, meaning a service through which anyone other than you and your affiliates stores, manages or queries health data held by it;
- selling, sublicensing or otherwise distributing FerroEHR for a fee, on its own or as a component of another product.
Companies and care providers building on FerroEHR are wanted here, and the
commercial licence is the normal path for them. It starts with a short
conversation with the maintainer named in
MAINTAINERS.md:
Ruben Talstra, @rubentalstra on GitHub.
The Change Date. 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. Relicensing changes future
versions only.
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.
Five of the eight published openehr-* spec crates are the exception: they
are Apache-2.0, not BUSL-1.1. 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, so any Rust project can use them, in proprietary and hosted
products included, with no commercial licence involved. 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.
The three hand-written engines, openehr-query (the AQL parser), openehr-adl
(the ADL engine) and openehr-its (the canonical codecs, the REST contract and
the Simplified Formats), 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. openehr-its declares
BUSL-1.1 AND Apache-2.0: its generated codecs and contract derive from the
Apache-2.0 openEHR XSD, OpenAPI and BMM artifacts and it embeds the vendored
ITS-JSON schema, so it ships both texts and names the openEHR Foundation as a
second holder. 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, 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 | European Data Protection Board | LicenseRef-EDPB-Reuse |
Dutch acts and decrees (UAVG, Wabvpz, BW Boek 7, 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 stateApache-2.0, those ofopenehr-itsstateBUSL-1.1 AND Apache-2.0, and every other first-party Rust, shell, SQL and YAML file, theopenehr-queryandopenehr-adlsources 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, and the file headers. - 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.
A separate FOSSA lane publishes dependency and license analysis from the
committed CLI configuration for review. It is analysis-only by design and gates
no merge; cargo deny is the gate.
Trademarks and lineage
- 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. The measured comparison between the two is published in both directions.
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.
- 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")]
What is planned
One piece of the programme (#3152 closed with v4.2.0) is still open, and it is about reading across the boundary for secondary use rather than holding the boundary.
| Planned control | Issue |
|---|---|
| A secondary-use read model as a separate pseudonymisation domain, fed from the outbox, under project-level pseudonyms | #3160 |
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. Until the read model lands, secondary use runs
on 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 | shipped, #3153, #3158, #3226 | Run the production profile with the separations it asserts, 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 subject proxy of a subject whose only record this was goes with it, 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 | 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, tenancy that fails closed, audit on by default | shipped; the by-default setting Art. 25(2) asks for, so that a record is not accessible without the individual’s intervention, is planned, #3323 | Choose the restrictive settings. Two defaults favour compatibility instead: the per-EHR access default is open, and the audit fail mode is open |
| 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 {base}/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. The FerroEHR project operates nothing and is not your processor |
| 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 {base}/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; the secondary-use read model as its own pseudonymisation domain is planned, #3160 | Decide whether the purpose can be met without identification and take that route where it can. Until the read model lands, secondary use runs on the primary store |
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 | shipped | Build the patient-facing access route; the CDR exposes the trail to an admin caller, not to the patient |
| 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 II documentation items, and the access trail records the logging component’s elements (audit) | readiness shipped, #3168, #3169, #3170, #3171; the exchange format itself is an open question until the Article 36 implementing acts fix it | Follow the implementing acts; the conformity assessment and the EU declaration are the manufacturer’s, and who that is for a source-available CDR is stated on the readiness page |
| Chapter IV, secondary use | AQL over the stored record, a change-event outbox, and cohort queries across the pseudonymisation boundary with small-cell suppression | shipped, #3159; a separate pseudonymisation domain for secondary use is planned, #3160 | Deal with the health data access body; a CDR is not a data-holder’s permit process |
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; a separate secondary-use domain under project-level pseudonyms is planned, #3160 | 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.external], which is the interface gematik’s terminology server exposes | shipped | Point the terminology route 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; the rebuildable read model under project-level pseudonyms is planned, #3160 | 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 | shipped | 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 {base}/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; a separate secondary-use domain under project-level pseudonyms is planned, #3160 | 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; FerroEHR has no emergency-access mode and 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: which obligation is the software’s and which is yours, obligation by obligation.
- 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
Almost every obligation in EU and national health-data law rests on the controller, and where FerroEHR is operated on that controller’s behalf, on the processor. A repository supplies technical measures. It cannot hold a legal basis, sign a processing agreement, notify a supervisory authority or run a management system.
This page draws that line obligation by obligation, so you can see at a glance which part of the work the software has already done and which part is still yours. No openEHR specification governs any of it; the division below follows the legal texts each row links.
How to read the tables
Each row names one obligation and links its official source. The middle column is what FerroEHR provides, linked to the page that documents it, or to the open issue when the control is planned rather than shipped. The right column is the work that stays with the deploying organisation.
“Nothing” in the middle column is a real answer and appears wherever it is the true one.
Note
The FerroEHR project is not your processor. It publishes software; it operates nothing on your behalf and holds none of your data. Where a row says “the processor”, it means whoever runs the deployment, which may be you.
GDPR
Every row below cites Regulation (EU) 2016/679. The duties in it belong to the controller and the processor. The middle column is only the technical measure FerroEHR supplies toward one of them.
| Obligation | What FerroEHR provides | What the deploying organisation does |
|---|---|---|
| Art. 5(1)(e) storage limitation | Audit-trail retention as a configured period, and irreversible physical deletion of an EHR through the admin API | Set the retention schedule and execute it; openEHR versions are append-only until you delete the record |
| Art. 5(2) accountability | An audit trail of every access, openEHR’s own contribution and audit chain on every write, and a published conformance record | Retain the evidence and be able to produce it on demand |
| Art. 6 and Art. 9(2) legal basis and the condition for health data | Nothing. Software cannot hold a legal basis | Establish the basis and the Art. 9(2) condition, per purpose, before data is entered |
| Art. 24 and Art. 25 responsibility, and protection by design and by default | Deny-by-default authorization, per-EHR access settings, tenancy that fails closed, audit on by default | Choose the restrictive settings, and document why the chosen configuration is appropriate |
| Art. 28(3)(e) to (h) what a processor’s contract must let it do | The rights operations in the rows below for (e); the trail, the threat model and the published control documentation for (f); EHR Extract export and physical deletion at the end of service for (g); GET {base}/admin/config, the trail and verifiable release artifacts as the evidence (h) asks for | Conclude the processing agreement with whoever operates the deployment, and audit them |
| Art. 30 records of processing activities | The effective configuration as a redacted tree at GET {base}/admin/config, and this book as a description of what the software does | Write and maintain the record; only you know the purposes, the recipients and the transfers |
| Art. 32 security of processing | TLS 1.3 with optional mutual authentication, authentication and access control, per-version signing, a tamper-evident audit chain, domain-separated database roles | Supply everything below the application: the database, its backups, the network, the platform. See Cluster hardening |
| Art. 32(1)(d) regularly testing the measures | A storage-integrity sweep and rebuild, an audit-chain verification query, and a conformance suite that runs against your own server | Schedule the checks, alert on their output, and test your restore |
| Art. 33(3)(a) and 34(3)(a) breach notification, and the measures that remove the duty to tell patients | The evidence a breach assessment needs: who read what, when, per patient and per agent, and whether the trail itself is intact | Detect, assess and notify within the deadlines. 34(3)(a) lifts the duty to inform patients only where the measures were applied to the data affected, and the application seals national identifiers only, so encryption of clinical content at rest is the database’s and the disk’s. The regulation says nothing about encryption at rest; the measure is yours to choose |
| Art. 12(3) act on a rights request within one month, electronically | Every right below is served by an API call, so the answer is produced in the run that handles the request | Start the clock at receipt, verify the requester, and use the two-month extension only with the reasons the article asks for |
| Art. 15 and 20 access, a copy, and portability | The full record over the openEHR REST API in canonical JSON or XML, the simplified FLAT and STRUCTURED formats, and EHR Extract export for a whole record, which another openEHR system imports directly; the recipients of 15(1)(c) from the per-patient trail search | Authenticate the data subject and build the patient-facing route. The purposes and the storage period of 15(1) come from your own records, and the portability right reaches consent-based or contract-based processing only (20(3) excludes a public-interest task) |
| Art. 16 and 17 rectification and erasure | Versioned correction with the prior version retained, which is the supplementary statement Art. 16 allows, and physical, irreversible deletion of an EHR and everything it owns, over the primary and the cold archival tier alike | Decide how an erasure request interacts with the medical record-keeping duty and with the Art. 17(3) grounds, record the decision, and reach the backups and the copies outside the CDR |
| Art. 18 and 21 restriction and objection | A restriction register at whole-EHR or single-object grain, with the marked object refused on every read, query, export, event and write path while its stored rows stay untouched, and its lift stamped rather than erased (#3324); a research objection under Art. 21(6) that removes the record from population queries, exports and the event stream while leaving reads for care untouched (#3325) | Decide when to set them, record the ground, hold the restriction in the systems around the CDR, and tell the subject before lifting it |
| Art. 19 telling each recipient about a rectification, erasure or restriction | An access record for every read and export naming the agent, the patient, the action, the outcome and the time, so the recipient list is answerable from the trail | Send the communications, and name the recipients to the subject on request. The trail records who received data; it notifies nobody, and the regulation sets no retention period for an access log, so how far back the list reaches is the retention you configure |
| Art. 35 data protection impact assessment | A DPIA page to assess against (processing description, data categories per schema, roles, retention, risk register, shipped controls by issue), records of processing pre-filled with what the software does, and a go-live checklist | Run the DPIA and keep it current. It is the controller’s, and no supplier document replaces it |
| Art. 4(5) pseudonymisation | Clinical data, demographic data and the EHR id / subject cross-reference live in three separate schemas with non-overlapping NOINHERIT roles, and the server refuses to boot if a role reaches across (the boundary). The cross-reference — which party and which subject identifier name which EHR, the openEHR Service Model’s EHR Index — is resolved only through the linkage pool, every resolution, merge and split an audited linkage access record (#3158, #3345); the clinical schema keeps only the guarded EHR_STATUS subject pair the openEHR wire binds to, and deleting an EHR removes the cross-reference rows naming it | Give the demographic and linkage pools their own DSNs, restrict who may resolve, and hold any additional information outside the CDR to the same standard |
| Art. 89(1) research safeguards, and anonymising where the purpose allows it | Cohort queries that answer a research question as an aggregate, withheld below the configured small-cell threshold; secondary use leaves the repository through the FerroBRIDGE product to the OMOP CDM, pseudonymised per permit there; the repository’s side of that path (the restriction, objection and retention marks every export honours, a resumable batch export, an access event per export) is planned in #3379 | Decide whether the purpose can be met without identification and take that route where it can. Until the export lands, secondary use runs on the primary store |
EHDS
Regulation (EU) 2025/327 is in force, and its operative obligations apply from the dates its own final provisions carry. No row below claims conformity with any of them.
| Obligation | What FerroEHR provides | What the deploying organisation does |
|---|---|---|
| Chapter II, primary use and the patient’s sight of who accessed their data | An access trail of every read, write and refusal, searchable by patient and by agent, and a subject-scoped read grant sized for a patient portal | Build the patient-facing access route on that grant and authenticate the person; the product serves one subject’s log to it, never every patient’s |
| Chapter III, EHR systems: the European interoperability and logging software components, and published technical documentation | The EHDS readiness page with a status and evidence per Annex II requirement, the technical documentation page per Annex II item, and an access trail carrying the logging component’s elements; the exchange format is an open question until the Article 36 implementing acts fix it | Follow the implementing acts and, when a deployment is placed as an EHR system, carry the manufacturer’s conformity assessment and declaration |
| Chapter IV, secondary use | AQL over the stored record and a change-event outbox; the batch export the FerroBRIDGE OMOP load consumes, filtered by the restriction and objection marks, is planned in #3379 | Deal with the health data access body and carry the data holder’s duties |
National law
The sections above apply to every EU deployment. This one is a single country’s law on top of them, three countries so far: the division a deployment reads is “the EU layer, plus my own jurisdiction”. The compliance overview says what adding another takes (National law).
The Netherlands
| Obligation | What FerroEHR provides | What the deploying organisation does |
|---|---|---|
| UAVG Art. 30, the exception for health data | Access control at the record and attribute level, with every use audited | Establish that your processing falls inside the exception, per role and per purpose |
| UAVG Art. 46, processing a national identification number | National identifiers sealed at rest under the instance’s identifier-protection key in the demographic domain, resolved only through the demographic role, every resolution recorded as a linkage access without the value | 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 | Nothing. FerroEHR performs no BSN verification and consults no index | 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 REST API, and EHR Extract export | Authenticate the patient and build the route; the CDR has no patient-facing interface |
| Wabvpz Art. 15e, a record of who made data available and who consulted it | An ATNA trail recording the agent, the patient, the action, the outcome and the time, retrievable per patient | Render it for the patient, set retention, and review it |
| BW Book 7, Art. 454, the medical treatment contract’s record-keeping duty | Append-only version history, so a correction never destroys the prior version | Set the retention schedule the article requires, and reconcile it with erasure requests |
The Netherlands: NEN
The NEN 7510 family is where the split is sharpest. A management-system standard cannot be met by a product at all.
| Obligation | What FerroEHR provides | What the deploying organisation does |
|---|---|---|
| NEN 7510-1, the information security management system | Technical controls an ISMS can point at, each documented with its residual risk in the threat model | Run the ISMS: scope, risk assessment, policy, internal audit, management review |
| NEN 7510-2, the controls | Access control, audit logging, cryptography in transit and for version signatures, supply-chain verification | Everything organisational: personnel, physical security, supplier management, continuity |
| Certification against NEN 7510 | Nothing. A product cannot be certified against a management-system standard, and FerroEHR makes no such claim | Obtain and maintain the certificate for your organisation |
| NEN 7512, the trust basis for data exchange | Mutually authenticated TLS, OAuth2 and OIDC with an enterprise identity provider, SMART App Launch | Agree the trust basis with each counterparty, and operate the certificate estate |
| NEN 7513, logging actions on electronic patient records | An audit trail of every operation including refusals, in FHIR AuditEvent and DICOM PS3.15 form, hash-chained in the database | Map the recorded fields onto the standard’s own list, set retention, and review the trail |
Germany
| Obligation | What FerroEHR provides | What the deploying organisation does |
|---|---|---|
| BDSG § 22 Abs. 2, appropriate and specific measures for health data: traceability, access restriction, pseudonymisation, encryption | Versioned writes with contribution and audit, an access trail, deny-by-default authorization, the pseudonymisation boundary, TLS and sealed identifiers | Choose the measures, establish the lit. b ground, and keep the processing under persons bound by professional secrecy |
| BDSG § 27 Abs. 3, identifying characteristics stored separately for research, rejoined only as the purpose requires | Separate clinical, demographic and linkage schemas, and cohort queries that cross on identifiers only | Decide when to anonymise, and hold the balancing test |
| SGB V §§ 346 to 348, writing treatment data into the ePA once it is held in interoperable form | Template-structured records, the REST API and EHR Extract export | Operate the transport into the ePA, the connector and the information objects |
| SGB V § 339 Abs. 3 and § 352, credential-bound access with a log of who accessed what, under a closed role matrix | Role- and attribute-based authorization and an access trail naming agent, roles, patient, action and outcome | Bind the identity provider to the HBA and SMC-B; the sections bind the ePA, which the CDR is not |
| SGB V § 309, the TI access log with attempts, three years’ retention and deletion on expiry | A trail that records attempts and refusals, retention_days and the retention reaper | Set the retention owed; the section binds TI application controllers, not a CDR outside the TI |
| GDNG § 6, own-data secondary use under pseudonymisation, a rights-and-roles concept, logging and a thirty-year limit | Per-domain roles, cohort queries with small-cell suppression, an access record per query with purpose and legal_basis; the export to the FerroBRIDGE OMOP load, where pseudonymisation per permit happens, is planned in #3379 | Write the rights-and-roles concept, publish the purposes, run the clock, answer subjects from the trail |
| § 203 StGB Abs. 3 and 4, necessity-bounded access for those who keep the systems running, and the duty to bind them to secrecy | Separate operational surfaces, audited admin reads, one database role per domain | Bind operators and subcontractors to secrecy in writing, and route support so it needs no standing read of clinical content |
Switzerland
Switzerland is not an EU member state: the GDPR rows above do not apply to a Swiss deployment, and the DSG takes their place.
| Obligation | What FerroEHR provides | What the deploying organisation does |
|---|---|---|
| DSG Art. 7, data protection by design and by default | Deny-by-default authorization, a production deployment profile that refuses missing separations | Choose the restrictive settings where the shipped default favours compatibility |
| DSG Art. 8 with DSV Art. 3, the minimum security measures | Need-to-know authorization, one database role per domain, TLS 1.3, attributed versioned writes, an access trail with refusals, signed releases | Backup and restore, patching, breach detection and everything below the application |
| DSV Art. 4, logging including reads, kept at least a year separately from the processing system | The trail with every operation and its actor, time and outcome, and forwarding sinks that put a copy outside the CDR | Forward the trail, set the retention at a year or more, restrict who reads it |
| DSG Art. 12, the register of processing activities | The effective configuration at GET {base}/admin/config and records of processing pre-filled with what the software does | Write and maintain the register; DSV Art. 24 leaves no small-organisation exemption for a clinical repository |
| DSG Art. 22, the impact assessment for large-scale processing of sensitive data | The DPIA page with the technical description, the risk register and the controls by issue | Run the assessment; it is the controller’s |
| DSG Art. 25 and Art. 28, the right of access within 30 days and data portability in a common electronic format | The full record over the REST API, the EHR Extract, the published openEHR formats, and a per-patient search of the trail for the recipients | Identify the requester, render the answer understandably, route it, and meet the deadline |
| DSG Art. 31 Abs. 2 lit. e, research on anonymised data, with measures against identifiability meanwhile | Separate clinical, demographic and linkage schemas and cohort queries with small-cell suppression; the export to the FerroBRIDGE OMOP load, where pseudonymisation per permit happens, is planned in #3379 | Decide when anonymisation is possible and hold the research ground |
| EPDG Art. 10 and EPDV Art. 10 and 12, the certified community’s logging, storage, encryption and residency duties | IHE ATNA events over ITI-20 with ITI-19 mutual TLS, ITI-81 retrieval, the record in published formats, a self-hosted deployment | Feed the EPD through a certified community; the duties bind the community, which the CDR is not |
What this page does not do
It does not tell you whether your deployment satisfies any of these obligations. That answer depends on your legal basis, your organisation, your infrastructure and your operating practice, none of which a supplier can see.
The companion guidance is written: the DPIA page, the records of processing and the go-live checklist. Beside them, the compliance overview carries the legal sources, the control matrix carries the live status of every declared control, and the threat model carries the risk that survives each one.
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 |
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 category, 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 {base}/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 pair. An unknown
category, an unknown anchor rule or a non-positive period is 400.
GET {base}/admin/retention/policy reads the whole register back, which is what
an access answer and an audit both cite.
Anchoring an EHR and holding it
PUT {base}/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 {base}/admin/retention/hold
Content-Type: application/json
{ "vo_id": "df58b2ee-30bd-4b2c-9b7d-3a0f8e5c6d21", "held": true }
Reading what is due
GET {base}/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.
POST {base}/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 {base}/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 {base}/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 {base}/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: national rules set a floor below which an access log may not be reaped — five years in the Netherlands, one year in Switzerland — and one 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_days = 1095
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. 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.
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. A CI job re-runs the generator with --check and fails the
build when the committed page no longer matches the tracker, which is what
keeps a shipped control from sitting here as “planned”.
- 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. 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) | feat(ext): secondary use leaves through FerroBRIDGE to OMOP in batches over AQL: the CDR keeps the restriction, objection and retention marks, serves AQL over VERSION by time_committed as the batch surface and logs each population query; no in-CDR research domain | #3379 | Planned | — |
| 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 | feat(ext): secondary use leaves through FerroBRIDGE to OMOP in batches over AQL: the CDR keeps the restriction, objection and retention marks, serves AQL over VERSION by time_committed as the batch surface and logs each population query; no in-CDR research domain | #3379 | Planned | — |
| 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 |
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.
EHDS readiness
The European Health Data Space regulation puts obligations on EHR systems in its Chapter III: an EHR system must include two harmonised software components, meet the essential requirements of Annex II, and carry technical documentation and an EU declaration of conformity before it is placed on the market or put into service.
This page states, requirement by requirement, what FerroEHR provides today. It exists so an evaluator can see the real position rather than infer one, and so the project has a checklist rather than an intention.
Warning
No conformity assessment has been carried out. No technical documentation has been drawn up under Article 37, no EU declaration of conformity exists under Article 39, and FerroEHR is not registered under Article 49. A status of “Shipped” below means the software provides the capability — it is not a claim of conformity, and nothing on this page is one.
The regulation, and how to check this page against it
Regulation (EU) 2025/327 of the European Parliament and of the Council of 11 February 2025 on the European Health Data Space and amending Directive 2011/24/EU and Regulation (EU) 2024/2847 — OJ L series, 2025/327, 5.3.2025.
Every requirement identifier, heading and date on this page was read from that published text on 2026-09-10. The wording in the “Requirement” column is this project’s own paraphrase for navigation; the regulation is the authority and its text governs. Where the two differ, the regulation is right and this page is a defect worth reporting.
When it applies
- 26 March 2027 — The Regulation applies from this date.
- 26 March 2029 — Articles 25, 26, 27, 47, 48 and 49 apply to the priority categories of personal electronic health data in Article 14(1)(a), (b) and (c), and to EHR systems the manufacturer intends to process them.
- 26 March 2031 — The same articles apply to the categories in Article 14(1)(d), (e) and (f), and Chapter III applies to EHR systems put into service in the Union as described in Article 26(2).
The last of those matters most here: a deployment that a health institution runs for itself, or that is offered as a service, is put into service under Article 26(2) rather than placed on the market.
The two harmonised software components
Article 25(1) requires an EHR system to include both.
| Component | Status | Where it stands |
|---|---|---|
| European interoperability software component for EHR systems | Open question (#3171, #3206) | The requirements this component carries are Annex II 2.1 to 2.3: an interface that provides and receives personal electronic health data in the European electronic health record exchange format. FerroEHR serves the openEHR ITS-REST surface and an optional FHIR façade; neither is that format, and the format’s own content is set by implementing acts under Article 36. |
| European logging software component for EHR systems | Partial (#3204) | Annex II 3.2 lists five things every access event must record. The access-event model records the accessing organisation and person, the subject, the object and its domain, the outcome, the purpose and the time, the origins of the served data, and the audit trail is retrievable. One of the five is still short: the category is an openEHR resource class rather than an Annex I priority category. |
Annex II — the essential requirements
The identifiers are the Annex’s own. “Evidence” links what a reader can check for themselves; “Gap” says what is missing when a row is not complete.
1. General requirements
| # | Requirement (our paraphrase) | Status | Evidence | Notes |
|---|---|---|---|---|
| 1.1 | The components achieve the performance the manufacturer intended and are suitable for their intended purpose in normal use, without putting patient safety at risk. | Open question | — | Question: The requirement is a claim by a manufacturer about an intended purpose. FerroEHR is source-available software rather than a placed product, so who the manufacturer is depends on who puts a deployment into service — the question #3168 has to answer before this can be a status rather than a question. Tracked in #3168 |
| 1.2 | The components can be supplied and installed following the manufacturer’s instructions without adversely affecting their characteristics and performance. | Partial | Deployment artifacts and their documented installation | Gap: The artifacts and their instructions exist and a deployment probe reads the running stack back. What does not exist is the manufacturer’s declared intended purpose those characteristics would be measured against. |
| 1.3 | Interoperability, safety and security features uphold the rights of natural persons in line with the intended purpose, as set out in Chapter II. | Partial | The rights the software can serve, and who must serve the rest | Gap: Chapter II rights are largely a deployment’s duty rather than a product’s. The shared-responsibility page states which side each one falls on; the patient-facing access route is not built. |
| 1.4 | Components intended to operate with other products, including medical devices, are designed so interoperability and compatibility are reliable and secure and data can be shared with the device. | Open question | — | Question: FerroEHR claims no interoperability with a medical device. If a deployment claims it, Article 27 puts that claim’s requirements on the party making it. |
2. Requirements for interoperability
| # | Requirement (our paraphrase) | Status | Evidence | Notes |
|---|---|---|---|---|
| 2.1 | A system that stores or intermediates personal electronic health data provides an interface giving access to it in the European electronic health record exchange format, through the European interoperability software component. | Open question | Which priority categories round-trip through the FHIR façade | Question: The exchange format waits on the Article 36 implementing acts; the FHIR façade’s round trips per priority category are recorded, and the profile mappings that would carry the format belong to FerroBRIDGE. Tracked in #3171, #3206 |
| 2.2 | Such a system can receive personal electronic health data in that format, through the same component. | Open question | — | Question: Receiving the exchange format waits on the same Article 36 acts as providing it. Tracked in #3171 |
| 2.3 | A system designed to provide access to personal electronic health data can receive it in that format, through the same component. | Open question | — | Question: The same open question as 2.2: the format is not yet fixed. Tracked in #3171 |
| 2.4 | A system that lets a user enter structured personal electronic health data allows entry with enough granularity to provide it in the exchange format. | Partial | Templates and the archetype-constrained entry model | Gap: openEHR templates constrain entry to the archetype’s granularity, which is finer than any exchange format is likely to require. That the granularity SUFFICES for the European format cannot be shown until the format is set by implementing act. |
| 2.5 | The components include no feature that prohibits, restricts or unduly burdens authorised access, sharing or permitted use. | Shipped | The query surface over the whole stored record; Authorisation refuses or permits; it adds no commercial gate | — |
| 2.6 | The components include no feature that prohibits, restricts or unduly burdens exporting the data in order to replace the system with another product. | Shipped | Whole-repository dump and load; EHR-Extract export | — |
3. Requirements for security and logging
| # | Requirement (our paraphrase) | Status | Evidence | Notes |
|---|---|---|---|---|
| 3.1 | A system used by health professionals provides reliable identification and authentication of them. | Shipped | Basic and OAuth2/OIDC authentication | — |
| 3.2 | The European logging software component records, for every access event or group of events, at least the healthcare provider or other individuals who accessed the data, the specific natural person or persons who accessed it, the categories of data, the time and date, and the origin of the data. | Partial | The five elements mapped onto fields, gaps included | Gap: Points (a), (b), (d) and (e) are recorded: the accessing organisation beside the natural person, the time, and the origins of the served data read from the FEEDER_AUDIT provenance openEHR stamps on content, with the true distinct count beside the capped set. Point (c) is partial, because the record names an openEHR resource class and a pseudonymisation domain rather than an Annex I priority category. The DICOM rendering carries neither the organisation, the declared purpose nor the origins, because PS3.15 A.5 defines no element for them; the FHIR rendering carries all three. The remaining gap is asserted by a test that fails when it closes. Tracked in #3204, #3212 |
| 3.3 | The components include tools or mechanisms to review and analyse the log data, or support connecting external software that does. | Shipped | Audit retrieval (IHE ATNA ITI-81) and the syslog and FHIR feeds | — |
| 3.4 | Components that store personal electronic health data support different retention periods and access rights that take the origin and category of the data into account. | Partial | Per-EHR access control and the audit retention setting | Gap: Access rights are per EHR, per role and per attribute, and audit retention is configurable. Retention that varies by the ORIGIN or CATEGORY of clinical data is not implemented; the archival tier moves records without expiring them. |
Open questions
These decide whether Chapter III applies to a given deployment at all, and to whom. They are questions this project has not answered, stated as questions.
- Who is the manufacturer of a FerroEHR deployment? FerroEHR is source-available software, not a product placed on the market. Article 26(2) treats an EHR system manufactured and used within a health institution, and one offered as a service, as put into service — which puts the manufacturer’s duties on the party doing that rather than on the project. Tracked in #3168.
- Article 25(2) excludes general purpose software used in a healthcare environment from Chapter III. A clinical data repository is not general purpose software, so the exclusion is unlikely to apply, but the line has not been drawn by guidance yet. Tracked in #3168.
- The European electronic health record exchange format is set by implementing acts under Article 36 that have not been adopted. Until they are, requirements 2.1 to 2.3 name a format with no content, and what the interoperability component must emit cannot be built to a specification. Tracked in #3171.
Related
- Technical documentation readiness — the Annex III elements, and what exists for each.
- Shared responsibility — which duties the software can carry and which belong to the deployment.
- Control matrix — the legal controls the tracker declares, generated from the tracker.
Technical documentation readiness
Article 37 of the EHDS regulation requires a manufacturer to draw up technical documentation before an EHR system is placed on the market or put into service, and to keep it up to date. Article 37(2) says it must contain at least the elements of Annex III.
This page is not that documentation. It is a map of what FerroEHR can already supply for each element and what does not exist, so the gap is visible rather than discovered when someone needs the file.
Warning
No technical documentation has been drawn up, and no EU declaration of conformity exists. A “Shipped” row below means the material an element asks for is published and can be cited — not that the element has been written.
Regulation (EU) 2025/327 of the European Parliament and of the Council of 11 February 2025 on the European Health Data Space and amending Directive 2011/24/EU and Regulation (EU) 2024/2847 — OJ L series, 2025/327, 5.3.2025. Read on 2026-09-10; the regulation governs and the summaries here are this project’s paraphrase.
Annex III, element by element
1. A detailed description of the EHR system
| # | Element | State | Material | Notes |
|---|---|---|---|---|
| 1(a) | Intended purpose, date and version. | Missing | — | The version and release date are published per release; the intended purpose is a manufacturer’s statement that does not exist. |
| 1(b) | The categories of personal electronic health data it processes. | Partial | what exists | The storage model is documented and the pseudonymisation domains are separated, but a mapping onto the Annex I priority categories is not written. |
| 1(c) | How it interacts with hardware or software that is not part of it. | Available | what exists | — |
| 1(d) | Versions of relevant software or firmware, and update requirements. | Available | what exists | — |
| 1(e) | Every form in which it is placed on the market or put into service. | Available | what exists | — |
| 1(f) | The hardware it is intended to run on. | Partial | what exists | The measured deployment classes state an environment envelope; a minimum hardware specification as such is not published. |
| 1(g) | The system architecture, and how the components integrate. | Available | what exists | — |
| 1(h) | Technical specifications, variants, configurations. | Available | what exists | — |
| 1(i) | A description of every change through the lifecycle. | Available | what exists | — |
| 1(j) | Instructions for use and, where applicable, for installation. | Available | what exists | — |
2. The system in place to evaluate the EHR system’s performance
| # | Element | State | Material | Notes |
|---|---|---|---|---|
| 2 | Where applicable, how performance is evaluated. | Available | what exists | Performance is measured by an independent instrument in open-loop, coordinated-omission-free runs, and the records are committed. |
3. References to the common specifications used
| # | Element | State | Material | Notes |
|---|---|---|---|---|
| 3 | Common specifications under Article 36 against which conformity is declared. | Missing | — | The implementing acts that set the common specifications have not been adopted. Nothing can reference them yet. |
4. Verification and validation results
| # | Element | State | Material | Notes |
|---|---|---|---|---|
| 4 | Results and critical analyses of the tests demonstrating conformity. | Partial | what exists | The openEHR conformance record is complete and committed. It demonstrates conformity to the openEHR specifications, which is a different claim from conformity to Annex II; the European digital testing environment of Article 40 does not exist yet. |
5. A copy of the information sheet
| # | Element | State | Material | Notes |
|---|---|---|---|---|
| 5 | The information sheet required by Article 38. | Missing | — | No information sheet has been drawn up. |
6. A copy of the EU declaration of conformity
| # | Element | State | Material | Notes |
|---|---|---|---|---|
| 6 | The declaration required by Article 39. | Missing | — | No declaration exists, and none can be made before the common specifications are adopted and the manufacturer is identified. |
Test evidence
Element 4 asks for the results of the verification and validation tests. What
exists is the openEHR conformance record: an independent instrument’s runs
against a composed deployment, with the results, verdicts and the statement
committed under docs/conformance/ and published on the
conformance pages.
Read what that record does and does not say. It demonstrates conformity to the openEHR specifications. Conformity to Annex II is a different claim against a different yardstick, and the European digital testing environment of Article 40 — whose results Article 37(2) also requires a reference to — does not exist yet.
Risk analysis
Annex III does not name a risk analysis as a separate element, but element 1 asks for a description of the system architecture and element 2 for the system in place to evaluate performance. The material FerroEHR publishes for both is the architecture chapter and the security chapter, with the pseudonymisation boundary and its data flows documented as they land.
Declaration of conformity
Article 39 requires an EU declaration of conformity stating that the essential requirements of Annex II are met, and Annex IV sets out what it contains. No declaration exists, and none can be drawn up yet: the common specifications of Article 36 have not been adopted, so there is nothing to declare conformity against, and the manufacturer of a given deployment has not been identified — see the open questions on the readiness page.
When those two are settled, the declaration is drawn up by the manufacturer of the deployment, not by this project on their behalf.
Related
- EHDS readiness — status per Annex II requirement.
- Shared responsibility — which duties belong to the deployment.