Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

FerroEHR is a pure-Rust openEHR Clinical Data Repository (CDR): a headless, API-first server that stores and queries structured health records through a vendor-neutral REST API and the Archetype Query Language. This book is the user-facing guide — how to run it, configure it, talk to its API, query it, and load the templates that give your data shape. If you build clinical applications, operate healthcare infrastructure, or are evaluating an openEHR back end, you are in the right place.

The name comes from ferrum, Latin for iron: Rust is iron oxide, so the project went straight to the element. Say it “FER-ro-E-H-R”.

What openEHR gives you

openEHR separates clinical knowledge from software. The structure and meaning of clinical data — a blood-pressure reading, a medication order, a discharge summary — live in shared, computable models called archetypes and templates, authored by clinicians and modellers rather than baked into application code. Applications then store and retrieve that data through a standard API, against a shared Reference Model, so the same record is portable across every conformant system.

FerroEHR implements that standard natively. It speaks the openEHR REST API (ITS-REST Release-1.1.0), executes Archetype Query Language (AQL 1.1), and holds data as canonical openEHR compositions with full, indelible version history. There is no proprietary data format in the middle: what you commit is what you query and what you read back.

What makes this implementation different

  • Conformance you can run yourself. The openEHR conformance catalogue is executed by a committed runner against a live server, over both canonical JSON and canonical XML, and the profile verdicts are computed from the per-case outcomes. The run records live in the repository, and every number on the Conformance page is derived from them at build time, never hand-typed.
  • The openEHR specifications, generated directly from the official machine-readable models: the Reference Model, the Archetype Model (1.4 and 2.4), the serialization schemas, and the REST API contract (Release-1.1.0). openEHR’s own published terminology (3.1) ships embedded, byte-identical to upstream. A specification update is a regeneration.
  • Two selectable specification generations. One configuration key, spec_profile, chooses the whole generation set the server runs: development (Reference Model 1.2.0 with BASE 1.3.0 and LANG 1.1.0 — the default) or stable (the latest released generations, Reference Model 1.1.0 with BASE 1.2.0 and LANG 1.0.0). See spec_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 up to 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

openEHR does something almost nothing else in health IT does: it separates clinical knowledge from software, and then writes both down. What a blood pressure, a medication order or a discharge summary means lives in archetypes and templates authored by clinicians and modellers: published and computable. The Reference Model underneath them is specified. So is the query language, the REST interface, and the serialization, down to the shape of the JSON on the wire.

The consequence is determinism. Given the same template and the same composition, two conformant systems store the same record and answer the same AQL query the same way. A clinical record stops being one application’s private state and becomes data that outlives the application, the vendor and the procurement cycle. For a record that has to stay readable in twenty years, by software nobody has written yet, that is the whole game.

A specification is only as strong as the implementations you can run

A standard becomes real when there is something you can start with one command, read the source of, check against the specification yourself, and run for research, teaching or a non-profit deployment without a fee. Without that, an excellent specification stays an idea that only well-funded organisations can act on.

That is the gap this project set out to close: one complete, openly developed, source-available openEHR CDR whose conformance is measured and published.

What we commit to

  • One licence for all of our own code, with no open-core tier. Role- and attribute-based access control, IHE ATNA audit, per-version digital signatures, change events and the viewer are in one repository under the Business Source License 1.1. Nothing is held back to be sold back to you. (Vendored openEHR material keeps its own upstream terms, and the spec crates that embed it say so in their own metadata; see Licensing & legal.)
  • Every claim checkable. Conformance is executed by a runner against a live server, and the run records, per-case results and measured performance are committed to the repository. If a number appears on this site, the record it came from is in the tree, and a change that moves a verdict cannot land quietly.
  • The specification is the authority. The normative openEHR text is vendored in the repository and cited decision by decision. Where we find it silent or self-contradictory, the finding is filed in public and reported upstream.
  • The specification layer as reusable libraries. The generated openEHR model, the canonical codecs, the REST contract, the ADL engine and the AQL parser are published on crates.io as eight standalone crates, so the next Rust project does not have to re-model openEHR to get started. The five generated model crates are under the Apache License 2.0, the licence of the openEHR artifacts they are generated from, and need no licence conversation at all.
  • Maintenance in the open. Public roadmap, public issue tracker, changelog-driven releases, signed artifacts, and a security policy with a private reporting channel.
  • A compliance posture you can read before you buy. FerroEHR aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture, and an EHDS conformity self-assessment is on its roadmap. The compliance overview states which controls ship today, which are planned and under which issue number, and which obligations stay with the organisation running the software. It claims no certification and no conformity, because a product cannot hold either on its own.

Running FerroEHR commercially

FerroEHR is source-available under the Business Source License 1.1, which is not an OSI-approved open-source licence. One licence covers the whole repository, and no feature is held back for a paid tier.

What you are doingWhat you needWhy
Reading, building, modifying or redistributing the sourceFreeThe licence grants it without a fee and without asking anyone.
Development, testing, evaluation, prototypingFreeAll non-production use is granted.
Production use for Non-Commercial PurposesFreePersonal 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 patientsCommercial licenceDelivering health care, or any other service for payment, is production use outside the grant.
A vendor or integrator, or any company running it in productionCommercial licenceProduction 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 serviceCommercial licenceExcluded from the grant in every case, whoever you are.
Selling, sublicensing or otherwise distributing it for a feeCommercial licenceExcluded from the grant in every case, whoever you are.

The last two rows hold whatever else you are: they need a commercial licence even for an organisation the rows above would otherwise leave free. Each version becomes Apache 2.0 four years after it is published, so what ships today opens up on a published schedule. Licensing & legal carries the full picture, including every vendored third-party tree.

Building products on FerroEHR is welcome, and it is how standards reach patients. Companies and care providers running FerroEHR are wanted here, and the commercial licence is the normal path for them, on terms meant to make those deployments and products possible on a sustainable footing. It is also how the shared, conformant foundation gets maintained by the people who build on it instead of every vendor rebuilding one in private. A commercial licence is arranged with Cadasto B.V., the Licensor, which handles the business side of FerroEHR: write to info@cadasto.com or use https://www.cadasto.com/contact/. Technical questions go to the maintainer named in MAINTAINERS.md. Talk to us early.

What we ask in return

Contribute back.

The licence does not oblige you. The arithmetic does:

  • A private fork is the expensive option. Fork it and you inherit the whole maintenance surface: specification releases, security advisories, database upgrades, re-running conformance, and re-merging your changes at every release, forever. Upstream the same change and it is maintained once, by everyone who runs it.
  • A defect found once should be fixed everywhere. In clinical software the validation gap you patched privately is still live in every other deployment of the same code. Sharing the fix is the difference between one organisation being safe and all of them being safe.
  • Interoperability is a property of the population, not of any single implementation. Every conformance case contributed, and every ambiguity resolved in the open, makes it likelier that your system and the next one actually agree about a record.

When the same fix is made privately in five places, the standard is no stronger and five teams have paid for it. Nobody chose that outcome, and it is an easy one to avoid.

What contributing back can look like

You do not have to write Rust to make this project better:

  • A bug report with the request that reproduces it, the most valuable thing most users will ever send.
  • A conformance case for behaviour the catalogue does not cover yet, so the next release cannot regress it.
  • A specification finding: the ambiguity you had to resolve in order to ship. We adjudicate those against the vendored text and report the genuine ones upstream to the openEHR Foundation.
  • Documentation: a correction, deployment experience from your environment, or the paragraph that would have saved you two days.
  • Measurement from your own hardware and workload. The instruments that produce our published numbers ship in the repository and run against any server, including one that is not FerroEHR.
  • Code: fixes, features, connectors, packaging for your platform.
  • Sponsorship, if your organisation depends on this and cannot spare engineering time.

Contributing is the practical starting point. Security issues have their own private channel; please use it rather than the public tracker.

What this is not

This is not an argument against commercial or proprietary software. A product with FerroEHR inside it, or a care provider running it for its patients, is a good outcome, and we would rather those succeed than not exist at all; a commercial licence is how they and this foundation support each other.

It is also not a claim to be the only good openEHR CDR. FerroEHR began as a fork of EHRbase and records that lineage in the labelled import commit at the root of its history; today it is an independent Rust implementation with none of that code left in the tree. FerroEHR is an independent implementation of the openEHR® specifications and is not affiliated with or endorsed by the openEHR Foundation.

And it is not a large team. The project is maintained by one person today, with the machine gates (conformance, the fidelity suites, the CI guards) standing in for review capacity a bigger group would have. The threat model and the repository’s own governance and maintainer documents say so plainly.

The same honesty covers how the code gets written: substantial parts of FerroEHR are built with AI coding tools, directed and reviewed by the maintainer, with every change held to the machine-enforced gates described above. The full statement — what that means, what bounds it, and what you can verify instead of trusting it — is the repository’s AI statement.


Maintained by Ruben Talstra and the FerroEHR contributors. If your organisation runs FerroEHR, or is thinking about building a product on it, we would like to hear about it early: open an issue or say hello on the tracker.

Getting started

This chapter takes you from nothing to a running server with a template loaded, a clinical composition stored, and an AQL query returning results in a few minutes, using Docker Compose. It is the fastest way to see FerroEHR work end to end and to get a feel for the API before reading the reference chapters. Everything here uses the built-in development credentials; do not use them outside local evaluation.

Warning

The steps below enable Basic auth with the throwaway user ferroehr / ferroehr, leave role-based access control off (so that one user reaches every enabled surface, admin API included), and use a permissive CORS policy. This is a development configuration only. See Security and the configuration reference before exposing a server.

1. Start the stack

You need Docker with the Compose plugin (2.23.1 or newer). Download docker-compose.yml (attached to every release) into an empty directory and start it:

docker compose up

This pulls and starts two services: the server (ferroehr) on port 8080, and a preconfigured PostgreSQL 18 database. The server runs its schema migrations automatically on first boot, so the database is ready as soon as it reports healthy. Nothing else is needed: the server’s configuration travels inside the Compose file, which also ships one Basic-auth user (ferroehr / ferroehr, holding both the ADMIN and USER roles) so the API authenticates out of the box.

Published ports bind 127.0.0.1 by default, so the stack is reachable from this machine and not from the network. Three things are optional and stay down until you ask for them: the viewer (docker compose --profile viewer up, then http://localhost:3000), a SeaweedFS S3 gateway for multimedia (--profile s3), and a ready-made Keycloak identity provider for bearer-token auth (a second downloadable overlay, docker-compose.keycloak.yml). See Docker Compose for all of them.

2. Probe the status endpoint

The status endpoint is public and confirms the server is up:

curl http://localhost:8080/ferroehr/rest/status

It answers a small JSON document: status, server_version, openehr_rest_api_version, a timestamp, the licence in force and the declared deployment profile. All clinical API routes live under the base path /ferroehr/rest/openehr/v1. Interactive OpenAPI documentation is served at http://localhost:8080/ferroehr/rest/swagger-ui; sign in with the API credential when the browser asks (the UI is served to authenticated users by default, see rest.swagger_ui). If you have no server yet, use the hosted sandbox: https://sandbox.ferroehr.eu opens the viewer over a live CDR, and the same server’s Swagger UI is at https://sandbox.ferroehr.eu/ferroehr/rest/swagger-ui. Both take the public demo credentials ferroehr / ferroehr. The sandbox is a shared demo server that resets every night, so treat anything you write there as temporary and expect it to slow down under heavy use.

What is already in the sandbox

The nightly reset reloads a fixed demo dataset, so the server always answers with something to look at:

  • 8 EHRs, each with a mixed record and its own patient. The sandbox resolves patients through FerroPIX, the FerroHEALTH patient identity source, as the compose quickstart does: the patients are sandbox-patient-01 … -08 in the synthetic hospital domain urn:oid:2.999.1.1, and each EHR_STATUS names its patient by the pseudonym FerroPIX minted in the clinical domain urn:oid:2.999.7.1. POST /query/cohort with subjects named in the hospital domain finds their EHRs through FerroPIX. Two of them carry a second EHR_STATUS version: patient 07 is not queryable and so never appears in AQL results, patient 08 is not modifiable and refuses writes with a 409.
  • 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_VERSION and ALL_VERSIONS differ on real data.
  • 233 ADL 2 archetypes and 5 ADL 2 templates, browsable under extensions/v1/definition/archetype/adl2 and openehr/v1/definition/template/adl2. Two of the templates are ADL 2 source templates whose slots the server flattens against that archetype library, and the compositions committed from them came out of the server’s own …/example generator.
  • 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, and patient_record (takes an $ehr_id parameter). Run one with GET /query/eu.ferroehr.sandbox::composition_index, or open the viewer’s query screen.

There are also three always-on, unauthenticated health endpoints: /health, /health/liveness and /health/readiness; the last one reports each dependency it checked. See Operations → Health probes.

3. Create an EHR

An EHR is the container for one subject’s records. Create one with a POST (no body needed):

curl -u ferroehr:ferroehr -X POST -i \
  http://localhost:8080/ferroehr/rest/openehr/v1/ehr

The -i flag shows the response headers. On success you get 201 Created; the new EHR’s identifier is in the ETag header (as the weak form W/"<ehr_id>"), and Location points at the created resource. Copy the UUID; the examples below refer to it as EHR_ID.

By default the response body is empty. Add -H 'Prefer: return=representation' to have the server return the full EHR object instead, or -H 'Prefer: return=identifier' for just the uid; either way Preference-Applied echoes what the server honoured.

4. Upload a template

Before you can store a composition, the server needs the Operational Template (OPT 1.4) that the composition conforms to. Templates are XML documents; upload one with Content-Type: application/xml:

curl -u ferroehr:ferroehr \
  -H 'Content-Type: application/xml' \
  --data-binary @my-template.opt \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4

A successful upload returns 201 Created. If you do not have a template to hand, the openEHR community publishes example OPTs (for instance the Vital Signs templates used in the openEHR training material), and the international Clinical Knowledge Manager is the source for the archetypes they are built from. List what is loaded, and inspect a template’s derived WebTemplate (a JSON description convenient for building forms), with:

# List templates
curl -u ferroehr:ferroehr \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4

# Fetch the WebTemplate for one template
curl -u ferroehr:ferroehr \
  -H 'Accept: application/openehr.wt+json' \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4/my_template_id

See Templates & validation for the full template lifecycle and the WebTemplate/FLAT/STRUCTURED formats.

5. Commit a composition

A composition is one clinical document, stored inside an EHR and validated against its template. Post the composition JSON (its archetype_details name the template it belongs to):

curl -u ferroehr:ferroehr \
  -H 'Content-Type: application/json' \
  -H 'Prefer: return=representation' \
  --data-binary @my-composition.json \
  http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition

On success you get 201 Created and (because of Prefer: return=representation) the stored composition in the body, now carrying a server-assigned version identifier in its uid. If the composition does not conform to its template you get 422 Unprocessable Entity with the validation errors; a malformed request gets 400 Bad Request. The composition walkthrough in Resource walkthroughs covers update and delete, which use the If-Match header for optimistic concurrency.

6. Query with AQL

Now query across the data with the Archetype Query Language. The simplest query lists the EHR ids the server holds:

curl -u ferroehr:ferroehr \
  -H 'Content-Type: application/json' \
  -d '{"q":"SELECT e/ehr_id/value FROM EHR e"}' \
  http://localhost:8080/ferroehr/rest/openehr/v1/query/aql

The response is a RESULT_SET: a columns array describing each selected value and a rows array of result tuples. To pull values out of the compositions you committed, select by their archetype path, for example every systolic blood pressure above 140:

curl -u ferroehr:ferroehr -H 'Content-Type: application/json' -d '{
  "q": "SELECT o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic FROM EHR e CONTAINS COMPOSITION c CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2] WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140"
}' http://localhost:8080/ferroehr/rest/openehr/v1/query/aql

Querying with AQL is the full language guide — parameters, stored queries, version scope, terminology, pagination, and the supported feature set.

7. Explore the API interactively

Open http://localhost:8080/ferroehr/rest/swagger-ui (signing in with the API credential when the browser asks) to browse and try every endpoint from your browser. The UI’s spec selector carries one entry per API family: the standardised openEHR groups (EHR, Query, Definition, Demographic, Admin) and the server’s own extensions (status & management, terminology, party relationships, messaging, event subscriptions, audit, SMART discovery), plus FerroEHR: Complete surface last, which is the whole server in one document. Every entry is filtered from that same document, which the server generates from its own handlers, so nothing here can drift from the routes it actually serves. When authentication is enabled the “Authorize” dialog shows the one scheme the server is configured for (HTTP Bearer/JWT when OIDC is set up, otherwise HTTP Basic). The hosted sandbox runs the same UI at https://sandbox.ferroehr.eu/ferroehr/rest/swagger-ui if you want to try it before running anything locally — and https://sandbox.ferroehr.eu itself opens the viewer on that server, with the demo credentials ferroehr / ferroehr.

Next steps

Installation

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.

What every path has in common

One configuration file, with environment overrides on top. The server reads a single ferroehr.toml covering every subsystem; FERROEHR__* environment variables override individual keys, and repeatable --set key=value flags override those. Anything the environment grammar cannot spell (the Basic-auth user store, which is an array of tables) is file-only. ferroehr config default writes an annotated template with every key at its default, and ferroehr config check validates the result without touching the database.

The schema is the binary’s, and who applies it is a choice. By default (db.migrate = "apply") the server applies its embedded migrations at boot, so an empty database self-provisions. Setting db.migrate = "verify" makes the server issue no DDL at all (it checks the schema and refuses to start if it is not this build’s) which lets the runtime role hold no DDL rights. Something else then runs ferroehr db migrate under a migrator role first. That check still reads every schema’s migration state, so a least-privilege deployment also sets db.migrate_url to the credential that can. Both postures are laid out in Operations → Applying migrations.

A deployment declares what it may hold. The top-level deployment_profile key is sandbox by default: the server starts whatever separations are missing and names every one of them on the banner, in the log and on GET /management/status. Set it to production and the server refuses to start while a separation is open and not accepted by name in deployment_accepts. Read deployment_profile before you point a deployment at real patient data.

Choosing a specification generation

One top-level key, spec_profile, selects which openEHR specification generation set the deployment runs: development (the default: Reference Model 1.2.0 with BASE 1.3.0 and LANG 1.1.0) or stable (the latest released generations: Reference Model 1.1.0 with BASE 1.2.0 and LANG 1.0.0). It is a single coupled choice, and stable → development is always safe while the reverse is not, so decide it before you commit clinical data. The full semantics, defaults, and the direction contract are in spec_profile.

Note

A Clinical Data Repository stores PHI. In production the database must be an externally managed, backed-up, point-in-time-recoverable PostgreSQL 18, never a throwaway sidecar. The Kubernetes chart deliberately ships no in-cluster database for this reason.

Try it in Codespaces

The fastest way to try FerroEHR is a GitHub Codespace: one click boots the published images in your browser, with nothing installed on your machine.

Open in GitHub Codespaces

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:

PortWhat it serves
8080the REST API, with the Swagger UI at /ferroehr/rest/swagger-ui
3000the 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:

Computeone 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
Databasea second, dedicated CX33 running the published ferroehr-postgres image (PostgreSQL 18), reachable only over a Hetzner private network — no public database port
Data durabilitynone 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.yml downloaded into an empty directory and to a bare docker compose up in 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.

Note

The quickstart file carries the server configuration inline (a Compose configs entry with content:), which requires Docker Compose 2.23.1 or newer. Check with docker compose version.

The three images

FerroEHR publishes three container images to GHCR:

ImageContents
ghcr.io/ferrohealth/ferroehrThe ferroehr server binary on a distroless, non-root, shell-less multi-arch base (amd64 + arm64). Configured by a mounted TOML file and/or FERROEHR__* environment variables.
ghcr.io/ferrohealth/ferroehr-postgrespostgres:18.6 (with Debian security updates applied at image build) plus init scripts that pre-create the application login role, the eight NOLOGIN group roles (ferroehr_migrator, ferroehr_clinical, ferroehr_clinical_reader, and the five domain roles ferroehr_clinical, ferroehr_party, ferroehr_clinical_reader, ferroehr_party_reader, ferroehr_linkage), the database, the schemas (clinical, ext, audit) and the one extension the schema needs, btree_gist, so the app role never needs superuser.
ghcr.io/ferrohealth/ferroehr-viewerThe 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:

TagPublished from
X.Y.Z, X.Yevery release
latestthe newest release
mainevery push to the default branch
sha-<commit>every push, for exact pinning

The quickstart Compose file pins the exact release version it shipped with on all three images, so a downloaded file always runs one known-good, mutually compatible set; a CI guard (scripts/checks/compose-image-tags.sh) fails the build whenever those pinned tags disagree with the version being released, so they cannot be forgotten at a cut. To run something else, set the image variables in the table below.

The role the server connects as in the quickstart owns the database and is a member of ferroehr_migrator and ferroehr_clinical, which is what lets it apply migrations at boot, and of ferroehr_clinical and ferroehr_party, so the schema separation is exercised on one credential. That single credential is deliberate: one container with one DSN cannot demonstrate the credential separation honestly.

A least-privilege deployment sets db.migrate = "verify", runs ferroehr db migrate out of band under the migrator DSN, and serves on a narrow runtime credential. It also has to name the credential that prepares the schema, in [db] migrate_url: preparation reads all five _sqlx_migrations bookkeeping tables even under verify, which no least-privilege role can do, ferroehr_clinical included. Unset, migrate_url falls back to [db] url, which is what the quickstart runs. See Operations → Which credential prepares the schema and Applying migrations.

The PostgreSQL image is init-scripts only: it creates roles, schemas and btree_gist, and bakes in no migration state. That extension is the only one the schema needs, and the server installs it itself at boot (CREATE EXTENSION IF NOT EXISTS btree_gist), so a plain PostgreSQL 18 with a superuser-capable migration credential serves just as well. The server owns the schema content and applies its migrations idempotently at every boot, so a fresh database self-provisions and a restart is a no-op.

Note

PostgreSQL init scripts run only when the data volume is empty. If you see startup notices like skipping role creation (no CREATEROLE privilege) or roles absent, your volume predates the image’s role setup (or you are running a plain postgres image): either recreate the volume (docker compose down -v, which destroys data) or create the group roles once by hand as a superuser (Operations has the statements). The server runs fine either way: the grants are a defense-in-depth layer, not a functional requirement.

Bringing up the stack

Download docker-compose.yml (attached to every release) into an empty directory and start it:

docker compose up

This pulls and starts the core services (no profile needed): the database and the server below, and the patient identity source described in The patient identity source (FerroPIX).

  • ferroehr-postgres: the database image, with a named data volume, a pg_isready healthcheck, pg_stat_statements preloaded, 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/shm than 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 own healthcheck subcommand, because the image has no shell.

The API is then at http://localhost:8080/ferroehr/rest/openehr/v1, with Swagger UI at http://localhost:8080/ferroehr/rest/swagger-ui (the browser asks for the API credential; rest.swagger_ui chooses who may read it).

Important

Every published port binds the loopback interface by default (127.0.0.1:HOST:CONTAINER), and that is a security property rather than a style choice: a published port is DNAT’d by rules Docker inserts ahead of the host firewall’s own chains, so a ufw deny 8080 does not stop a port published on 0.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 compose delegates 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 Python podman-compose package, flag coverage differs — prefer the Docker Compose provider.
  • The default podman machine on 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 the ADMIN and USER roles.
  • RBAC disabled: so any authenticated caller may use every enabled surface, including the admin API. The ADMIN / USER / READONLY separation is switched on by setting [authz.rbac] enabled = true and giving each user an explicit roles list; see Security.
  • Admin API enabled: so the optional viewer’s panels work.
  • Management introspection enabled, with info and metrics at private, prometheus at public, and env and loggers at admin_only.
  • Permissive CORS: any origin may call the API from a browser.
  • No TLS: plain HTTP on port 8080.
  • The sandbox deployment profile, declared in the inline configuration: the binary’s default, stated so the file says what it runs. The server names every separation it has not made on the boot banner, in the log and on GET /management/status, and the viewer raises a notice saying the deployment must not hold real patient data. That is the quickstart telling the truth about itself, not a fault. See deployment_profile.

Warning

These are development credentials and development defaults. Before exposing a server, replace the user store (or point it at an identity provider), turn RBAC on, restrict CORS, close the admin API, and terminate TLS in front of the server. The configuration reference and Security cover each of these.

To change any of it without editing the Compose file, add FERROEHR__* variables to the ferroehr service’s environment: block; the environment layer wins over the inline file. The Basic-auth user store is the one setting the environment cannot express (it is an array of tables), so replacing the users means editing the inline config or mounting your own TOML file.

The patient identity source (FerroPIX)

FerroEHR keeps no patient-to-record cross-reference of its own by default. It asks an IHE patient identity source over PIXm (ITI-83) which pseudonym the clinical domain holds for a patient ([ehr_index]). The quickstart runs FerroPIX as that source, pinned by tag and image-index digest (CI runs the config check of every pinned image over its inline configuration), with four services beside the server:

  • identity-issuer: a development OpenID Provider (NAV’s mock-oauth2-server) that signs any client credentials request, at http://localhost:8180/quickstart. It is not for anything real.
  • ferropix: the PIX Manager, at http://localhost:8090/fhir. Its configuration declares the pseudonymisation domain urn:oid:2.999.7.1 with FerroEHR’s client ferroehr as its consumer, so it mints a pseudonym the first time FerroEHR asks, and a synthetic identifier domain urn:oid:2.999.1.1 fed by the client quickstart-source.
  • ferropix-postgres and ferropix-key: FerroPIX’s own database, and a one-shot job that writes its sealing key into a volume, so the file carries no key.

The server’s [ehr_index] names the endpoint and the grant, and [privacy] subject_namespaces declares the clinical domain. The server reads FerroPIX’s CapabilityStatement at boot and does not start until it answers.

The issuer, FerroPIX and FerroEHR share the issuer’s network namespace. A server accepts a plain-http issuer only on loopback (RFC 8414 §2), and the mock issuer writes the host it was asked on into every token, so all three reach it at http://localhost:8180. The issuer service therefore publishes the ports of all three and answers to the names ferroehr and ferropix on the compose network.

To register a synthetic patient and ask for the pseudonym by hand:

SRC=$(curl -s -d grant_type=client_credentials -d client_id=quickstart-source \
  -d client_secret=any -d 'scope=system/Patient.cud' \
  http://localhost:8180/quickstart/token | jq -r .access_token)
curl -s -X PUT -H "Authorization: Bearer $SRC" \
  -H 'Content-Type: application/fhir+json' \
  'http://localhost:8090/fhir/Patient?identifier=urn:oid:2.999.1.1%7CH-1' \
  -d '{"resourceType":"Patient","identifier":[{"system":"urn:oid:2.999.1.1","value":"H-1"}],"name":[{"family":"Synthetic"}]}'

Any conformant PIX Manager can take FerroPIX’s place: point pixm_url, assigning_authority and [ehr_index.oauth] at it.

The local EHR index

The other supported posture keeps the subject-to-EHR cross-reference in FerroEHR ([ehr_index]): EHR_STATUS.subject names a patient by its own namespace and identifier, and GET /ehr?subject_id=…&subject_namespace=… finds the EHR by them. One variable selects it, with no copy of the configuration to make:

FERROEHR_EHR_INDEX=local docker compose up

The server then runs the file’s ferroehr-config-local block, which differs from ferroehr-config in [ehr_index] and [privacy] alone: the local map, and no pseudonym namespace, so a subject reference is stored as the client sends it:

curl -u ferroehr:ferroehr -X POST -i -H 'Content-Type: application/json' \
  http://localhost:8080/ferroehr/rest/openehr/v1/ehr -d '{
  "_type": "EHR_STATUS",
  "archetype_node_id": "openEHR-EHR-EHR_STATUS.generic.v1",
  "archetype_details": {"_type": "ARCHETYPED", "rm_version": "1.1.0",
    "archetype_id": {"_type": "ARCHETYPE_ID", "value": "openEHR-EHR-EHR_STATUS.generic.v1"}},
  "name": {"_type": "DV_TEXT", "value": "EHR Status"},
  "subject": {"_type": "PARTY_SELF", "external_ref": {"_type": "PARTY_REF",
    "namespace": "benchmark", "type": "PERSON",
    "id": {"_type": "GENERIC_ID", "value": "1793131381", "scheme": "benchmark"}}},
  "is_queryable": true, "is_modifiable": true}'
curl -u ferroehr:ferroehr -i \
  'http://localhost:8080/ferroehr/rest/openehr/v1/ehr?subject_id=1793131381&subject_namespace=benchmark'

The identity services still start, and nothing asks them. Start without the variable to return to FerroPIX; the EHRs created on the local index keep their subject references, and the database refuses no stored row.

The OIDC variant (Keycloak overlay)

A second standalone file adds a ready-made identity provider, so you can exercise bearer-token authentication without registering a client anywhere. Download docker-compose.keycloak.yml from the same release, beside the base file, and stack the two:

docker compose -f docker-compose.yml -f docker-compose.keycloak.yml up

That starts Keycloak with a small demo realm (ferroehr) defined inline and points the server’s bearer validation at it. Basic auth from the base file keeps working, so the server then advertises Basic, Bearer.

The server accepts a token issuer only over HTTPS, or plain HTTP on a loopback host, so Keycloak serves the issuer over TLS at https://keycloak.localhost:8443/auth/realms/ferroehr. The containers resolve keycloak.localhost through a network alias on the compose network, and a browser resolves every *.localhost name to the loopback address, where the published port lands, so both sides use one issuer URL with no hosts entry. On the first up, a one-shot keycloak-tls service generates a development CA and a certificate for that name into the keycloak-tls volume and discards the CA’s private key; the server ([auth] ca_file) and the viewer ([auth.oidc.tls] trust_roots_file) trust that CA beside the public ones. docker compose down --volumes deletes the volume, and the next up generates a new CA. Keycloak also answers plain HTTP on port 8081 for the token call below, and the tokens it mints there name the HTTPS issuer. Keycloak’s access tokens carry typ: JWT, so the overlay sets FERROEHR__AUTH__TOKEN_PROFILE=jwt.

Start the viewer too and sign in at http://localhost:3000 as any user below:

docker compose -f docker-compose.yml -f docker-compose.keycloak.yml --profile viewer up

The viewer takes the authorization-code flow with PKCE through the ferroehr-viewer client and calls the server with the user’s token. Your browser does not trust the development CA, so it warns once on the Keycloak page: accept the warning, or trust the CA, which docker compose cp keycloak:/opt/keycloak/conf/tls/ca/ca.crt . copies out. A browser that does not resolve *.localhost needs 127.0.0.1 keycloak.localhost in its hosts file.

Every route checks SMART on openEHR scopes, so the realm puts them in every token: user/composition-*.cruds, user/template-*.cruds and user/aql-*.cruds grant a user every resource family, the service client gets their system/ twins, and __ferroehr.platform is the product scope the ADMIN and DEMOGRAPHIC APIs need. The roles below then decide.

The overlay also turns RBAC on, deliberately: an identity provider whose roles decide nothing is a login screen, not authorization. So the realm ships four users whose roles are load-bearing:

UserPasswordRealm rolesWhat it demonstrates
ferroehrferroehrADMIN, USERthe full surface, admin API included
clinicianclinicianUSERthe clinical API without the admin routes
auditorauditorUSER, READONLYreads succeed, every write is refused 403
nobodynobodynonean authenticated caller with no grant

Two confidential clients are defined, and both put ferroehr, the audience the server checks, in every access token:

ClientSecretGrants
ferroehr-viewerferroehr-viewer-quickstart-secretthe authorization-code flow with PKCE (S256), redirecting only to the viewer’s origin on port 3000, and the password grant so a token can be fetched by curl
ferroehr-serviceferroehr-service-quickstart-secretthe client-credentials grant only

Fetch a user’s token and call the API with it:

TOKEN=$(curl -s -d client_id=ferroehr-viewer -d client_secret=ferroehr-viewer-quickstart-secret \
  -d username=ferroehr -d password=ferroehr -d grant_type=password \
  http://localhost:8081/auth/realms/ferroehr/protocol/openid-connect/token \
  | jq -r .access_token)

curl -H "Authorization: Bearer $TOKEN" -X POST -i \
  http://localhost:8080/ferroehr/rest/openehr/v1/ehr

A service with no user behind it takes the client-credentials grant with ferroehr-service. Its service account holds USER, so the token reaches the clinical API and the admin routes refuse it with 403. Its system/aql-* grant counts because the overlay names the client in [smart] backend_clients (FERROEHR__SMART__BACKEND_CLIENTS):

TOKEN=$(curl -s -d client_id=ferroehr-service -d client_secret=ferroehr-service-quickstart-secret \
  -d grant_type=client_credentials \
  http://localhost:8081/auth/realms/ferroehr/protocol/openid-connect/token \
  | jq -r .access_token)

Warning

The realm, the client secret, the user passwords and the CA are demo values, served by a Keycloak in start-dev mode. For a real deployment, drop this overlay and point [auth] (or FERROEHR__AUTH__ISSUER and FERROEHR__AUTH__AUDIENCE) at your own issuer over HTTPS.

The terminology overlay (FerroTERM)

FerroTERM is the Ferro family’s FHIR terminology server: R4, R4B and R5 endpoints over a precomputed index, no JVM, no database, one distroless image. The overlay docker-compose.terminology.yml starts it beside the CDR and switches the CDR’s terminology server on, so archetype value-set bindings resolve at commit and AQL TERMINOLOGY() expands through it. It also switches on the read-only /terminology/* extension API (FERROEHR__TERMINOLOGY__API_ENABLED, off by default): a term, subsumption or value-set call there for a terminology the bundled openEHR terminology does not carry is answered by FerroTERM, while listing and describing terminologies stays with the bundle:

docker compose -f docker-compose.yml -f docker-compose.terminology.yml up
curl 'http://localhost:8090/r4b/metadata?mode=terminology'

It is an overlay rather than a profile for the same reason as the Keycloak and observability ones: enabling it has to change the CDR’s environment, which a profile cannot do. FerroTERM’s image carries its own health probe (the binary’s healthcheck subcommand, since the distroless image has no shell), and the CDR is declared to start only once that probe reports healthy, so docker compose up --wait returns with both serving.

Out of the box FerroTERM serves the licence-free shaped seed this repository ships (two code systems and two value sets under the reserved example.test domain, the content the conformance lane binds to), mounted read-only straight out of the ferroehr image. No licensed terminology is distributed with FerroEHR or FerroTERM. To serve a release you hold a licence for, build its index once into the named volume and tell the server to open it:

FERROTERM_RF2=/path/to/SnomedCT_Release.zip \
  docker compose -f docker-compose.yml -f docker-compose.terminology.yml \
  --profile terminology-build run --rm ferroterm-build
FERROTERM_INDEX=/data/index \
  docker compose -f docker-compose.yml -f docker-compose.terminology.yml up

The archive is mounted read-only and unpacked to a tmpfs that dies with the build container. FerroTERM’s loading page covers the other code systems and their build flags.

Three things the operator owns:

  • The code-system licence. SNOMED CT needs an Affiliate Licence (free in Member countries, see snomed.org/get-snomed); LOINC, ICD and RxNorm have their own terms. Every surface that shows SNOMED CT content carries the notice its licence prescribes (clause 8.3.1) and the release version and date.
  • Who can reach the server. The overlay publishes FerroTERM on the loopback interface at port 8090 (FERROEHR_TERMINOLOGY_PORT) so you can query it while developing. FerroTERM has no authentication and no rate limit of its own; a deployment that serves other machines puts a reverse proxy with both in front, or removes the ports: block and lets the CDR be the only caller, which is what the hosted sandbox does.
  • FerroTERM’s licence. BUSL-1.1 from the same Licensor as FerroEHR: non-commercial production use is free, any other production use needs a commercial licence. Its README says which is which.

The CDR keeps its shipped fail-open posture: a binding the server cannot resolve is accepted. FERROEHR__TERMINOLOGY__SERVER__FAIL_ON_ERROR=true turns an unreachable server into a 422 refusal instead; the terminology page explains both. A code the server resolves as outside its value set is refused with 422 under either posture.

Optional services (Compose profiles)

The core services are profile-less and start on every up. Further services sit behind a Compose profile and stay down until you ask for them:

  • ferroehr-viewer (--profile viewer): the viewer on port 3000, pointed at the server inside the Compose network. Start the stack with it:

    docker compose --profile viewer up
    # → http://localhost:3000  (log in with ferroehr / ferroehr)
    
  • seaweedfs and seaweedfs-init (--profile s3): an S3 gateway for large DV_MULTIMEDIA externalization (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 seaweedfs
    

    The compose file passes the whole FERROEHR__MULTIMEDIA__* set through from your shell, so there is no file to edit, and seaweedfs-init creates the bucket by issuing an idempotent CreateBucket against the gateway, which ships with none, and answers an S3 write into a missing bucket with 403 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, unset those variables (or set FERROEHR__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 of enabled = false.

    Confirm the server took them with curl -s -u ferroehr:ferroehr http://localhost:8080/management/env | jq .multimedia ("enabled": true and a non-empty endpoint mean 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-party and ferroehr-backup-linkage (--profile backup): one pg_dump job 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-linkage
    

    Each 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/demographic and ./backups/linkage). Set FERROEHR_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.yml overlay above (and, in a repository checkout, a keycloak profile of the development override described below).
  • A terminology server is the docker-compose.terminology.yml overlay above, FerroTERM beside the CDR; the repository’s conformance stack additionally runs its own seeded server for the acceptance lane, see Terminology servers.

Port already in use?

Every published port is a variable, so a conflict never requires editing any file. When docker compose up refuses with port is already allocated (or address already in use), pick a free port and pass it:

FERROEHR_PORT=8081 docker compose up

The same works for the other ports (FERROEHR_VIEWER_PORT, FERROEHR_PIX_PORT, FERROEHR_ISSUER_PORT, FERROEHR_S3_PORT; PostgreSQL publishes no host port — see Connecting to the database). The defaults stay fixed on purpose: every URL in this book assumes localhost:8080, and Docker’s automatic ephemeral-port allocation exists only when a mapping omits the host port entirely — a server that silently moved ports would break every printed URL, so here you always choose the port and always know it.

Note

On Windows the refusal reads differently: Ports are not available: exposing port TCP 127.0.0.1:8080 … bind: Only one usage of each socket address … is normally permitted when another program holds the port, or … An attempt was made to access a socket in a way forbidden by its access permissions when the port sits in a Windows reserved range. The remedy is the same variable; to see the reserved ranges, run netsh 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:

VariableDefaultEffect
FERROEHR_IMAGEghcr.io/ferrohealth/ferroehr:<release>Server image to run.
FERROEHR_POSTGRES_IMAGEghcr.io/ferrohealth/ferroehr-postgres:<release>Database image to run.
FERROEHR_VIEWER_IMAGEghcr.io/ferrohealth/ferroehr-viewer:<release>Viewer image (the viewer profile).
FERROEHR_BIND_HOST127.0.0.1Host interface every published port binds.
FERROEHR_PORT8080Host port mapped to the server.
FERROEHR_VIEWER_PORT3000Host port mapped to the viewer.
FERROEHR_PIX_PORT8090Host port mapped to FerroPIX, the patient identity source.
FERROEHR_ISSUER_PORT8180Host port mapped to the development OpenID Provider.
FERROPIX_POSTGRES_PASSWORDpostgresFerroPIX’s database superuser password (init only).
FERROEHR_DB_PORTnot publishedHost port for PostgreSQL, read only by the db-publish overlay (default 5432 there).
FERROEHR_S3_PORT8333Host port mapped to the S3 gateway (the s3 profile).
FERROEHR_BACKUP_CLINICAL_DIR./backups/clinicalHost directory the clinical dump job writes to (the backup profile).
FERROEHR_BACKUP_PARTY_DIR./backups/partyHost directory the party dump job writes to.
FERROEHR_BACKUP_LINKAGE_DIR./backups/linkageHost directory the linkage dump job writes to.
FERROEHR_BACKUP_USER0: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_MEM4 / 4GServer container resource ceiling.
FERROEHR_DB_CPUS / FERROEHR_DB_MEM4 / 4GDatabase container resource ceiling.
PG_INIT_USER / PG_INIT_PASSWORD / PG_INIT_DBferroehrApp role, password, and database created by the DB image’s init script.
POSTGRES_PASSWORDpostgresBootstrap superuser password (init only).
FERROEHR__TELEMETRY__FORMATprettyLog rendering for docker compose logs. Set json for log collectors.
FERROEHR__TELEMETRY__FILTERinfoLog level filter.
FERROEHR__DB__MAX_CONNECTIONS10Server connection-pool ceiling.
FERROEHR__SERVER__MAX_CONCURRENT_REQUESTS256Concurrent-request bound (503 with Retry-After past it).
FERROEHR__SIGNING__ENABLEDtrueVersion signing.

A commercial licence token is not a passthrough variable: mount the signed token file into the server container read-only and name its path with FERROEHR__LICENCE__FILE (the compose file carries a commented example beside FERROEHR__SIGNING__ENABLED). Unset, the server runs under the embedded non-commercial grant and reports which on GET /management/status.

The Keycloak overlay adds KEYCLOAK_PORT (default 8081), KEYCLOAK_HOSTNAME, KEYCLOAK_ADMIN_USER and KEYCLOAK_ADMIN_PASSWORD (both admin); the observability overlay adds GRAFANA_PORT (default 3000). The FERROEHR__MULTIMEDIA__* set is declared with no defaults on purpose, so an unset key reaches the server as absent rather than as an empty string.

The server container is passed FERROEHR__DB__URL (assembled from the database variables), and its configuration file is the inline quickstart config, delivered to /etc/ferroehr/ferroehr.toml, which FERROEHR_CONFIG names. Any other setting from the configuration reference can be added under the ferroehr service’s environment: block and takes precedence over the file. Every variable uses the same FERROEHR__… grammar as the rest of that reference; a single-underscore spelling is rejected at startup, not ignored.

Repository development

A bare docker compose up in a checkout runs the same published-images quickstart as the downloaded file. Building from the current sources is an explicit opt-in: the docker-compose.dev.yml overlay, passed as a second file, switches the stack to the from-source developer posture:

docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build

That builds the server, database and viewer images from the current sources (the :local tags) instead of pulling published ones, and replaces the inline quickstart configuration with docker/ferroehr.dev.toml: three Basic users (ferroehr, ferroehr-admin, ferroehr-readonly, all with password ferroehr), RBAC enabled so the role separation is exercised, and trust for the development Keycloak realm. The override also defines a keycloak profile that imports the full development realm (docker compose --profile keycloak up).

Downloaders of the standalone quickstart file never see any of this; it is purely a convenience for working on FerroEHR itself, and it only ever applies when its -f is passed — nothing merges it in silently.

Tip

A cold from-source build compiles at two parallel rustc jobs by default, and a release-optimization compile can hold several gigabytes per job. If the build gets OOM-killed on a memory-constrained host (observed on a 24 GB machine that was also running another stack), rerun it serially — slower, but it completes:

CARGO_BUILD_JOBS=1 docker compose -f docker-compose.yml -f docker-compose.dev.yml build

Build provenance

Images built by CI (and any docker compose build you drive from a checkout) embed a build SHA reported at /management/info and on the ferroehr_build_info metric. The build does not read .git; instead the SHA is passed as the standard REVISION build argument, the same value that fills the org.opencontainers.image.revision image label (CI uses the commit SHA; the project’s own scripts export git rev-parse --short=12 HEAD). The argument is declared by the files that carry build: blocks (the development override and the conformance stack) not by the pull-only quickstart file. When no value is supplied the identity falls back to the workspace version with an unknown SHA; the build never fails for lack of it.

Observability overlay

A further standalone file adds a full local telemetry stack: an OTLP collector, Prometheus, Tempo, Loki and Grafana with a provisioned service-overview dashboard. Like the Keycloak overlay it travels inline, so downloading it beside docker-compose.yml is enough:

docker compose -f docker-compose.yml -f docker-compose.observability.yml up
# Grafana → http://localhost:3000

The overlay reconfigures the server for that stack: it exports traces and metrics over OTLP/gRPC (FERROEHR__TELEMETRY__OTLP__ENDPOINT with FERROEHR__TELEMETRY__OTLP__METRICS=true), switches stdout to JSON lines (FERROEHR__TELEMETRY__FORMAT=json), and enables the management surface on its own internal port 9464 with info, metrics and prometheus reachable there. That port is only published on the Compose network; Grafana’s 3000 is the sole published port.

Metrics are pushed, not scraped, and that is a property of the bundled image rather than a preference: grafana/otel-lgtm runs Prometheus with a config file carrying no scrape_configs at all, and receives metrics over OTLP from its own collector. A scrape job dropped into that image is read by nothing, which is what an earlier version of this overlay did: every metric panel was empty with no error anywhere. Since one meter provider feeds both readers, the push carries every metric family the scrape endpoint would, and /management/prometheus stays reachable on the Compose network for anyone who wants to compare the two.

Grafana’s port 3000 is the same one the viewer would use, but the two never collide by accident: the viewer only starts when you ask for the viewer profile. If you want both at once, move one of them (FERROEHR_VIEWER_PORT=3001 or GRAFANA_PORT=3001).

This is the easiest way to see the server’s metrics and traces without wiring up a collector by hand. See Operations for what the server exports and how to consume it in production.

Next

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.

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. Set hostUsers: true to opt out and share the host’s user namespace. What the user namespace buys is in Cluster hardening.

Important

There is no in-chart PostgreSQL. A CDR stores PHI, so its database must be an externally managed, backed-up, point-in-time-recoverable PostgreSQL 18 (a managed service or an operator-run cluster). The chart carries only the connection string, preferably from an existing Secret.

Installing

The chart is published to GHCR as an OCI artifact, beside the images it deploys. Create a Secret holding the app-role connection string, then install:

kubectl create namespace ferroehr
kubectl -n ferroehr create secret generic ferroehr-db \
  --from-literal=FERROEHR__DB__URL='postgres://ferroehr_clinical:***@pg-host:5432/ferroehr?sslmode=verify-full'

helm install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
  --version 21.0.0-rc3 -n ferroehr \
  --set config.deployment_profile=sandbox \
  --set database.existingSecret=ferroehr-db \
  --set image.tag=5.6.0-rc3 \
  --set ferropix.enabled=false --set config.ehr_index.provider=local

config.deployment_profile=sandbox names the posture of a trial install, which must not hold real patient data; the chart has no default and refuses to render without one. A deployment holding patient data starts from the production overlay instead. The last line opts in to the in-instance EHR index, for a CDR run on its own. By default the chart runs FerroPIX, the IHE patient identity source, beside the CDR and points the EHR index at it, which needs FerroPIX’s own Secrets and an issuer: see FerroPIX. With neither, the render is refused and names both choices, as the server’s boot would be.

Important

helm repo add does not work for this chart, and never will. There is no HTTP chart repository and no index.yaml: OCI is the only publication path, so that there is exactly one place a chart version can exist. Every command takes the oci:// reference above. Helm has treated OCI registries as first-class since 3.8, so the cost of this choice is real but narrow: a client older than Helm 3.8 cannot install this chart at all.

helm show, helm pull, helm template and helm upgrade all take the same reference. To read the chart’s metadata without installing it:

helm show chart oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3

Pin two versions, not one

The chart version and the image tag move independently, and this is the thing people get wrong first. --version selects the chart (its templates and values schema); image.tag selects the server binary. The chart’s appVersion is only the default for the second, and it names the release the chart was cut against.

SelectsPin withLine
Chart versiontemplates, values schema, defaults--version 21.0.0-rc3SemVer over the chart’s own contract
Image tagthe server binary--set image.tag=5.6.0-rc3 (or image.digest)the application’s SemVer line

Always pin the image to an immutable version or, better, a @sha256 digest, never latest. Pin the two deliberately: the config tree is passed through to the server, so a key the chart’s defaults carry and your chosen image does not know is a boot refusal (unknown configuration key …), which presents as CrashLoopBackOff. A chart version is never republished with different content (the publish lane refuses to overwrite one) so a pinned chart version is a fixed artifact.

Note

A published chart version being immutable is a property of that refusal, not of the registry: an OCI tag is mutable, and helm push over an existing one would silently replace it. That is why a correction always ships as a new chart version, and why the 6.0.3 → 6.0.4 bump exists: the chart’s appVersion moved to a new release while its own version had already been published.

Verifying what you installed

Everything published here is signed keyless through Sigstore, bound to this repository’s build identity, so every claim is checkable rather than asserted. Two different artifacts answer two different questions, and you can ask both.

Who signed this chart: a cosign signature over the chart’s digest:

cosign verify ghcr.io/ferrohealth/charts/ferroehr:<chart-version> \
  --certificate-identity-regexp '^https://github\.com/FerroHEALTH/FerroEHR/\.github/workflows/build-chart\.yml@' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Both flags matter. Without them cosign verify would accept a signature from any identity in the transparency log; with them you are requiring that this repository’s chart-publishing workflow, authenticated by GitHub’s OIDC issuer, is what signed the bytes you pulled.

What it was built from: a SLSA build provenance attestation:

# the chart
gh attestation verify oci://ghcr.io/ferrohealth/charts/ferroehr:<chart-version> -R FerroHEALTH/FerroEHR
# the image it deploys
gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:<tag> -R FerroHEALTH/FerroEHR

The publish lane reads both back from the registry before it reports success, so a run that produced only one of them fails instead of going green.

Important

Both commands verify what the publishing lanes produce now. A published artifact is never replaced, so if one you pinned answers HTTP 404: Not Found it 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 the 3.17.4 cycle, so image tags from before it have nothing to verify.

Note

helm install --verify and helm verify do not apply: they check a PGP .prov file, and this chart ships none. That is deliberate: a .prov needs 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 config defaults track development and can be ahead of appVersion’s image. Check the pairing before you install, with the image itself as the authority:

helm template ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3 \
  -s templates/configmap.yaml --set database.existingSecret=ferroehr-db \
  | sed -n '/ferroehr.toml/,$p' | sed '1d;s/^    //' > /tmp/ferroehr.toml
docker run --rm -v /tmp/ferroehr.toml:/etc/ferroehr/ferroehr.toml:ro \
  -e FERROEHR_CONFIG=/etc/ferroehr/ferroehr.toml \
  -e FERROEHR__DB__URL=postgres://u:p@db:5432/ferroehr \
  --entrypoint /usr/local/bin/ferroehr ghcr.io/ferrohealth/ferroehr:<tag> config check

Exit 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 null for this deployment. A published chart is checked this way before it is published: the publish lane refuses to ship a chart whose defaults its own appVersion image rejects, and it repeats the check against every values overlay the chart carries, so this matters mainly when you install from a checkout of main, or pin an image older than the chart.

That install alone boots but answers 401 to everything, deliberately: config.auth.enabled is on and no mechanism is configured yet, and a server that authenticates nothing is not a safe default. Add a config.auth.issuer (with config.auth.audience) or a config.auth.basic.users entry before expecting a request to succeed. Every route also checks the SMART on openEHR scopes of the caller: a Basic user’s scopes, a token’s scope claim.

Configuration: one key, one file

The chart carries the server’s whole configuration under one key, config, rendered into a ferroehr.toml ConfigMap mounted at /etc/ferroehr/ferroehr.toml. Its keys are therefore exactly the TOML keys of the configuration reference (config.server.listen, config.authz.rbac.enabled, config.spec_profile, and so on) so anything that reference documents can be set without waiting for a bespoke chart key.

Secret-bearing keys are the one exception: a credential never reaches that ConfigMap. A ConfigMap is not a sensitive object: it is readable with namespace read, quoted wholesale into issues and support tickets, collected by backup tooling that skips Secrets, and not covered by Secret encryption at rest. The chart therefore classifies every key it renders and takes one of two actions.

A secret the chart routes: signing.key_passphrase, multimedia.secret_access_key, a Basic user’s password_hash, a terminology client_secret, and the three URL-shaped ones (db.url, events.url, audit.fhir_feed.url) has a secrets: key of its own, so a value under config: is a mistake and fails the render, naming the key that belongs there:

Error: execution error at (ferroehr/templates/deployment.yaml:…):
  refusing to render a secret into the ConfigMap (a ConfigMap is not a sensitive object …):
  - config.signing.key_passphrase: set `secrets.signingKeyPassphrase` instead

A secret the chart cannot route moves the whole rendered ferroehr.toml into the chart’s Secret, and no ConfigMap is created at all, the safe direction, taken automatically. No key reaches that branch today: every credential the server models now has either a *_file sibling or a Secret-borne environment route. The branch exists for the next one that does not, so that a secret key added upstream fails safe instead of landing in a ConfigMap. When it is taken, the install notes say which object your release used, and the configuration is read with:

kubectl -n ferroehr get secret ferroehr-config \
  -o jsonpath='{.data.ferroehr\.toml}' | base64 -d

Classification is by name shape, not by a list of today’s keys: any key whose name carries password, passphrase, secret, credential, private_key, api_key or a trailing token is treated as a credential unless it ends in _file, _path or _dir (those hold a path), and the four URL-shaped secrets are matched by path, because url carries no shape a classifier can see. That is what makes a secret key added to the server’s configuration tree in a future release move to the Secret rather than leak silently. extraEnv is the escape hatch for anything neither config: nor secrets: surfaces.

The deployment profile and the access log

The chart’s values declare three keys, so a rendered ferroehr.toml always says what it runs:

  • config.deployment_profile: no default; the chart refuses to render until it names sandbox or production, so an installation that follows a quickstart instead of the instructions for use does not end up in the sandbox posture by omission (EHDS Annex II 1.2). sandbox must not hold real patient data: the server names every separation it has not made on its boot banner and on /management/status, and reports the profile on GET /ferroehr/rest/status. A deployment holding patient data sets production, which refuses to start while a separation is missing and not named in config.deployment_accepts. The chart defaults leave several open: one DSN for every domain, an empty config.privacy.subject_namespaces, the server’s open defaults for config.audit.fail_mode and config.authz.rbac.ehr_access_default, config.db.migrate: apply on the runtime credential, and a plaintext listener behind the ingress; see deployment_profile. The chart’s production overlay closes them and accepts the plaintext listener by name.
  • config.audit.store.retention_years: unset. Set it in place of config.audit.store.retention_days when the horizon must be exact in calendar years, such as 3 for a German SGB V § 309 controller with config.audit.store.sgb_v_309_controller: true.
  • config.audit.categories: an empty templates and archetypes map, under which the category map FerroEHR ships applies: it classifies the published CKM templates and archetypes of one priority category and records anything else as unclassified. Map your own templates and root archetypes, which replaces the shipped map; the keys are template ids and archetype ids, so quote a template id that contains spaces:
config:
  audit:
    categories:
      templates:
        "International Patient Summary": [patient-summary]
      archetypes:
        openEHR-EHR-COMPOSITION.report-result.v1: [test-results]

The Audit page has the category vocabulary and the retention floors and ceilings the server enforces at boot.

The production overlay

The chart ships values-production.yaml beside values.yaml. Layered over the defaults, it runs config.deployment_profile: production with every separation that profile checks made, except one it accepts by name:

GapIn the overlay
shared_credentialthe clinical, party and linkage domains each read their own Secret (database.existingSecret, database.party.existingSecret, database.linkage.existingSecret), and the audit domain reads database.audit.existingSecret
shared_clusteryours to make: the server compares the cluster each domain pool reaches, so the separation is four DSNs on four PostgreSQL clusters. Each cluster is prepared on its own migrator: the migration Job applies the schema in every cluster, and the server verifies it there, on database.<domain>.migrateExistingSecret (database.migrateExistingSecret for the clinical cluster). The linkage cluster also receives the party migration set, schema only, because the linkage set names a function the party set creates
migrate_on_runtime_credentialconfig.db.migrate: verify, with migrations.job.enabled preparing the schema on database.migrateExistingSecret
open_subject_namespaceconfig.privacy.subject_namespaces declared
audit_off, audit_fails_openthe audit trail on with its local store, config.audit.fail_mode: closed
no_category_mapthe shipped category map (the default), or config.audit.categories templates and archetypes of your own
open_ehr_access_defaultconfig.authz.rbac.ehr_access_default: restricted
auth_offOIDC authentication on
rbac_offthe role gate on (config.authz.rbac.enabled: true, the chart’s default)
plaintext_brokerno AMQP integration on
plaintext_audit_feed, plaintext_object_storethe audit FHIR feed and the multimedia store off; an https:// URL for either when you turn it on
plaintext_listeneraccepted by name: TLS ends at the Ingress (ingress.tls), the pod listens in plain HTTP, and the NetworkPolicy admits only the ingress controller’s namespace (networkPolicy.ingressFrom, with networkPolicy.ingressAllowAll: false)

The database hop is TLS with the server’s certificate checked: end every DSN in ?sslmode=verify-full&sslrootcert=/etc/ferroehr-db-ca/ca.crt, and put the CA your database certificates chain to in a ConfigMap under the key ca.crt. The overlay names it ferroehr-db-ca (database.caConfigMap), and the chart mounts it read-only at /etc/ferroehr-db-ca in the server, the migration Job and the backup CronJobs. A CA the image already trusts needs no ConfigMap; leave database.caConfigMap empty then.

Every Secret name, the CA ConfigMap name, the identity provider, the pseudonym namespace, the host, the TLS Secret and the ingress controller’s namespace in it are examples. Replace them in a values file of your own and pass both:

helm pull oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3 --untar
helm install ferroehr ./ferroehr -n ferroehr \
  -f ./ferroehr/values-production.yaml -f my-site-values.yaml

A name you forget to replace fails loudly: a Secret that does not exist keeps the pod from starting, and the server refuses to start under production while a separation is open, naming each gap and its remedy. The chart’s validation renders the overlay and asserts each separation above, and its boot check has the server evaluate the posture from the rendered configuration. Neither reaches a database. The deployment probe (scripts/deploy-probe-k8s.sh --production-only) installs the overlay on a real cluster against four PostgreSQL clusters that refuse a connection without encryption: it observes the migration Job prepare all four and the server start under production with plaintext_listener as its only gap, both over sslmode=verify-full against the CA the overlay mounts, and the server refuse all four domains on one cluster by naming shared_cluster. Against your own databases, shared_cluster is first judged when the pod starts.

Database roles — who runs migrations

The chart expects a PostgreSQL role model in which the runtime pod is never a superuser. Four roles cover the single-domain posture:

RolePurpose
ownerowns the database (provisioning only)
ferroehr_migratorruns the append-only schema migrations
ferroehr_clinicalday-to-day reads and writes; the running pod connects as this
ferroehr_clinical_readerread-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 of ferroehr_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 needs database.migrateExistingSecret as well, for the reason the paragraph further down gives: verify reads all five bookkeeping tables, which a narrow runtime role cannot do.

Set migrations.job.enabled and the chart runs (b) for you as a pre-install,pre-upgrade hook Job that runs ferroehr db migrate. Helm creates it before the Deployment and waits for it, so a failed migration fails the release rather than rolling pods against a schema that was never applied. The Job authenticates from its own Secret (deliberately a different credential from database.existingSecret) and rendering is refused if you enable it without one:

database:
  existingSecret: ferroehr-db                 # postgres://ferroehr_clinical:…
  migrateExistingSecret: ferroehr-db-migrator # postgres://ferroehr_migrator:…
migrations:
  job:
    enabled: true
    existingSecret: ferroehr-db-migrator      # the same credential, its own Secret mount
config:
  db:
    migrate: verify

The demographic and linkage domains take their own credentials the same way, and that is what turns the schema separation into a credential separation. One more goes with them, because no runtime credential can prepare the schema:

database:
  existingSecret: ferroehr-db             # postgres://ferroehr_clinical:…
  party:
    existingSecret: ferroehr-db-party     # postgres://ferroehr_party:…
  linkage:
    existingSecret: ferroehr-db-linkage   # postgres://ferroehr_linkage:…
  migrateExistingSecret: ferroehr-db-migrator        # postgres://ferroehr_migrator:…

All four are mounted as files, so no DSN enters the pod’s environment. Unset the demographic and linkage ones and those two pools share the first, which is the schema-only posture, still separated and still verified at boot. Creating the five domain roles is a database step the chart cannot do for you; Operations has the statements and what ferroehr db verify reports.

database.migrateExistingSecret is what the pod prepares the schema with, and it is required as soon as database.existingSecret is anything narrower than a credential reaching every schema. Preparation spans them all at once: the DDL of all five migration sets under config.db.migrate: apply, all five _sqlx_migrations bookkeeping tables under verify. So verify is not the exception, and this is not only about the domain roles. ferroehr_clinical holds one pseudonymisation domain, ferroehr_clinical holds the clinical schemas, and neither can read the demographic or linkage bookkeeping at all; without this value the pod is refused on the first set it cannot read and does not start. It is normally the same credential migrations.job.existingSecret carries; the Job needs its own copy because it runs before the Deployment exists. Unset, preparation falls back to database.existingSecret, which is what a single-credential install has always done.

database.migrateExistingSecret reaches one database. A domain whose DSN reaches another database or cluster is prepared there, on that database’s own migrator, which database.<domain>.migrateExistingSecret names:

database:
  linkage:
    existingSecret: ferroehr-db-linkage            # postgres://ferroehr_linkage:…@linkage-db/…
    migrateExistingSecret: ferroehr-db-linkage-migrate   # postgres://ferroehr_migrator:…@linkage-db/…

The migration Job mounts it and applies the schema in that database; the server mounts it and verifies the schema there under config.db.migrate: verify. The domain’s own role cannot do either, because it holds one domain and cannot read the ext set’s bookkeeping. The server refuses to start when the two DSNs of one domain reach different databases. A database that holds the linkage domain without the party domain also receives the party migration set, its schema and grants and never a row, because the linkage set names a function the party set creates.

Give the migrator DSN a short lock_timeout (?options=-c%20lock_timeout%3D5s) so DDL blocked behind live traffic fails fast instead of queueing; migrations.job.activeDeadlineSeconds is the hard ceiling on the step either way. migrations.runByMigratorRole remains an informational marker surfaced in the install notes, which also tell you when the Job is enabled but config.db.migrate is still apply, a combination that buys nothing, because the server would migrate itself anyway.

You can check the posture from outside the cluster at any time: ferroehr db verify exits 0 only when the database carries exactly that build’s migrations, and issues no DDL doing it.

Secrets and mounted config

Some material is file-shaped rather than a value: ABAC policy files, ATNA TLS certificates, terminology-server client certificates, a JWKS blob, the PGP signing key, and a commercial licence token. Supply these under config.files, whose entries the chart mounts read-only from a Secret at /etc/ferroehr/<key> (and which is deliberately not part of the rendered TOML); point the matching in-TOML *_file / *_path key at the mounted path. A commercial licence goes the same way: put the token the licensor issued under config.files as licence.asc and set config.licence.file to /etc/ferroehr/licence.asc; GET /management/status then reports licence.use = "commercial". Without it the pod runs under the non-commercial grant every build embeds (see Licensing & legal). Secret-bearing scalar values go under secrets:: signingKeyPassphrase, eventsUrl, auditFhirFeedUrl, basicUserPasswordHashes, multimediaAccessKeyId, multimediaSecretAccessKey, terminologyClientSecret, and the database DSN comes from database.existingSecret (key database.existingSecretKey, default FERROEHR__DB__URL). None of these ever reach the ConfigMap, and all but two are delivered as mounted files rather than environment values.

How a secret reaches the process differs by whether the configuration key has a *_file sibling, and the difference is a security one: an environment variable is readable through /proc/<pid>/environ and is inherited by every child process, so the OWASP Kubernetes Security Cheat Sheet asks for a read-only volume instead.

SecretHow the chart delivers it
the database DSNmounted 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.signingKeyPassphrasemounted at /etc/ferroehr-secrets/signing.key_passphrase
secrets.multimediaSecretAccessKeymounted at /etc/ferroehr-secrets/multimedia.secret_access_key
secrets.basicUserPasswordHashes (per user-id)mounted at /etc/ferroehr-secrets/auth.basic.users.<user>.password_hash; the chart injects the matching password_hash_file
secrets.terminologyClientSecretmounted at /etc/ferroehr-secrets/terminology.server.oauth.client_secret; the chart injects the matching client_secret_file into config.terminology.server.oauth, which must declare the grant
secrets.eventsUrlmounted at /etc/ferroehr-secrets/events.url
secrets.auditFhirFeedUrlenv; audit.fhir_feed.url is the only credential-bearing key with no *_file sibling
secrets.multimediaAccessKeyIdenv; 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.basicUserPasswordHashes under the user-id, declare only the user, its scopes and its roles under config.auth.basic.users. Setting password_hash under config: 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.

FieldValue
runAsNonRoottrue (uid/gid 65532, the distroless nonroot user)
readOnlyRootFilesystemtrue (a writable emptyDir is mounted at /tmp)
allowPrivilegeEscalationfalse
capabilities.drop[ALL]
seccompProfile.typeRuntimeDefault (pod and container)
hostUsersfalse; the pod gets its own user namespace
supplementalGroupsPolicyStrict; only the groups the manifest names
ServiceAccount tokennot mounted (the workload never calls the K8s API)
enableServiceLinksfalse (see below; not a preference)
NetworkPolicyports narrowed to the API (and management) port; sources admitted unless you narrow them with networkPolicy.ingressFrom; set networkPolicy.ingressAllowAll: false to have the chart refuse the open state (see §Ingress)

The whole set satisfies the Pod Security Restricted profile, and the deployment probe harness reads it back off a running pod rather than off the rendered manifest: the container runtime’s own spec for the security context, the API server for admission, the EndpointSlice for readiness.

Satisfying the profile and enforcing it are different things, and only one of them is yours to do. Enforcement comes from a label on the namespace, which a chart cannot set for a namespace it does not own, so label it, or the posture above is a convention nothing checks and nothing fails when a future change regresses it:

kubectl label --overwrite namespace ferroehr \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/enforce-version=latest

With the label in place the API server refuses a non-compliant pod outright (Pod Security Admission), which is a stronger guarantee than any check the chart can make about itself. The install notes print this as a prerequisite for the same reason.

enableServiceLinks: false is load-bearing, not hygiene. The kubelet injects a set of Service link environment variables for every Service in the namespace, and for a Service named ferroehr* those land inside the server’s reserved FERROEHR_ namespace, whose strict boot-time sweep rejects unknown variables and refuses to start. Leaving service links on makes every install crash-loop.

Egress restriction is opt-in (networkPolicy.egress.enabled) because egress targets (the database, broker, terminology server) are deployment-specific; when you enable it the chart always admits DNS and you add rules for the rest. In the default posture the server’s only outbound traffic is DNS and PostgreSQL, so those two rules suffice; each integration you switch on adds a target, and a blocked one can fail silently. The full destination table is in Namespaces, network & policy.

Two limits worth stating plainly. First, with networkPolicy.ingressFrom empty the rendered ingress rule carries no from selector, and a rule without from admits every source, other namespaces included. Only the port list is narrowed in that state, so set ingressFrom to your ingress controller for a PHI workload, and set networkPolicy.ingressAllowAll: false if an open ingress rule must never render at all; the full treatment is §Ingress. The viewer’s own policy carries the same pair under viewer.networkPolicy. Second, a NetworkPolicy is only as real as the CNI that implements it: on a cluster whose network plugin does not enforce NetworkPolicy the object is documentation rather than a control, and nothing in Kubernetes reports that. Confirm it by attempting a connection the policy should refuse.

The database-side controls (the server certificate behind sslmode=verify-full, pgaudit, at-rest encryption, WAL archiving / PITR) belong to whoever provisions PostgreSQL; the chart mounts the CA (database.caConfigMap) but cannot enforce the rest. See Operations.

TLS at the pod

The production overlay ends TLS at the Ingress and accepts plaintext_listener by name. To close that gap instead, the server terminates TLS itself (config.server.tls), and every client the chart wires to it follows: the viewer and FerroBRIDGE call https://<release>:<service.port>, the kubelet probes over HTTPS, the Service declares appProtocol: https, and a ServiceMonitor that scrapes the API port uses https too.

The clients check the server’s certificate against a CA you name in service.tls.caExistingSecret. The certificate must carry the Service name (the release’s full name, ferroehr for a release called ferroehr) as a DNS SAN, which no public CA issues, so the chart refuses to render a TLS listener beside an enabled viewer, FerroBRIDGE or API-port scrape while that value is empty. Name a Secret that holds the CA certificate alone: every pod and the ServiceMonitor that read it then read no private key, and a ServiceMonitor in another namespace reads its own copy there. A cert-manager Certificate writes the key pair and the CA into one Secret (tls.crt, tls.key, ca.crt); copy ca.crt out of it (trust-manager’s Bundle does this) rather than naming that Secret. The Ingress in front of the TLS listener must reach it over HTTPS too, and how a controller is told so is controller-specific, so the chart refuses to render the Ingress with server TLS on until ingress.backendTlsAnnotations names it:

config:
  deployment_accepts: []          # nothing left to accept for the listener
  server:
    tls:
      certificate_file: /etc/ferroehr-tls/tls.crt
      key_file: /etc/ferroehr-tls/tls.key
      reload_interval_s: 60
extraVolumes:
  - name: server-tls
    secret:
      secretName: ferroehr-server-tls
      defaultMode: 0440
      items:
        - { key: tls.crt, path: tls.crt }
        - { key: tls.key, path: tls.key }
extraVolumeMounts:
  - { name: server-tls, mountPath: /etc/ferroehr-tls, readOnly: true }
service:
  tls:
    caExistingSecret: ferroehr-server-ca    # ca.crt alone
ingress:
  backendTlsAnnotations:                    # ingress-nginx
    nginx.ingress.kubernetes.io/backend-protocol: HTTPS

When cert-manager renews the certificate, the kubelet updates the mounted files and the server serves the new pair within config.server.tls.reload_interval_s (60 seconds above; unset, only SIGHUP reads the files again), with no restart; a renewed pair that fails to load is logged and the old one stays in service. A rotated client CA (client_ca_file, for client_auth) is picked up on the same interval, and the connections already open keep the trust anchor they were verified against.

The viewer reads the mounted CA through its own cdr.tls.trust_roots_file, beside the image’s roots. FerroBRIDGE reads it through SSL_CERT_DIR, which the chart sets to the image’s system roots plus the mounted CA, so its other peers that verify through the platform verifier stay trusted. For an OIDC issuer whose certificate comes from a private CA, name a Secret holding that CA in viewer.auth.oidc.caExistingSecret (key viewer.auth.oidc.caExistingSecretKey, ca.crt by default): the chart mounts it and points the viewer’s [auth.oidc.tls] trust_roots_file at it. For the server’s own [auth] issuer behind a private CA, name the Secret in auth.caExistingSecret (key auth.caExistingSecretKey, ca.crt by default): the chart mounts it and points the server’s [auth] ca_file at it. The ServiceMonitor reads the Secret from its own namespace, and the prometheus.io/scheme: https pod annotation follows the listener when metrics.enabled is set. With config.server.tls.client_auth: required the kubelet probes cannot present a client certificate, so the chart refuses to render it; keep it at none or optional. config.management.port stays plain HTTP on its own listener.

Health probes

All three probes use the always-on, unauthenticated, PHI-free health routes on the main HTTP port. They need no configuration at all: no management surface, no access level, nothing to forget:

ProbeRouteContract
liveness/health/liveness200 while the process is up; touches no dependency
readiness/health/readiness200 (up or degraded) or 503 (down, booting or draining): checks the database ping, migrations applied, the audit sender and the event outbox, each bounded at one second
startup/health/livenessthe 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 exec probe option. One existed and was removed: it ran the binary’s healthcheck subcommand, which then asked the openEHR status document rather than a health route, so readiness never touched the database and a pod with a dead database reported Ready and took clinical traffic. The subcommand now asks /health/readiness, and the httpGet probes above remain what the chart runs.

Warning

Migrations run only at boot, so a replaced or wiped database leaves the pods NotReady until something migrates it. Readiness reports the migrations indicator down (its detail on /management/health: core schema tables missing (migrations not applied)) while db reads up: 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/ferroehr

The check re-tests the schema on every probe, so a pod recovers on its own within one probes.readiness.periodSeconds of 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.enabled does exactly that, as a hook Helm waits on. Durable storage is the other half: an emptyDir-backed or otherwise disposable PostgreSQL puts a clinical repository one node eviction away from this state.

One recovery path does not self-heal, and it is worth knowing before you try it: restoring or dropping part of the schema set. A restore that brings back some of a domain’s relations without its migration bookkeeping makes the next migration run fail permanently with relation "version" already exists: the pod crash-loops, and restarting it retries the same failure. Recreate the whole database rather than one schema.

The management surface is independent of the probes and stays ops-only (/management/info, /prometheus, /metrics, /env, /loggers, /flamegraph). Unlike the bare binary, the chart ships config.management.enabled: true with every endpoint off, so nothing is exposed until you opt one in. Set config.management.endpoints.prometheus: public plus metrics.enabled=true to add the prometheus.io/* scrape annotations; set config.management.port to serve the surface on its own internal listener, so /management is never reachable on the clinical API port. The health probes stay on the main port regardless of all of this, and the install notes warn you if you add the scrape annotations while the endpoint is still off.

Those annotations are honoured by a Prometheus that discovers targets in its own scrape configuration. An operator-managed Prometheus (kube-prometheus-stack) ignores them and selects targets through ServiceMonitor objects instead, so set metrics.serviceMonitor.enabled=true there; metrics.serviceMonitor.labels is where the label your Prometheus’ serviceMonitorSelector matches on goes. It needs the monitoring.coreos.com CRDs installed first, or the install fails on an unknown kind.

Authentication is required, and the chart says so before it installs

config.auth.enabled defaults to true, and the server requires at least one mechanism with it: a 401 challenge has to name a scheme the server actually implements (RFC 9110 §11.6.1), so it exits rather than run as an openEHR API that can only refuse every request.

The chart therefore refuses to render a values file that enables authentication without configuring one, so helm install stops with an actionable error instead of reporting success and crash-looping:

Error: execution error at (ferroehr/templates/deployment.yaml:...):
config.auth.enabled is true but no authentication mechanism is configured...

Pick one:

config:
  auth:
    issuer: https://keycloak.example.org/realms/ferroehr
    audience: [ferroehr]

or Basic auth, whose password hashes go in secrets.basicUserPasswordHashes while the user, its scopes and its roles stay under config.auth.basic.users.

Warning

config.auth.enabled: false makes the chart render, and serves every request unauthenticated. On a repository holding patient data that is a development-only choice; the chart makes you state it explicitly rather than reaching it by omission.

Integrations

Any server setting is reachable

The chart renders its config tree verbatim into ferroehr.toml. So config.<the.toml.path> sets any key in the configuration reference, including keys this page and values.yaml never mention. There is no allow-list to extend and no chart release to wait for:

# values.yaml — [query] plan_cache_capacity, which values.yaml never names
config:
  query:
    plan_cache_capacity: 512
helm upgrade ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
  --version 21.0.0-rc3 -n ferroehr --reuse-values \
  --set config.query.plan_cache_capacity=512

Turning something off is the same edit in reverse: remove the key (or set the integration’s enabled back to false) and upgrade. The tables below are a curated starting point for the switches most deployments want; they are not the boundary of what the chart supports.

Two things make this safe to rely on:

  • A typo is a boot refusal, not a silent default. The server sweeps its configuration strictly and rejects an unknown key with a did-you-mean, so a misspelled path fails loudly at startup instead of quietly doing nothing. Check before you deploy with the schema and boot gates described under Check your values before you deploy them.
  • Credentials do not belong in this tree. config becomes a mounted file; secrets have their own routes (secrets.*, existingSecret, and the *_file key 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:

IntegrationChart keyDefaultNotes
Specification generationconfig.spec_profiledevelopmentOne coupled choice; stable runs the released generations.
ADMIN APIconfig.admin.enabledoffPhysical, irreversible delete. Gate behind admin RBAC.
Terminology extension APIconfig.terminology.api_enabledoff404 when off.
Event-subscription APIconfig.events.admin_apioffAdmin CRUD over event filters.
Bearer tokensconfig.auth.issuerunsetWith config.auth.audience; the issuer’s metadata and key set are read at boot, asymmetric algorithms only.
RBACconfig.authz.rbac.enabledonThe coarse role gate (active while config.auth.enabled).
ABACconfig.authz.abac.enabledoffCedar (policies via a config.files mount) or a remote policy decision point.
Eventing → AMQPconfig.events.enabledoffEnvelopes are PHI-free by design. Use config.events.tls: true; URL via secrets.eventsUrl.
S3 multimediaconfig.multimedia.enabledoff⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket; keys via secrets.multimediaAccessKeyId and secrets.multimediaSecretAccessKey.
Terminology serverconfig.terminology.server.enabledoffThe one FHIR terminology server, at config.terminology.server.url. To run FerroTERM in the cluster instead, use terminology.enabled (FerroTERM), which sets these keys for you.
ATNA audit trailconfig.audit.enabledonOn with the local store only; forwarding (config.audit.syslog, config.audit.fhir_feed) is opt-in per sink.
Version signingconfig.signing.enabledon (config.signing.mode: digest)pgp mode needs a config.files key plus secrets.signingKeyPassphrase, and fails closed at boot without a usable key.
OTLP telemetryconfig.telemetry.otlp.endpointunsetSetting the endpoint is all it takes; without a config.telemetry.otlp table nothing is exported. With networkPolicy.egress.enabled, add a rule for the collector, since a blocked exporter drops spans without an error.

Full detail on each is in Beyond the core, Security, and Operations.

The usage report is on by default, and usageReport.enabled=false switches it off for FerroEHR, for FerroTERM and for FerroBRIDGE. Each of those takes its own terminology.usageReport.enabled or bridge.usageReport.enabled when you want it to differ. FerroPIX runs from its own chart, which cannot read this chart’s switch: set ferropix.usageReport.enabled=false as well, or the chart refuses to render.

FerroEHR Viewer (a second workload, off by default)

viewer.enabled renders a second Deployment and Service for the viewer beside the CDR, from its own image (viewer.image.repository, tagged appVersion by default so the two move together). It is off by default: the viewer is a separate product surface with its own attack surface, and a CDR is complete without it.

Three properties are worth knowing before you switch it on:

  • It reaches the CDR strictly over the REST API, which viewer.networkPolicy.enabled (on by default) enforces rather than assumes: the viewer’s egress admits the CDR Service, DNS and outbound HTTPS for an identity provider, and nothing else. It holds no database credential.
  • It is a human-facing web UI, so viewer.ingress.enabled is the normal way to reach it, and viewer.auth.oidc.enabled with an issuer, client id and viewer.auth.oidc.publicBaseUrl is how you keep a person who should not see PHI out of it. Its client secret comes from viewer.existingSecret, mounted as a file exactly as the server’s DSN is.
  • viewer.replicaCount is 1 deliberately. The viewer holds session state in process, so a second replica needs sticky sessions at the ingress or users are logged out on a reroute.

The viewer carries the same security context as the server, and the chart’s render gate holds it to the same Restricted profile and the same pod-isolation settings: a release whose two workloads disagreed about hostUsers would be a posture nobody could state in one sentence. The deployment probe harness then reads that back off the running viewer container, exactly as it does for the server: its uid, its empty capability bounding set, its read-only root and its seccomp filter come from the container runtime’s own spec, its admission from an enforce=restricted namespace, and its login page from an HTTP request made inside the cluster through the viewer Service, so the second workload is never vouched for by the first. What that run does not establish is stated in its own record: the viewer’s OIDC path, the screens behind a session, and whether a CNI enforces the viewer’s NetworkPolicy. Its screens are documented in the viewer chapter.

FerroPIX (the patient identity source, on by default)

The CDR keeps no patient-to-record cross-reference of its own by default: it asks an IHE patient identity source over PIXm (ITI-83) which pseudonym the clinical domain holds for a patient ([ehr_index]). The chart takes FerroPIX as the chart dependency ferropix (oci://ghcr.io/ferrohealth/charts, pinned in Chart.lock), so its Deployment, Service, NetworkPolicy and configuration are its own chart’s, and every key under ferropix: is that chart’s value. With ferropix.enabled (the default) the chart writes config.ehr_index.provider = "ihe" and config.ehr_index.pixm_url at the FerroPIX Service, and refuses a values file that sets either too. With the egress policy on, it adds the rule to FerroPIX’s pods.

You supply FerroPIX’s PostgreSQL URL and sealing key, its issuer, the pseudonymisation domain whose consumer is the CDR’s client, and the CDR’s side of the same facts:

ferropix:
  database: {existingSecret: ferropix-db}
  dataKey: {existingSecret: ferropix-data-key}
  config:
    public_base_url: https://pix.example.org
    auth:
      audience: [https://pix.example.org/fhir]
      issuer: [{issuer: https://idp.example.org/realms/pix}]
    trust:
      issuer: [{issuer: https://idp.example.org/realms/pix, clients: [hospital-source]}]
    pseudonym_domain:
      - {namespace: urn:oid:2.999.7.1, name: Clinical domain, consumer_client_id: ferroehr}
config:
  privacy:
    subject_namespaces: [urn:oid:2.999.7.1]
  ehr_index:
    assigning_authority: urn:oid:2.999.7.1
    oauth:
      token_endpoint: https://idp.example.org/realms/pix/protocol/openid-connect/token
      client_id: ferroehr
      scope: system/Patient.rs
secrets:
  ehrIndexClientSecret: <the client secret>

secrets.ehrIndexClientSecret is mounted at /etc/ferroehr-secrets/ehr_index.oauth.client_secret, and the chart injects the matching client_secret_file; the production overlay reads it from a Secret you own through extraEnv instead. Another PIX Manager: ferropix.enabled: false and config.ehr_index.pixm_url. A CDR run on its own: ferropix.enabled: false and config.ehr_index.provider: local.

The chart pins FerroPIX 0.0.12 by digest (ferropix.image) with the FerroPIX chart built for it. Since FerroPIX 0.0.8, public_base_url is a top-level key and the clients that may act as a Patient Identity Source sit in the [[trust.issuer]] table, apart from the [[auth.issuer]] entry that names the issuer; FerroPIX refuses the older server.public_base_url and auth.issuer[].clients, so an upgrade from a chart that pinned FerroPIX 0.0.7 moves those two keys in your values.

The restricted-profile gate holds FerroPIX’s pods to the Pod Security Standards restricted profile like every other pod in the release. FerroPIX’s chart sets the two properties this chart adds for its own pods itself: enableServiceLinks: false, and a user namespace per pod unless FerroPIX’s own hostUsers value, under ferropix:, is true.

FerroTERM (a terminology server beside the CDR, off by default)

FerroTERM is the Ferro family’s FHIR terminology server: R4, R4B and R5 endpoints over a precomputed index, no JVM, no database, one distroless image. terminology.enabled renders it as a third workload beside the CDR and the viewer, and points the CDR at it, so archetype value-set bindings resolve at commit and AQL TERMINOLOGY() expands through it. The CDR’s own /terminology/* extension routes are a separate switch: they answer only with config.terminology.api_enabled=true, which is false by default, and return 404 otherwise whether or not FerroTERM is running. It is the Helm equivalent of the compose terminology overlay.

helm upgrade --install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
  --version 21.0.0-rc3 -n ferroehr --reuse-values \
  --set terminology.enabled=true

That renders a Deployment, a ClusterIP Service, its own ServiceAccount, a ConfigMap holding the code systems, and a NetworkPolicy. It also writes three things into the CDR’s ferroehr.toml: config.terminology.server.enabled, config.terminology.server.fail_on_error from terminology.failOnError, and config.terminology.server.url at the Service address.

Because the chart writes those three, a values file that contradicts any of them is refused at render rather than silently overwritten. Three shapes are refused while terminology.wireCdr is on:

  • config.terminology.server.url — two answers to “which server resolves a binding” is how resolution ends up pointed somewhere nobody chose.
  • config.terminology.server.enabled: false — the rendered ferroehr.toml would say the opposite of your values file.
  • config.terminology.server.fail_on_error disagreeing with terminology.failOnError — same reason, for the posture that decides whether an unresolvable binding is a 422.

Set terminology.wireCdr=false to keep your own server and run FerroTERM unwired. The CDR asks one terminology server, so a server of your own and the in-cluster FerroTERM are an either-or.

No Ingress is rendered, and no value renders one. The CDR is the only caller, which is the posture the hosted sandbox runs and what the SNOMED CT Affiliate Licence asks of a public deployment: clause 2.2.4 permits a public system to serve SNOMED-encoded data provided users cannot extract a substantial portion of the release and no fee is charged, and clause 2.7 requires measures so the release cannot be downloaded except by authorised users. A published FHIR terminology endpoint answers $expand and $lookup to every client that reaches it, and FerroTERM has neither authentication nor a rate limit of its own. Its NetworkPolicy is therefore the one in this chart that narrows sources by default: ingress admits the CDR’s pods on the terminology port and nothing else, egress admits DNS. terminology.networkPolicy.extraIngressFrom adds a peer you decide to admit. No boolean opens this port the way networkPolicy.ingressAllowAll opens the CDR’s — and a peer you add is admitted exactly as written, which includes admitting everything: a namespaceSelector: {} selects every namespace in the cluster (networking/v1: “if present but empty, it selects all namespaces”). The values schema therefore refuses an empty peer, and an empty podSelector or namespaceSelector inside one, so reaching that state takes labels you chose rather than a blank.

Out of the box it serves the shaped seed, which is synthetic content under the reserved example.test domain: two code systems and two value sets, the same content the conformance lane binds to. The chart carries those files and mounts them as a ConfigMap, so a fresh install from the registry serves terminology with no repository checkout. terminology.codeSystems.existingConfigMap swaps in a ConfigMap of your own FHIR JSON. No licensed terminology ships with FerroEHR or FerroTERM. One caveat with a ConfigMap of your own: the pod’s rollout annotation hashes the chart’s own seed files and the NAME you gave, not the contents of an object the chart does not own, so editing that ConfigMap updates the volume while the running pods keep serving what they read at boot. Restart the workload after such an edit (kubectl rollout restart deployment/ferroehr-terminology).

A real release is an index you build off-cluster. Run ferroterm-build --rf2 <release.zip> --out <dir> on a machine that holds the archive, put the output on a PersistentVolume, and name that claim in terminology.index.persistentVolumeClaim. The chart mounts it read-only at terminology.index.mountPath and sets FERROTERM_INDEX; with no claim named, the variable is absent and the seed is served alone. The chart provisions no storage and renders no build Job, for the same reason it provisions no database: unpacking a licensed archive is a step whose licence terms are yours, not a step a chart should start on its own.

Naming a claim also switches this Deployment to strategy: Recreate. A rolling update creates the replacement pod before the outgoing one goes away, and a ReadWriteOnce volume can only be attached by one node, so the new pod would sit Pending on the attachment while kubectl reported a progressing rollout. Recreate stops the old pods first, which costs a short terminology outage at every upgrade and finishes. If your index volume is ReadOnlyMany, override it with terminology.strategy={type: RollingUpdate}. FerroTERM’s loading page covers the other code systems and their build flags. Size the memory limit for the edition before you mount it: terminology.resources ships a 1536Mi limit, and the same FerroTERM page states the resident size of each edition, which the limit must cover.

The fail posture is the CDR’s shipped one. terminology.failOnError defaults to false, so a binding the server cannot resolve is accepted; true turns an unreachable terminology server into a 422 refusal. The terminology page explains both. A code the server resolves as outside its value set is refused with 422 under either posture.

FerroTERM’s licence. BUSL-1.1 from the same Licensor as FerroEHR: non-commercial production use is free, any other production use needs a commercial licence. Its README says which is which. The licence for whatever code systems you load is separate and yours to hold; SNOMED CT needs an Affiliate Licence (free in Member countries, see snomed.org/get-snomed), and every surface showing its content carries the notice clause 8.3.1 prescribes plus the release version and date.

More than one replica needs two other keys. terminology.replicaCount is a throughput choice — each replica loads its own copy of the index, so watch the memory limit — and above 1 the pods spread one per node by default, the same soft constraint the CDR gets. Turn terminology.podDisruptionBudget.enabled on with it: without a budget a node drain may take every terminology replica at once, and the CDR’s own budget does not cover these pods. It is off by default because the shipped replica count is 1 and a budget over a single pod blocks the drain it exists to survive.

FerroTERM carries the same security context as the CDR and the viewer, and the chart’s render gate holds all three to the same Restricted profile. The deployment probe harness reads this workload back off a running cluster too: its applied runtime posture, the shaped seed reaching the process from the ConfigMap volume, a refusal on the terminology port from a pod the policy does not name, and the CDR resolving a bound code through the Service. Its image is pinned by digest in terminology.image.digest and moves on FerroTERM’s own release line, so it does not follow appVersion the way the viewer image does. Because that digest is set, terminology.image.tag alone deploys nothing: the digest wins, and a tag pointing anywhere else is refused at render rather than ignored. Clear the digest to deploy by tag.

FerroBRIDGE (the FHIR bridge, off by default)

FerroBRIDGE maps between FHIR R4 and openEHR with FHIRconnect mappings and reaches FerroEHR over ITS-REST. FerroEHR itself serves no FHIR API for clinical data, so FerroBRIDGE is the component that does. bridge.enabled renders it as its own workload in the same release: a Deployment, a ClusterIP Service, its own ServiceAccount, a ConfigMap holding its configuration file, and a NetworkPolicy.

The chart writes three sections of that file for you:

  • [cdr]: base_url is this release’s CDR Service with its REST base path (http://<release>:<service.port><config.rest.base_path>), and [cdr.credentials] names a file mounted from your Secret.
  • [terminology]: with terminology.enabled on, base_url is the chart’s FerroTERM Service at /r4 and wire_version is r4. With it off the section is absent, and you may set your own under bridge.config.
  • [mappings]: directory is where the mapping files below are mounted.

The CDR credential comes from a Secret you create. The chart never takes it as a value. The Secret carries the keys user and password, for a user of the CDR’s config.auth.basic.users whose scopes grant what FerroBRIDGE calls. The password is mounted as a file and named by its _file key, so it is in neither the ConfigMap nor the environment. FerroBRIDGE does not treat the username as a secret, and the chart passes it as FERROBRIDGE__CDR__CREDENTIALS__USER. FerroBRIDGE reads an environment value as TOML when it parses as TOML, so a username made only of digits arrives as a number and is refused at boot. A static bearer token is no longer a choice: machine-to-machine authentication is HTTP Basic or the OAuth 2.0 client credentials grant.

kubectl -n ferroehr create secret generic ferroehr-bridge-cdr \
  --from-literal=user=bridge --from-file=password=./bridge-password

The mapping files come from a ConfigMap you create, named in bridge.mappings.existingConfigMap, with each key listed in bridge.mappings.files. The chart mounts every key on its own: a whole ConfigMap volume also carries the kubelet’s ..data snapshot directories, and FerroBRIDGE 0.0.5 reads those as a second and third copy of every file and refuses the start with a duplicate mapping name. FerroBRIDGE fetches each template a mapping context names from the CDR at boot, so upload the templates first; a template the CDR does not hold stops the start. Without a mapping set FerroBRIDGE still starts, and $tofhir and $toopenehr answer 503.

bridge:
  enabled: true
  cdr:
    existingSecret: ferroehr-bridge-cdr
  mappings:
    existingConfigMap: ferroehr-bridge-mappings
    files:
      - vital_signs.context.yml
      - blood_pressure.yml
  networkPolicy:
    ingressFrom:
      - podSelector:
          matchLabels:
            app.kubernetes.io/name: my-fhir-client

It stays inside the cluster until FerroBRIDGE authenticates its callers. FerroBRIDGE has no inbound authentication yet (FerroBRIDGE#400), and whoever reaches its port acts on the CDR with the credential above. So the chart renders no Ingress for it and no value renders one, the Service is always ClusterIP, and the NetworkPolicy admits only the peers you list in bridge.networkPolicy.ingressFrom. An empty list is refused at render, and the values schema refuses a peer that selects everything (an empty peer, or an empty podSelector or namespaceSelector inside one). There is no key that admits every source. Egress admits this release’s CDR, FerroTERM when it runs, and DNS; bridge.networkPolicy.extraEgress adds a rule for an upstream you name in bridge.config. When you narrow the CDR’s own networkPolicy.ingressFrom, the chart adds FerroBRIDGE’s pods to it, and FerroTERM’s policy admits them too.

Anything else goes in bridge.config, rendered into the same file with the keys of FerroBRIDGE’s configuration reference. FerroBRIDGE refuses an unknown key at boot. The render refuses a key the chart writes (cdr.base_url, cdr.credentials, server.listen, terminology while FerroTERM runs, mappings.directory while the chart mounts the mappings) and any secret-shaped key, such as a password, a token or cdm.url: set its _file sibling and mount the file with bridge.extraVolumes and bridge.extraVolumeMounts. FerroBRIDGE’s FHIR facade ([facade]) keeps its identity map in a file it writes, and the root filesystem is read-only, so turning the facade on also takes a PersistentVolumeClaim of yours mounted with those two keys and named in facade.identity_store, with bridge.strategy={type: Recreate} when the claim is ReadWriteOnce. Back that volume up with the CDR’s database.

Probes use FerroBRIDGE’s own endpoints: liveness /health/liveness, readiness /health/readiness, which answers 503 while the CDR or FerroTERM is unreachable. FerroBRIDGE carries the same security context as the other workloads and the release-wide hostUsers setting, and the render gate holds it to the same Restricted profile. Its image is pinned by digest in bridge.image.digest and moves on FerroBRIDGE’s own release line, never with appVersion; a tag other than the pin is refused while the digest is set. The deployment probe harness boots it on a cluster and reads back its runtime posture, its admission, the credential being used for the template fetch, a refusal on its port from a pod outside ingressFrom, and its readiness from a pod inside it. FerroBRIDGE’s own deployment documentation is at https://ferrobridge.eu/docs/operate/container.html.

Per-domain backups (one CronJob per domain, off by default)

backup.enabled renders one CronJob per pseudonymisation domain: backup.clinical.schedule dumps the clinical schemas, backup.party.schedule dumps the identities, backup.linkage.schedule dumps the party-to-EHR map, and each writes to its own existing claim (backup.clinical.persistentVolumeClaim, backup.party.persistentVolumeClaim, backup.linkage.persistentVolumeClaim). The image is backup.image.repository, which carries pg_dump.

A fourth job renders for the audit domain when database.audit.existingSecret gives it a database of its own. While the trail shares the clinical database the clinical dump names its schema and nothing more is needed; once it is somewhere else, that dump cannot reach it and nothing else would take it, so backup.audit.schedule, backup.audit.existingSecret and backup.audit.persistentVolumeClaim become required the same way the other three are, and the clinical dump stops naming the audit schema.

The chart refuses to render rather than let the separation collapse quietly: a missing claim is an error naming the value, and naming the same claim for any two domains is an error too, because one volume holding two of them — the clinical record, the identities, or the map that joins them — is the state per-domain backups exist to prevent.

Each job needs its own backup credential, named by backup.clinical.existingSecret, backup.party.existingSecret, backup.linkage.existingSecret and, when it renders, backup.audit.existingSecret, and the render is refused without them. It cannot be the pool’s credential: each domain’s runtime role is revoked from the other domains, so a dump taken through one would be silently partial. Give each domain a read-only role on that domain’s schema alone. Every job reads its DSN from a mounted file, never an environment variable.

The chart provisions no storage. Create the claims yourself, and give them different access control; that part no chart can do for you. The restore procedure, and the ferroehr db verify gate that judges it, are on the Operating page.

Staying available while things move

Five defaults keep the API serving through the events that routinely interrupt it. None needs configuring; each is listed because the reason matters when you tune it.

Replicas land on different nodes. With nothing telling the scheduler otherwise, two replicas can share one node and one node failure is a total outage. The chart ships a soft spread constraint (maxSkew: 1 over kubernetes.io/hostname, whenUnsatisfiable: ScheduleAnyway) so replicas prefer separate nodes but a single-node or full cluster still schedules them rather than leaving a pod Pending. Setting topologySpreadConstraints replaces it wholesale, so give the complete constraint including its own labelSelector; add a topology.kubernetes.io/zone entry if your cluster spans zones.

A terminating pod stops receiving requests before it shuts down. Deleting a pod removes it from the EndpointSlice and sends SIGTERM concurrently, and the removal still has to propagate to every node. preStopSleepSeconds (default 5) holds the container for that window first. It uses the native sleep hook action rather than an exec hook, because the image ships no shell to run one.

A node drain does not hang on unhealthy pods. The PodDisruptionBudget sets podDisruptionBudget.unhealthyPodEvictionPolicy: AlwaysAllow, the documented recommendation. The API default, IfHealthyBudget, makes a drain wait for pods to become healthy, which never completes when they are unhealthy because of the drain.

A migration interrupted by a drain is not counted as a failure. The migration Job carries a podFailurePolicy that ignores pod failures caused by disruption, so ordinary cluster maintenance during a release cannot exhaust migrations.job.backoffLimit and fail the upgrade with no migration error anywhere in the logs.

A reused connection is not closed under a client. The server closes a kept-alive connection only after config.server.idle_timeout_ms (120 s by default) with no request, and counts config.server.header_read_timeout_ms from the first byte of each request. Keep the idle limit longer than the idle timeout of your clients (90 s for a Go client) and of the ingress controller or load balancer in front, so they close an idle connection first. A client that sends a request into a connection the server is closing gets no answer, and a client that never retries a POST reports the commit as failed.

Two more are available and off by default. service.trafficDistribution: PreferSameZone keeps traffic inside the caller’s zone, for lower latency and inter-zone cost, at the price of even load, so measure before setting it. And autoscaling.behavior passes scaling policies straight through; the documented defaults already scale up immediately and wait out a five-minute stabilization window before scaling down, so change it only to be more conservative.

Upgrades

Migrations are append-only (a schema change is a new file, never an edit to an applied one) so a rolling upgrade stays compatible with the previous schema during the window where both versions run: additive DDL first, destructive changes in a later release once all pods are on the new version. Keep replicaCount at two or more (or autoscaling.enabled) and the default PodDisruptionBudget so upgrades and node drains never fully interrupt the API; the default terminationGracePeriodSeconds covers the binary’s shutdown drain. Roll back by re-pinning the prior image tag or digest.

A change anywhere under config, config.files or secrets changes the checksum/config pod annotation, so helm upgrade rolls the pods for a configuration-only change too, including a rotated secret or an edited ABAC policy, both of which are read at boot and would otherwise reach the volume while every running pod kept using the old value.

helm uninstall removes everything the chart created; the chart declares no PersistentVolumeClaim, so nothing is left behind; your database, and the Secret holding its DSN, are yours and survive.

Preview an upgrade against what you have installed with helm diff, or render the new chart version and read it:

helm template ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr --version 21.0.0-rc3 \
  -n ferroehr -f my-values.yaml | less

Working from a checkout instead, deploy/helm/validate.sh runs the chart’s full render gate: the helm-version pin, lint, YAML validity, the structural Restricted-profile and selector-immutability gates, the secret-leak gate, the values-schema probes, and the golden-render diff. It is the same gate CI runs on every change to the chart, so a local run and a pull request agree by construction.

Check your values before you deploy them

A render that lints is not a deployment that boots. validate.sh never runs the server, so it cannot see a configuration the server refuses: a missing authentication mechanism, an HS* algorithm, a password hash that is not a real Argon2id PHC string, SMART enabled without its public base URL. Every one of those renders perfectly and crash-loops the pod. The script prints exactly which properties it does not check, on success as well as on failure, so a green run is not mistaken for a working deployment.

The check that closes that gap runs the image against your rendered configuration:

FERROEHR_IMAGE=ghcr.io/ferrohealth/ferroehr:5.6.0-rc3 \
  deploy/helm/ci/boot-check.sh my-values.yaml

It renders the chart, mounts the ConfigMap and the Secret exactly as the Deployment does (with their real values, not placeholders) replays the declared environment, and runs ferroehr config check inside the image. Point FERROEHR_IMAGE at the tag you intend to deploy: the answer is specific to that image, since a key your values carry may be newer than the server that has to read it.

It validates configuration only; it opens no socket, so it cannot tell you the issuer resolves, the broker is reachable or the database accepts the DSN. CI runs the same script over every values overlay the chart ships.

Cluster hardening

The Kubernetes chapter documents the chart. This section is the other half: an audit of this deployment against the OWASP Kubernetes Security Cheat Sheet, split by who can actually apply each control.

That split is the point. A workload chart controls its pod security context, its resource bounds, its NetworkPolicy, its ServiceAccount and how it consumes secrets. It cannot patch a node, set an API-server flag, configure etcd, authenticate a kubelet, or install an admission controller. For those, the honest deliverable is not a setting but a statement of what you owe and what happens if you do not, because a deployment that inherits an unchecked assumption is not secured by the chart’s own hardening.

Important

Several controls here are ones no application-level hardening can compensate for. If the kubelet accepts anonymous requests, or anything untrusted can read etcd, then every control this project ships (non-root, read-only rootfs, dropped capabilities, RBAC, ABAC, the audit trail) is bypassable, because the attacker is beneath the layer they operate in. Those are marked where they appear.

Claims about what the chart renders are checked against the chart’s own render gates. Claims about what a cluster then applies come from the deployment probe harness, which reads each answer from the layer that actually decides it (the container runtime’s own spec for the security context, the API server for admission, the EndpointSlice for readiness) and which declares in its own record what it did not exercise, so silence is never read as coverage.

The five pages

PageCovers
The cluster: hosts, control plane, accessnode and OS hardening, the supported version window, rolling upgrades, advisories, etcd, the port surface, cluster API access, cluster RBAC, kubelet authentication
Images: build, provenance, scanningthe 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 & admissionthe applied security context, user namespaces, AppArmor, Restricted-profile compliance versus enforcement, sandboxing, kernel modules
Namespaces, network & policynamespace scoping, one instance per organisation, the service-mesh decision, which admission engine, resource bounds in four layers, deny-by-default egress
Secrets, detection & responseSecrets 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 controlOwnerWhere
Host hardening, OS patching, node firewalloperatorcluster
Supported Kubernetes version windowoperator (chart states the floor)cluster
Rolling upgrades rather than mutating containerschartcluster
Kubernetes security advisoriesoperatorcluster
Kubernetes Dashboardoperator (we ship none)cluster
etcd access + encryption at restoperatorcluster
Control-plane and kubelet portsoperatorcluster
The workload’s own port surfacechartcluster
Cluster API access control, MFAoperatorcluster
Cluster RBAC (Node,RBAC, NodeRestriction)operatorcluster
The workload’s own RBACchart, deliberately nonecluster
Kubelet authentication/authorizationoperatorcluster
Minimal, current, authorized imageschart/CIimages
Image provenance at admissionoperator (we publish the attestations)images
Continuous scanning of published imagesCIimages
Supply chainCI, with two gapsimages
Pod/container security contextchartworkload
Pod Security Admission enforcementoperator (one kubectl label)workload
Container sandboxingneither; a recorded decisionworkload
Kernel-module loadingsatisfied by the chartworkload
Namespace isolationchart (namespace-scoped by construction)network
Service meshneither; a recorded decisionnetwork
Centralized policy managementoperator, for admission onlynetwork
Container resource boundschartnetwork
Namespace ResourceQuota/LimitRangeoperatornetwork
Egress restrictionchart (mechanism) + operator (destinations)network
Secrets encrypted at restoperatorsecrets
Runtime/syscall detectionoperator (unusually cheap here)secrets
Replica behavioural deviationoperator (from metrics we publish)secrets
Breach containment + credential rotationoperator (procedure is ours)secrets
Cluster API audit loggingoperatorsecrets
Container loggingchart/appsecrets
Managed control planeprovidersecrets

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

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: env renders the effective configuration and flamegraph profiles the live process. The chart ships the master switch on and every endpoint off, so nothing is exposed until you name an endpoint and a level. Set config.management.port to 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 what viewer.auth.oidc.enabled and viewer.ingress.enabled are for.

etcd and what our secrets contain

Operator’s, and this is one of the controls that cannot be compensated for.

The cheat sheet’s requirements stand: mutual TLS between the API server and etcd, etcd reachable from nothing else, and separate instances or ACLs to bound what a component can read.

What makes it concrete for this deployment: anything that can read etcd can read every Secret in the cluster, and this release’s Secrets are not incidental. They hold the database DSN (the credential that reaches patient data) plus the version-signing passphrase, any Basic user’s Argon2id hash, and any terminology-server client secret. So “etcd is a cluster concern” is true and insufficient: for this workload etcd is the confidentiality boundary of the credentials that reach PHI. The full inventory is what our Secrets contain.

Two mitigations you can apply without touching etcd’s network posture:

  1. 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.
  2. An external secret manager, which removes the credential from etcd entirely: every secret this chart carries has a *_file route or an existingSecret route, 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:

PortServesWho should reach it
8080 (service.port)the openEHR REST API, the always-on /health family, and /management/* when config.management.port is unsetyour ingress controller or gateway, not the internet directly
config.management.port (unset by default)/management/* on its own listener when setoperators and your Prometheus, never clinical clients
3000 (viewer.service.port)the viewer, only when viewer.enabledyour ingress controller, in front of an authenticating edge

Read off the running pod rather than the template, from the listening sockets in the container’s own network namespace: the default posture binds one port and nothing else:

LISTENING TCP ports in the container netns: [8080]

The shipped NetworkPolicy narrows inbound traffic to that port list. Whether the narrowing is enforced is a property of your CNI, not of the object: on a cluster whose network plugin does not implement NetworkPolicy the object is accepted, stored and displayed with no effect and no warning. Verify it the way the managed-cluster section shows: attempt a connection the policy should refuse, from a pod in another namespace, and require it to fail.

Two limits stated in full under Namespaces, network & policy §Ingress and repeated because they matter here: with networkPolicy.ingressFrom empty the rule carries no from and therefore admits every source, including other namespaces (only the port list is narrowed in that state; set networkPolicy.ingressAllowAll: false to have the chart refuse to render that state at all, and the same pair exists for the viewer under viewer.networkPolicy); and a NetworkPolicy is only as real as the CNI that implements it.

Cluster API access

Operator’s. Control access to the Kubernetes API: authenticate, then authorize, and deny by default.

  • Recommended routes: OIDC, a managed-IAM integration, or user impersonation, with MFA on the identities that can reach the cluster API.
  • Not suitable for production: static token files, long-lived X.509 client certificates, and service-account tokens used as human credentials. They cannot be revoked individually, they do not expire usefully, and they carry no second factor.

Worth drawing explicitly, because it saves a translation: this server implements the same shape. Authentication then authorization, deny by default, OIDC preferred over long-lived credentials, and a missing credential distinguished from a refused one (401 with a challenge versus 403). The Security chapter is the detail. An operator who understands why a static token file is a poor cluster credential already understands why config.auth.basic is a machine and bootstrap mechanism here and a config.auth.issuer is the production one for people.

Cluster RBAC, and why this chart needs none

Operator’s, for the cluster: run the API server with --authorization-mode=Node,RBAC and enable the NodeRestriction admission plugin, so a compromised kubelet cannot edit objects belonging to other nodes.

Ours, and it is an absence on purpose. The chart creates a ServiceAccount and no Role, RoleBinding, ClusterRole or ClusterRoleBinding at all, with serviceAccount.automountServiceAccountToken: false. That is not an omission to be tidied up later: the workload never calls the Kubernetes API, so it needs no permissions, and it is not given a token with which to try. The same holds for the viewer’s own ServiceAccount when that workload is enabled. Checkable on a live release:

helm get manifest ferroehr | grep -cE '^kind: (Role|RoleBinding|ClusterRole|ClusterRoleBinding)'
kubectl -n ferroehr get role,rolebinding

The first prints zero; the second reports no resources. And because the token is not mounted, no service-account token file exists under the pod’s volumes on the node.

If you are reviewing this chart and reaching for a Role to add: don’t. The correct fix for a future feature that genuinely needs the Kubernetes API is a Role enumerating exactly the verbs and resources it needs, plus turning the token mount back on for that ServiceAccount alone, not a broad grant added speculatively.

Kubelet access

Operator’s, and the second control no application hardening can compensate for.

Run every kubelet with --anonymous-auth=false and --authorization-mode=Webhook so its HTTPS endpoint is not open. Left open, that endpoint permits arbitrary command execution in any container on the node.

For this deployment, spelled out: an attacker reaching an unauthenticated kubelet gets a process-level foothold in a running CDR: the ability to read the database DSN out of the mounted secret files, to read patient data straight from memory, and to do so beneath the layer where authentication, RBAC, ABAC and the ATNA audit trail operate, so none of them see it and none of them can stop it. Non-root, a read-only root filesystem, an empty capability set and a private user namespace raise the cost of what happens next; they do not prevent the entry.

This is a cluster-configuration control, and it is worth confirming rather than assuming; an exposed 10250 is a routine finding in real clusters.

Images: build, provenance, scanning

The image half of the audit. Building a minimal image and signing it is ours; requiring a cluster to check that signature before running the image is yours, and this page carries the copyable policies for both engines that can do it.

The build phase, and what distroless costs

Ours. Each build-phase control and what actually satisfies it:

ControlSatisfied by
Minimal image (distroless)gcr.io/distroless/cc-debian13:nonroot: no shell, no package manager, no libc tooling
Image currencybase images and CI job containers pinned by digest, not tag, so a rebuild cannot silently change bytes
Vulnerability identification in CITrivy over every published image, hadolint over every Dockerfile, plus secret and misconfiguration scanning over the whole tree
Continuous scanning after releasea scheduled scan of the published tags; below
Authorized images onlysigned provenance published; enforcement is the operator’s; below
Non-root by constructionthe 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/readiness body, 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=ferroehr starts an ephemeral container shares the target’s namespaces without adding a shell to the image that ships.

The registry posture: the images are public on GHCR, so a pull needs no credential and there is nothing to leak. Public does not mean trusted, which is the point of the next section: nothing about a public registry stops a cluster pulling a different image with the same name from somewhere else.

Image provenance at admission

The operator’s, and this is provenance nobody in your cluster currently checks.

The publishing lanes attest their artifacts through keyless Sigstore, so a verifier can establish that an artifact came from this repository’s build: the published images carry a signed SLSA v1 provenance attestation, and the chart carries both an attestation and a cosign signature over its digest. Nothing in a cluster requires that check before running an image, and a signature nobody verifies changes nothing about what actually runs. That is what the policies below close.

The identity to trust, read off a real artifact

Each lane signs with a short-lived Fulcio certificate whose subject alternative name is the workflow that issued the token, and whose issuer is GitHub’s OIDC provider. Read it off a published artifact rather than deriving it from a workflow file:

gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:main \
  -R FerroHEALTH/FerroEHR --format json \
  | jq '.[0].verificationResult.signature.certificate
        | {subjectAlternativeName, issuer, sourceRepositoryRef, runnerEnvironment}'
{
  "subjectAlternativeName": "https://github.com/FerroHEALTH/FerroEHR/.github/workflows/build-image.yml@refs/heads/main",
  "issuer": "https://token.actions.githubusercontent.com",
  "sourceRepositoryRef": "refs/heads/main",
  "runnerEnvironment": "github-hosted"
}

The SAN’s ref varies with the trigger, and that is the part a policy gets wrong. Each lane runs on more than one ref, so each issues more than one identity:

ArtifactSigning workflowSAN on a release buildSAN on a development build
the three imagesbuild-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 chartbuild-chart.yml — the reusable chart lane…/build-chart.yml@refs/tags/vX.Y.Z (the release pipeline’s chart leg)…/build-chart.yml@refs/heads/main (a workflow_dispatch chart-only publish, or the recovery of a release’s chart leg for a tag cut before a re-run could finish it)
the release binariesrelease-build.yml…/release-build.yml@refs/tags/vX.Y.Z(none; the lane only runs on a tag)

All three prefixed with https://github.com/FerroHEALTH/FerroEHR/.github/workflows/, and all with issuer https://token.actions.githubusercontent.com.

The release binaries are signed by release-build.yml rather than by the release workflow because the build lives in a reusable workflow: the certificate names the workflow that owns the build definition, which is what makes the --signer-workflow pin below meaningful.

Pick the ref set deliberately, because the choice is a refusal. A policy matching refs/tags/v… only admits released images and refuses ghcr.io/ferrohealth/ferroehr:main, correct for production, and the reason a policy tested against :main appears broken when it is working. A staging cluster that runs :main needs both refs. Nothing accepts an arbitrary branch: refs/heads/main is exact, not a prefix match.

Kyverno

The engine chosen here. Two details decide whether this policy works at all:

  • type: SigstoreBundle. These attestations are GitHub Artifact Attestations, stored in the Sigstore bundle format as an OCI referrer. Kyverno reads that format only under this type; the field defaults to Cosign, which looks for a sha256-<digest>.sig tag 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:, not attestors: alone. Kyverno’s own rule is that “each verifyImages rule 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 with attestors: at the top level therefore fails closed on a perfectly legitimate image.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: ferroehr-image-provenance
  annotations:
    pod-policies.kyverno.io/autogen-controllers: none
spec:
  background: false
  webhookTimeoutSeconds: 30
  rules:
    - name: verify-ferroehr-provenance
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: [ferroehr]
      verifyImages:
        - imageReferences:
            - "ghcr.io/ferrohealth/ferroehr"
            - "ghcr.io/ferrohealth/ferroehr:*"
            - "ghcr.io/ferrohealth/ferroehr-viewer*"
          # Sigstore bundle format, GitHub Artifact Attestations. Omitting
          # this defaults to Cosign, which looks for a signature that does not
          # exist and refuses every image.
          type: SigstoreBundle
          failureAction: Enforce
          attestations:
            - type: https://slsa.dev/provenance/v1
              attestors:
                - count: 1
                  entries:
                    - keyless:
                        issuer: https://token.actions.githubusercontent.com
                        # Released images only. For a staging cluster that runs
                        # the development tag, make the group
                        # `(heads/main|tags/v.+)`.
                        subjectRegExp: '^https://github\.com/FerroHEALTH/FerroEHR/\.github/workflows/containers\.yml@refs/(tags/v.+)$'
                        rekor:
                          url: https://rekor.sigstore.dev
              conditions:
                - all:
                    - key: '{{ buildDefinition.buildType }}'
                      operator: Equals
                      value: https://actions.github.io/buildtypes/workflow/v1

Add ghcr.io/ferrohealth/ferroehr-postgres* to imageReferences only if you run that image in the namespace; the chart never installs it.

failureAction sits on the verifyImages entry: the spec-level validationFailureAction is deprecated in the CRD (“use validationFailureAction under the validate rule instead”), and a verifyImages rule has no validate block. mutateDigest, verifyDigest and required all default to true, which is what you want: a tag is rewritten to the digest that was verified, and an image with no attestation is refused rather than passed.

sigstore-policy-controller

If you already run it. The equivalent two details:

  • signatureFormat: bundle on the authority. The default is legacy (cosign’s own), which cannot read these attestations. Requires policy-controller v0.13.0 or newer.
  • an attestations: entry, because in bundle format policy-controller supports only attestations, not plain signatures.
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: ferroehr-image-provenance
spec:
  images:
    - glob: "ghcr.io/ferrohealth/ferroehr**"
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://token.actions.githubusercontent.com
            # Same group as the Kyverno policy: `(heads/main|tags/v.+)` for a
            # staging cluster that runs the development tag.
            subjectRegExp: '^https://github\.com/FerroHEALTH/FerroEHR/\.github/workflows/containers\.yml@refs/(tags/v.+)$'
      signatureFormat: bundle
      attestations:
        - name: require-slsa-provenance
          predicateType: https://slsa.dev/provenance/v1

Note

What has been checked, and what has not. The identity, the issuer and the predicate type above are verified first-hand against the published images with cosign verify --certificate-identity-regexp …, which admits them and refuses both an unsigned image and, under a tags-only pattern, a :main image, so the matcher is neither vacuous nor accidentally permissive. The manifests are checked field by field against the published ClusterPolicy and ClusterImagePolicy CRDs. Neither policy has been exercised by a running admission controller, and the Kyverno CLI is no substitute: kyverno test reports a verifyImages rule as Excluded and returns the same verdict whichever result you assert. Before enforcing, run the policy in Audit (Kyverno) or as a warn policy (policy-controller) long enough to see one real deployment pass.

The chart is signed too, and verifying it is a release-time check rather than a per-pod one: an admission controller sees pods and images, never the Helm artifact a human pulled. The chart’s own commands are on the Kubernetes page; its identity is the build-chart.yml row in the table above.

Should the chart ship an admission policy? No, and the reason is structural: an admission policy is cluster-scoped and governs workloads the chart knows nothing about, while the chart deliberately renders no cluster-scoped object at all (see namespaces). A ClusterPolicy in this chart would mean helm uninstall removing a control that other releases had come to depend on. The chart’s contribution is the policy document, here, versioned with the lanes whose identity it encodes.

Without an admission controller, the manual equivalent is a release-time check. Add --signer-workflow to insist on the lane as well as the repository; without it you are trusting that some workflow here signed the image:

gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:main \
  -R FerroHEALTH/FerroEHR \
  --signer-workflow FerroHEALTH/FerroEHR/.github/workflows/build-image.yml

Substitute a vX.Y.Z tag for main on a release; the signer workflow is the same.

Important

Signing landed in the publishing lanes during the 3.17.4 cycle, so image tags from before it answer HTTP 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:

ControlSatisfied byCheck it yourself
Trusted, minimal base imagesgcr.io/distroless/cc-debian13:nonroot, digest-pinned; build stages pinned by digest toogrep FROM docker/Dockerfile
Vulnerability scanning in CITrivy over every published image, HIGH/CRITICAL with a fix availablethe image vulnerability scan job log
Scanning after releasea weekly scan of the published tags.github/workflows/image-scan.yml
Dockerfile lintinghadolint, with adjudicated exceptions in .hadolint.yamlthe Dockerfile lint job
Secret + misconfiguration scanningTrivy’s secret and misconfig scanners over the treethe tree-scan job
Dependency advisoriescargo deny on every change, plus a scheduled latest-dependencies lanecargo deny check
Signed imagesa Sigstore keyless SLSA v1 provenance attestation per imagegh attestation verify oci://ghcr.io/ferrohealth/ferroehr:<tag> -R FerroHEALTH/FerroEHR
Signed chartan attestation plus a cosign signature over the chart digest, both read back from the registry before the lane reports successgh attestation verify oci://ghcr.io/ferrohealth/charts/ferroehr:<version> -R FerroHEALTH/FerroEHR
SBOMan SPDX SBOM written onto the image index by the builder; a CycloneDX dependency-graph SBOM attached and Sigstore-attested per released binarydocker buildx imagetools inspect <image> --format '{{json .SBOM}}'
Adjudicated findings carry their argumentOpenVEX documents under security/vex/, applied by the scheduled scanread the impact_statement in the document
Secured CI/CDevery uses: digest-pinned, permissions: {} by default, no context interpolated into a shell, zizmor and CodeQL over the workflows themselvesthe zizmor job
No long-lived registry tokencrates.io Trusted Publishing (OIDC); GHCR uses the ephemeral workflow tokenthe crates.io leg of .github/workflows/release.yml
Independent gradeOpenSSF Scorecard, computed by someone other than usthe Scorecard badge

Two gaps remain, stated here rather than left out of a page that otherwise reads as complete:

  1. 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.
  2. Provenance exists only from the 3.17.4 cycle onward. Images published before the signing lane landed carry no attestation and never will, because a published artifact is not replaced. gh attestation verify on 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

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 requiresThe chart sets
hostNetwork/hostPID/hostIPC unsetnone set
no privileged containerssecurityContext.privileged: false
allowPrivilegeEscalation: falseset
capabilities dropped to ALL (only NET_BIND_SERVICE may be added)drop: [ALL], none added
runAsNonRoot: trueset, pod and container, uid/gid 65532
seccompProfile.type RuntimeDefault or LocalhostRuntimeDefault, pod and container
no hostPath volumesonly 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=true produced 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 != false
    

    while the healthy ReplicaSet kept both replicas serving, because maxUnavailable: 0 means 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

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 boundaryKubernetes plus PostgreSQL: separate Secrets, NetworkPolicies, quotas, RBAC, and a separate database
Blast radius of an application-level bugone organisation, because a query has nothing else to reach
Blast radius of a compromised database credentialone organisation’s database
Costone 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 benefitAlready covered by
mTLS between servicesthe 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 restrictionthe shipped NetworkPolicy, and for the viewer an egress policy that admits the CDR Service, DNS and outbound HTTPS and nothing else
Request-level observabilityOTLP 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 accessthe 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:

ValueDerivation
resources.requests.cpu250mthe 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.memory256Miobserved 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.cpu2AQL 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.memory1Gia 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:

  1. Per request: config.server.body_limit_bytes and config.rest.body_limit_bytes (413 on an over-large body), config.server.request_timeout_ms (408), config.query.timeout_ms and config.query.max_result_rows (a query cannot return an unbounded row set), config.db.statement_timeout_ms as the backstop the HTTP timeout cannot be, because dropping a handler future does not cancel the statement PostgreSQL is running.
  2. Per connection / per caller: config.server.header_read_timeout_ms bounds a socket before a request exists and config.server.idle_timeout_ms a kept-alive one between requests; config.server.caller_rate refuses 429; config.server.max_concurrent_requests refuses 503 when the server is full.
  3. Per container: the requests and limits above.
  4. 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: false makes “no open ingress” a checked fact. With it set, an empty ingressFrom is 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 your ingressFrom fails 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 stock helm install produces the open rule, and now says so, in the values file, in the object’s kubernetes.io/description, and in the notes printed at install. A non-empty ingressFrom always 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 ingressFrom admits.

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.to nor networkPolicy.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. Supplying rules but omitting the database destination renders, so keep the database entry where it belongs rather than folding it into a raw rule where it is easy to lose.

The destination set, derived from the configuration tree rather than from what a default install happens to use. Every row except the usage report is off unless its key is set, so add only the rows you have switched on:

DestinationTurned on byPortTypically
Cluster DNSalwaysUDP+TCP 53in-cluster (always allowed)
PostgreSQLthe DSN, never optionalthe DSN’s (5432)off-cluster (managed)
OTLP collectorconfig.telemetry.otlp.endpoint4317 gRPC / 4318 HTTPin-cluster
Token issuer (metadata + key set)config.auth.issuer, unless config.auth.jwks_file is supplied443off-cluster
External policy decision pointconfig.authz.abac.engine: remote plus config.authz.abac.remote.serverthe URL’s (3001)in-cluster
FHIR terminology serverconfig.terminology.server.enabled plus its url443off-cluster
FerroTERM in the clusterterminology.enabled8080in-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 endpointconfig.terminology.server.oauth.token_endpoint443off-cluster
AMQP broker (events)config.events.enabled plus secrets.eventsUrl5672, or 5671 with config.events.tlsin-cluster
Object storeconfig.multimedia.enabled plus config.multimedia.endpoint (unset means AWS regional resolution)443, or the endpoint’soff-cluster
Syslog audit repositoryconfig.audit.syslog.enabled6514 TCP (TLS)off-cluster
FHIR audit repositoryconfig.audit.fhir_feed.enabled plus secrets.auditFhirFeedUrl443off-cluster
FerroPULSE usage reportusageReport.enabled, on by default443off-cluster, report.ferropulse.eu. Without the rule the report logs a warning and nothing else is affected; see Usage report

The viewer, when enabled, carries its own egress policy rather than appearing in this table: it admits the CDR Service, DNS, and outbound HTTPS for an identity provider. Narrow that last rule to your issuer’s address if you can.

Warning

A NetworkPolicy cannot match a DNS name. The API selects peers by pod, namespace or ipBlock CIDR 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 a 0.0.0.0/0 rule 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

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 contentPresent whenWhat it gets an attacker
The database DSNalwaysdirect read/write access to all patient data, bypassing the API, its authorization and its audit trail entirely
A Basic user’s Argon2id hashsecrets.basicUserPasswordHashesan offline cracking target, not a usable password
The version-signing passphrase (plus the PGP key via config.files)config.signing.mode: pgpthe ability to forge version signatures, breaking the integrity guarantee
A terminology client_secretsecrets.terminologyClientSecretaccess to the terminology server as this client
The AMQP broker URLsecrets.eventsUrlaccess to the change-event stream, which is PHI-free by design
The audit repository URLsecrets.auditFhirFeedUrlthe ability to read or forge audit records at the repository
S3 credentialssecrets.multimediaSecretAccessKey (with secrets.multimediaAccessKeyId)offloaded DV_MULTIMEDIA blobs, which are PHI
The viewer’s OIDC client secretviewer.existingSecret, when the viewer is enabledthe 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 extraVolumes and extraVolumeMounts at it and set the matching *_file configuration key; config.signing.key_passphrase_file, config.multimedia.secret_access_key_file, the terminology grant’s client_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), and serviceAccount.annotations exists for the IRSA or Workload Identity binding that needs.
  • config.audit.fhir_feed.url is the only credential-bearing key with no *_file sibling, 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:

SignalWhy it is unambiguous for this image
Any execve of anything other than /usr/local/bin/ferroehrthe image contains no other executable to run, so a second process means one arrived from outside
A shell process in the containerimpossible under normal operation; there is no shell in the filesystem
A write outside /tmpthe root filesystem is read-only and /tmp is the only declared writable mount
An outbound connection to anything not in the egress tablethe inventory is complete and small
Any attempt to load a kernel module, mount, or change namespacesthe 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 podsWhat a deviation suggests
request rate per poda load-balancing fault, or one pod being addressed directly, bypassing the Service
error ratio (5xx over total) per poda pod-local fault: a broken database connection, an exhausted pool, a failing dependency one pod reaches and others do not
tail latency per poda throttled pod (CPU limit), a noisy neighbour, or a degraded node
authentication-failure rate per podcredential stuffing aimed at one endpoint, or a token-validation path failing on one pod (an unreachable JWKS endpoint)
resident memory slope per poda leak or an unbounded working set on one replica only
database pool acquire-wait per podthat 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.)

CredentialRotationNotes
Database DSNupdate the Secret, then rollout restartrotate the database password too, or the old one still works; maxUnavailable: 0 keeps the old pods serving on the old credential until the new ones are ready, so grant both briefly or accept a gap
Terminology client_secretupdate, then restartrotate at the identity provider in the same window
AMQP broker URLsupdate, then restartthe credential is inside the URL
S3 credentialsupdate, then restartor remove them entirely with IRSA or Workload Identity
Audit repository URLupdate, then restartstill an environment value (no *_file sibling)
Basic user password hashupdate, then restartrotate the password and re-hash at the OWASP Argon2id floor, which the server checks at boot
Viewer OIDC client secretupdate, then restart the viewerrotate at the identity provider in the same window
Version signing keyread the next section firstrotate the signing subkey, not the certificate

Rotating the version signing key

In the default digest mode there is no key: the version signature is a hash of the canonical form, recomputed at read time. Nothing to rotate, and nothing breaks.

In pgp mode there are two mechanisms, and the first is the one to reach for.

The ordinary path: rotate the signing subkey. OpenPGP is built for this. A certificate is a primary key plus its subkeys (RFC 9580 §10.1), the primary key certifies while a subkey signs, and rotating means issuing a new signing subkey on the same certificate. The retired subkey stays in the certificate, so every version it signed keeps verifying, with no configuration change, no second key file, and no window where history is unreadable. It is also the only path that keeps the primary key’s identity intact; replacing a primary key discards everything that has ever been said about it.

# add a fresh signing subkey to the existing certificate
gpg --quick-add-key <FINGERPRINT> ed25519 sign 1y
# revoke or expire the previous one, then re-export BOTH halves
gpg --export-secret-keys --armor <FINGERPRINT> > signing.asc

Update the mounted Secret and roll the pods. The server signs with the newest signing-capable subkey and verifies against every subkey the certificate carries, so the switch is invisible to readers.

The exception: a whole certificate is replaced. A compromised primary key, an organisational change, or migrating from a different signer means a genuinely new certificate, and then the old one must be retained, because a stored signature carries no key identifier and a version signature is an immutable committed fact that cannot be re-issued. Keep the retired public key:

[signing]
mode = "pgp"
key_path = "/etc/ferroehr/signing.asc"                  # the new certificate
retired_key_paths = ["/etc/ferroehr/signing-2025.pub.asc"]   # public, verify-only

Under Helm these are config.signing.key_path and config.signing.retired_key_paths, with both files mounted through config.files.

Note

Retired entries are public keys, which is what makes the safety property structural rather than a promise: no secret key is loaded for them, so a retired certificate can verify and can never sign again. Verification does not become permissive either: a signature matching neither the active certificate nor any retired one still fails, and tampered content still fails.

This is the same mechanism Debian uses for its archive: debian-archive-keyring ships current and retired keys so packages signed under an older key stay verifiable (Debian archive keys).

What neither mechanism covers. Key expiry and revocation are a different problem: a strict verifier arguably should accept a signature made while the key was valid and reject one made after, and nothing in a detached signature proves when it was made. Solving that needs trusted timestamps (RFC 3161), the approach Sigstore takes with short-lived keys and a timestamp authority. FerroEHR does not implement it, so treat an expired signing key as a certificate replacement and use config.signing.retired_key_paths.

If you are mid-rotation and need reads to keep working before either mechanism is in place, config.signing.verify_on_read: warn downgrades a verification failure from a 5xx to a logged and metered event (version_signature_invalid_total{verdict="pgp_invalid"}). That is a deliberate, recorded reduction in an integrity guarantee, not a setting to leave on.

Logging: two streams that are not interchangeable

Container logging is ours, and already the right shape. The server writes to stdout and stderr and never to a file inside the container, which is both the Kubernetes logging architecture’s expectation and a requirement of readOnlyRootFilesystem: true: there is nowhere to write a log file. Set config.telemetry.format: json (the chart’s default) for a collector; pretty is for a terminal.

The distinction that matters, because getting it wrong loses the accountability record:

Application logATNA audit trail
Purposediagnostics: what the process is doingaccountability: who accessed which patient’s record
Destinationstdout/stderr → node → your collectorits own store in the database, plus optional forwarding to an external repository
FormatJSON lines, oursDICOM PS3.15 plus FHIR AuditEvent (IHE BALP), standardised
Retrievalyour log toolthe ITI-81 FHIR AuditEvent search endpoint
Retentionyour collector’s policyconfig.audit.store.retention_days (0 keeps forever)
May it be sampled or dropped?yes, it is diagnosticsno

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.

ControlOn a managed cluster
Host hardening, node OS patchingprovider’s, though node images and upgrades are usually still yours to trigger
API-server flags, authorization modeprovider’s (RBAC is on by default everywhere mainstream)
etcd access + encryption at restprovider’s, but envelope encryption with your own KMS key is usually opt-in, and it is the control our Secrets need
Kubelet authenticationprovider’s
Control-plane audit loggingprovider’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 Admissionyours (namespace labels)
NetworkPolicyyours to write; enforcement depends on the CNI
Everything the chart setsunchanged: it is a workload

Check the CNI before relying on the shipped NetworkPolicy. This is the item that varies most and fails most silently: a NetworkPolicy on a cluster whose network plugin does not enforce it is an object the API accepts, stores and displays, with no effect and no warning. Provider defaults differ, versions change them, and some require enabling enforcement at cluster creation, which cannot be changed afterwards on some platforms. Do not read your provider’s documentation and conclude; test it:

kubectl create namespace ferroehr-probe
kubectl -n ferroehr-probe run probe --image=busybox:1.37 --restart=Never --command -- sleep 600
kubectl -n ferroehr-probe exec probe -- nc -w3 -z <ferroehr-pod-ip> 5432   # must fail
kubectl -n ferroehr-probe exec probe -- nc -w3 -z <ferroehr-pod-ip> 8080   # must succeed

If the first command succeeds, the policy is decoration and every claim in this section that rests on it is void for your cluster. This is also the reason the project’s own deployment probe harness declares NetworkPolicy enforcement as something it does not exercise: a green result on one cluster’s CNI would say nothing about yours.

Provider audit tooling exists: for EKS, hardeneks is the commonly cited one. We have not run it, so it is named as a starting point rather than a recommendation: treat its output as input to the same ownership question this section asks, not as a verdict.

From source

You can build the ferroehr binary yourself: for a platform without a published image, for local development, or to run the test suite. This chapter covers the prerequisites and the build. Most operators should prefer the published container images (Docker Compose, Kubernetes & Helm); build from source when you need to.

Prerequisites

  • The pinned Rust toolchain. The repository pins Rust 1.99.1 (edition 2024) via rust-toolchain.toml, so rustup installs 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 FERROEHR_CONFIG (with neither, the server reads no file);
  • --set <key>=<value> is a repeatable dotted-path override with the highest precedence of all (--set db.max_connections=40);
  • ferroehr config check validates the effective configuration and prints it redacted, exiting 0 when valid and 1 otherwise, the fastest way to test a deployment’s configuration before starting it;
  • ferroehr db migrate applies the embedded migrations and exits, the out-of-band schema step, run under a ferroehr_migrator DSN;
  • ferroehr db verify checks, 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) asks GET /health/readiness on the configured server.listen port over the loopback and exits 0 on 200, 1 otherwise.

Note

Building from source gives you the same binary the images use: the container Dockerfile pins its Rust version from the same rust-toolchain.toml, and CI cross-checks the two so they cannot drift.

Build features

The server builds with three additive cargo features, all on by default: fhir (the FHIR terminology providers, and the FHIR AuditEvent the audit store, the audit feed and the ITI-81 retrieval carry), events (contribution-outbox eventing and the AMQP transport), and multimedia (DV_MULTIMEDIA externalization to S3-compatible object storage). Their implementations live in a separate crate that the platform library pulls in only when the matching feature is on, so a build without them contains none of their code.

A slim build compiles them out entirely:

cargo build --release --locked -p ferroehr-server --no-default-features

A slim binary refuses loudly at boot if the configuration enables an integration it was built without: multimedia.enabled, events.enabled, audit.store.enabled, audit.fhir_feed.enabled, or a configured external FHIR terminology provider. The syslog ATNA feed and the in-process terminology bundle remain available in slim builds.

Warning

The local audit store is on in the shipped defaults, so a slim build refuses to start on an untouched configuration. To run one, disable it explicitly (FERROEHR__AUDIT__STORE__ENABLED=false) or configure the syslog feed as the audit sink; silently dropping the audit trail is not a boot mode.

Configuration reference

FerroEHR is configured by one file, ferroehr.toml, whose sections cover the entire server, with FERROEHR_* environment variables (and repeatable --set flags) as per-key overrides on top. This page is the entry point: the quickstart, how configuration loads and how environment names map onto the file, the one key that selects the openEHR specification generation set, and a map of which page documents which section.

Quickstart

Copy the annotated template, edit it, and run:

# A fully-commented ferroehr.toml with every key at its default.
curl -fsSLo ferroehr.toml \
  https://raw.githubusercontent.com/FerroHEALTH/FerroEHR/main/app/ferroehr/assets/ferroehr.default.toml

# Edit it — at minimum set db.url and an auth mechanism.
$EDITOR ferroehr.toml

# Validate without touching the database, then run.
ferroehr config check --config ferroehr.toml
ferroehr --config ferroehr.toml

A server started with no file and no environment still boots, with one exception you will hit immediately: authentication is on by default and a server with no mechanism configured refuses to start. See Zero-config boot and the production checklist.

How configuration loads

Configuration is assembled once at boot from four layers, lowest precedence to highest:

  1. Built-in defaults: the values in the tables on the section pages.
  2. The config file: ferroehr.toml (see file discovery).
  3. FERROEHR_* environment variables: override individual keys.
  4. --set key=value CLI flags (repeatable), which win over everything.

Some conventional names sit below their FERROEHR_ forms within layer 3:

  • DATABASE_URL → db.url, and RUST_LOG → telemetry.filter.
  • PORT → a 0.0.0.0:<PORT> value for server.listen (the port container platforms such as Vercel, Cloud Run and Heroku inject and route traffic to).
  • The libpq environment set (PGHOST, PGUSER, PGPASSWORD, PGDATABASE, PGPORT, PGSSLMODE), which managed-Postgres integrations such as Neon inject: when PGHOST is set and no URL form is, the server assembles the DSN from them. An explicit DATABASE_URL beats the assembled form, and FERROEHR__DB__URL beats 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.

TOMLEnvironment variable
[db] max_connections = 20FERROEHR__DB__MAX_CONNECTIONS=20
[auth] issuer = "…"FERROEHR__AUTH__ISSUER=…
[management.endpoints] env = "off"FERROEHR__MANAGEMENT__ENDPOINTS__ENV=off
[terminology.server] url = "…"FERROEHR__TERMINOLOGY__SERVER__URL=…

Scalars are typed automatically (bool / int / float, else string). List-typed keys take comma-separated values (FERROEHR__AUTH__AUDIENCE=ferroehr,other). Map-keyed tables are reachable too: the map key is just another segment (FERROEHR__AUTHZ__ABAC__POLICY__COMPOSITION__NAME). Arrays of tables (the Basic-auth user store) are file-only, because the environment grammar has no way to spell an array index.

Note

Enum values are lowercase / snake_case tokens, exactly as the tables show. Secret-typed keys are redacted everywhere the configuration is rendered (the /management/env snapshot, GET /ferroehr/rest/extensions/v1/admin/config, ferroehr config check, ferroehr report, logs), and a secret-URL key shows its host and path with the embedded credentials masked. Each secret, and each database and broker URL, has a *_file sibling that reads the value from a file, for Kubernetes and Docker secret mounts. Setting a key and its *_file sibling at once is a boot error.

File discovery

The server reads the file --config <path> names, or else the file FERROEHR_CONFIG=<path> names; later layers still override its values. A named file is fatal if absent, unreadable or unparseable. With neither set, the server reads no file and searches no directory: the environment and the defaults make the whole configuration.

Strict validation

Configuration is validated at boot (and by ferroehr config check), and the server refuses to start on any error:

  • Unknown keys are rejected: in the file, naming the key and the closest valid one, and in every FERROEHR__… override, so a misspelled security key can never be silently ignored. Only the uniform FERROEHR__<SECTION>__<KEY> form sets a key. Any other FERROEHR_ variable, such as the single-underscore FERROEHR_DB_URL, sets nothing and is left alone, because the viewer’s FERROEHR_VIEWER_ variables share the prefix.
  • Type errors are boot errors, naming the key and what was expected.
  • Semantic errors are aggregated: one pass reports every problem at once, so a broken configuration is fixed in a single iteration.

spec_profile

# The openEHR specification generation set the server runs.
spec_profile = "development"   # or "stable"

openEHR publishes released specification versions and keeps developing the next ones. FerroEHR generates both, and this key decides which set the running server serves.

ValueRMBASELANGChoose it when
development (default)1.2.01.3.01.1.0You want the generations this build is developed against. This is the default for every deployment that does not set the key.
stable1.1.01.2.01.0.0Your 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:

DirectionSupported?Why
stable → developmentAlways safeopenEHR 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 → stableOnly for data that never used a development-only constructThere 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.

ValueAssertsOn boot
sandbox (the binary’s default; the Helm chart has none and refuses to render until you choose)Nothing; the deployment must not hold real personal dataSays which separations are missing: a red notice on the banner, one warn line per gap with the structured field posture = "deployment", and the same list on GET /management/status under deployment; the public GET /ferroehr/rest/status carries the profile only
productionEvery separation below holds, or is accepted by name in deployment_acceptsRefuses 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 tokenWhat production requires
shared_credential[storage.party] url and [storage.linkage] url set, so the domains connect on their own database roles
shared_clusterThe four pools reach four different PostgreSQL clusters, read from pg_control_system().system_identifier on each pool, never from the DSN text. The audit pool counts: Swiss law keeps the access log on a system separate from the one processing the data
open_subject_namespace[privacy] subject_namespaces declared, so an EHR_STATUS subject is an opaque pseudonym
audit_off[audit] enabled with a durable sink (the local store, syslog or the FHIR feed)
audit_fails_open[audit] fail_mode = "closed", so an operation whose access record cannot be taken is refused instead of succeeding unlogged
no_category_mapA category map applies while auditing is on: the one FerroEHR ships (the default) or [audit.categories] templates and archetypes of your own, so every access record names the categories of the data it reached (EHDS Annex II 3.2(c)). The gap opens only with [audit.categories] shipped_map = false and nothing declared
open_ehr_access_default[authz.rbac] ehr_access_default = "restricted", so an EHR carrying no ACCESS_CONTROL_SETTINGS is reachable only by an admin. The shipped default is open. The setting is enforced with [authz.rbac] enabled on or off
migrate_on_runtime_credential[db] migrate_url set, or migrate = "verify", so no credential that serves requests can alter the schema. Evaluated per domain database: a domain relocated to a database of its own is prepared on [storage.migrate.<domain>] url when that is set, and on that domain’s runtime DSN, which opens the gap, when it is not
auth_off[auth] enabled = true, so no request is served unauthenticated (CRA Annex I Part I(2)(d))
rbac_off[authz.rbac] enabled = true whenever authentication is on, so a caller reaches only the operations its roles allow (CRA Annex I Part I(2)(d)). Not reported while auth_off is
plaintext_listener[server.tls] set, or [server] listen on a loopback address, so data does not cross the network in clear (CRA Annex I Part I(2)(e)). A deployment behind a TLS-terminating ingress accepts plaintext_listener by name, which records that the ingress carries the encryption. The gap also opens when [management] port is set on an enabled management surface: that listener binds every interface and never speaks TLS, so unset the port (the surface then shares the main listener) or accept the gap by name where the port is unreachable off the host
plaintext_brokerAn amqps:// broker URL, or tls = true, on the enabled change-event outbox ([events]), so change events do not cross the network in clear (CRA Annex I Part I(2)(e)). A deployment whose broker path stays on a segment it trusts accepts plaintext_broker by name
plaintext_audit_feedAn https:// URL for an enabled audit FHIR feed ([audit.fhir_feed] url), so the access records, which name the patient and the professional, do not cross the network in clear (CRA Annex I Part I(2)(e)). A deployment whose repository is reached on a segment it trusts accepts plaintext_audit_feed by name
plaintext_object_store[multimedia] allow_http = false on an enabled multimedia store, so the externalised clinical blobs travel over an https:// endpoint (CRA Annex I Part I(2)(e)). A deployment whose object store is reached on a segment it trusts accepts plaintext_object_store by name

An accepted gap is stated on every boot and on /management/status; it can be run, not hidden. Environment form: FERROEHR__DEPLOYMENT_PROFILE=production, FERROEHR__DEPLOYMENT_ACCEPTS=shared_cluster. There is no research value: the key controls rigour, not purpose, and a secondary-use platform under an EHDS data permit runs on real patient data at production rigour whatever it is for.

This is FerroEHR’s own posture, not a legal requirement. GDPR Art. 4(5) asks that the additional information needed to re-identify a person be “kept separately and … subject to technical and organisational measures”, not that it sit on a separate server; one cluster with separated schemas and roles is a defensible reading. Two clusters are materially stronger, because a superuser, an instance-wide point-in-time recovery and a single compromise are bridges no grant can close, and that is a choice a deployment should make deliberately rather than inherit from a quickstart. See Compliance.

Where each section is documented

SectionWhat it coversPage
[server], [server.caller_rate], [server.tls]The HTTP listener (the family section): timeouts, the body limit, the concurrency bound, the drain, the caller rate, TLSServer, database & telemetry
[rest], [rest.identity]The openEHR REST surface: the API base path, the system identifier, Swagger UI, CORS, the clinical body tier, the deployment’s own identityServer, database & telemetry
[db]PostgreSQL connection, pool, migrationsServer, database & telemetry
[telemetry]The console, the request log and the OpenTelemetry exportServer, database & telemetry
[usage_report]The daily installation report to FerroPULSE, on by default; what it sends is on the Usage report pageServer, database & telemetry
[auth], [authz]Authentication (Basic, OAuth2/OIDC) and RBAC/ABACAuthentication & access
[admin], [management]The ADMIN API group and the ops-introspection surfaceAuthentication & access
[smart]SMART App Launch discovery and scope enforcementAuthentication & access
[signing]VERSION signing and read-time verificationAuthentication & access
[licence]The commercial licence tokenAuthentication & access
[query]AQL execution budgets and result ceilingsIntegrations
[ehr_index], [ehr_index.oauth], [ehr_index.tls]Who holds the patient-to-EHR cross-reference: an IHE patient identity source over PIXm (the default) or the in-instance mapIntegrations
[events]Change eventingIntegrations
[terminology], [multimedia]The terminology server, multimedia externalizationIntegrations
[audit], [audit.store], [audit.syslog], [audit.fhir_feed]The IHE ATNA audit trail and its sinksAudit
[origins]The origins of data the deployment recognises, which a retention period and an ABAC rule may name (EHDS Annex II 3.4)Audit
[privacy], [privacy.identifier_scan]What the clinical side refuses to hold: the subject reference, identified parties, the identifier scannerPrivacy & data minimisation
[cohort], [cohort.predicates]Cross-domain cohort queries: the demographic predicate allow-list, the small-cell floor, the cohort ceilingPrivacy & data minimisation
[demographic.identifier_protection]Sealing national identifiers in the demographic domain, and the key that opens themPrivacy & data minimisation
—The CLI, the production checklist, file-versus-environment guidanceCLI & production checklist

Server, database & telemetry

The listener ([server]), the REST surface ([rest]), the PostgreSQL connection ([db]), the observability section ([telemetry]), and the usage report ([usage_report]). Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.

[server]

The HTTP listener: the section every FerroHEALTH product shares (ferrohealth-server), so the keys and their behaviour are the same in each. It carries the stack every request passes through: the server’s request id, the request timeout, the body limit, the concurrency bound, compression, the request log, the health routes and the bounded drain on SIGTERM.

[server]
listen = "0.0.0.0:8080"
base_path = ""
request_timeout_ms = 30000
header_read_timeout_ms = 30000
idle_timeout_ms = 120000
body_limit_bytes = 67108864
drain_delay_ms = 0
shutdown_timeout_ms = 10000
# max_concurrent_requests = 256
retry_after_s = 1
trusted_proxies = []
forwarded_header = "forwarded"
compression = true
compression_min_bytes = 1024
KeyTypeDefaultDescription
listensocket address0.0.0.0:8080The IP address and port the listener binds: every interface at the port the container image exposes. A host name is refused at startup. PORT alone gives 0.0.0.0:<PORT>, and FERROEHR__SERVER__LISTEN wins over both. Set 127.0.0.1:8080 to keep a bare binary on the loopback.
base_pathstringemptyThe path every route is served under, the health routes included. Empty serves them at the process root. The openEHR API path is rest.base_path.
request_timeout_msint30000A request still running after this long is answered 408 with the openEHR error body.
header_read_timeout_msint30000How long a client may take to send its request head, counted from accept for a connection’s first request and from the first byte of each later request on a kept-alive connection; a client that has not sent it by then, one that sent nothing after connecting included, is disconnected. Bounds the slow-header and the silent connection, which no other limit reaches. 0 is refused.
idle_timeout_msint120000How long a kept-alive HTTP/1.1 connection may wait for its next request; past it the server closes the connection with a clean end of stream. Keep it longer than the idle timeout of your clients (Go’s http.DefaultTransport keeps an idle connection 90 s) and of any load balancer, ingress controller or proxy in front, so they close an idle connection first and never send a request into one the server is closing. 0 is refused.
body_limit_bytesint67108864 (64 MiB)The largest request body any route reads, refused 413 above it. The bulk routes (operational-template upload, /message/import) take up to this; rest.body_limit_bytes narrows the rest of the clinical surface below it. The largest operational template in the vendored CKM corpus is several megabytes, well below this ceiling.
drain_delay_msint0On SIGTERM or SIGINT, readiness answers 503 this long while the listener still accepts, so an orchestrator stops routing first.
shutdown_timeout_msint10000Then the listener closes and the requests in flight get this long to finish; a connection still open after it is dropped.
max_concurrent_requestsintunsetThe requests served at once. Past it a request is answered 503 with Retry-After and the openEHR error body, never queued, so offered load beyond capacity cannot exhaust memory. The health routes are never refused. Unset, there is no bound.
retry_after_sint1The Retry-After of a 503 or 429 the listener writes.
trusted_proxieslist[]The reverse proxies, as addresses or CIDR blocks, whose forwarded client address is believed. Empty, every request is named by its peer address.
forwarded_headerenum{forwarded,x-forwarded-for}forwardedThe header a trusted proxy names the client in (Forwarded is RFC 7239).
compressionbooltrueCompress a response with gzip or Brotli when the client accepts one.
compression_min_bytesint1024The smallest response body that is compressed.

A refusal the listener writes itself (408, 413, 429, 503) and a handler that panics (500) answer the standard openEHR { error, message } body. 413 and 429 are not in the ITS-REST status table; ITS-REST admits them as additional, non-conflicting codes, and they are what RFC 9110 §15.5.14 and RFC 6585 §4 define for these refusals. A panic’s message reaches neither the body nor the log.

Every request gets a request id the server mints, which the request log and the access record carry and the response returns in X-Request-Id. A client’s own X-Request-Id is returned to it unchanged and never recorded.

[server.caller_rate]: per-caller request rates

[server.caller_rate]
requests_per_second = 1024
burst = 2048
KeyTypeDefaultDescription
requests_per_secondint(required in the table)Sustained requests per second one authenticated subject may send on the clinical API.
burstint(required in the table)How many requests one subject may send at once before the rate applies.

Unset, no caller is limited. The chart and the Compose quickstart set the values above.

This is not max_concurrent_requests, and you can tell them apart from the status alone. max_concurrent_requests protects capacity: too many requests in flight at once, from anyone, and the excess is refused 503 + Retry-After. The caller rate protects fairness: one caller asking too often over time, refused 429 + Retry-After. A 503 means the server is full; a 429 means you are asking too fast.

The limit is keyed on the authenticated subject, the only fair key for a clinical API: a hospital behind one NAT is a single address. It sits inside authentication, so the health routes, the status document and the discovery document are never refused by it.

Tip

Benchmarking this server? Lift the caller rate first, or you will measure it instead of the server. The project’s own measurement lanes compose an overlay that does exactly that, and both instruments refuse to write a record if the server answered any 429.

[server.tls]: native TLS and mutual-TLS client authentication

Native TLS termination on the main listener, unset by default; deployments commonly terminate TLS at an ingress. client_auth = "required" is the IHE ATNA ITI-19 mutually-authenticated-node posture (see the Audit trail chapter). The separate-port management listener always stays plain HTTP, so under deployment_profile = "production" setting [management] port opens the plaintext_listener gap.

[server.tls]
certificate_file = "/etc/ferroehr/server.pem"
key_file = "/etc/ferroehr/server.key"
client_auth = "none"
min_version = "1.3"
# reload_interval_s = 60
KeyTypeDefaultDescription
certificate_filepath(required in the table)Server certificate chain (PEM), the leaf first. A missing, unreadable or certificate-less file stops startup.
key_filepath(required in the table)Server private key (PEM), read as a secret file. A key that does not belong to the leaf stops startup.
client_authenum{none,optional,required}noneClient-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_filepathunsetThe explicit CA bundle client certificates must chain to, never the web PKI. Required when client_auth is optional or required, and refused at load when it is none, since a listener under none asks no client certificate.
min_versionenum{“1.3”,“1.2”}"1.3"The lowest TLS version this listener negotiates. "1.0" and "1.1" are refused when the configuration is read (RFC 8996).
reload_interval_sintunsetHow often the certificate, key and client CA are read again while the server runs. The files are always read again on SIGHUP. A changed pair is served, and a changed client CA verifies, from the next handshake on without a restart; a read that fails is logged and the running configuration stays.
healthcheck_identity_filepathunsetThe client certificate chain and key, in one PEM, that ferroehr healthcheck presents to a listener requiring one.

Before 5.5.0, client_ca_file alone meant that every client presents a certificate. From 5.5.0, client_auth decides it and defaults to none, so a configuration carried over from an earlier release set client_ca_file and no client_auth, and 5.5.0 served it without asking a client certificate. Such a configuration is now refused at load. Set client_auth = "required" beside client_ca_file to keep mutual TLS (GHSA-52mf-qg39-4q4j).

ferroehr healthcheck speaks TLS to a TLS listener over the loopback and accepts exactly the leaf certificate in certificate_file.

Note

min_version defaults to 1.3 only, as RFC 9325 §3.1.1 has it supported. Setting "1.2" enables 1.2 alongside 1.3, with the four forward-secret suites RFC 9325 §4.2 recommends; pick it only for a client that cannot do 1.3, such as an older integration engine or a pinned Java runtime.

Note

An EC certificate must name its curve. RFC 5480 §2.1.1 forbids explicit curve parameters, TLS clients refuse such a certificate, and the server refuses the pair at startup. LibreSSL (the openssl that ships with macOS) writes explicit parameters from genpkey and req -newkey ec unless you add -pkeyopt ec_param_enc:named_curve; openssl ecparam -name prime256v1 -genkey writes a named curve.

[rest]

The openEHR REST surface the listener serves.

[rest]
base_path = "/ferroehr/rest/openehr/v1"
swagger_ui = "private"
cors_permissive = false
system_id = "ferroehr.local"
# body_limit_bytes = 16777216
KeyTypeDefaultDescription
base_pathstring/ferroehr/rest/openehr/v1ITS-REST base path all API routes hang off. Shortening it is supported; see below. The served OpenAPI document describes whatever paths this setting produces, never the defaults.
swagger_uienum{off,admin_only,private,public}privateWho 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_permissiveboolfalsePermissive (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_idstringferroehr.localThis deployment’s own openEHR system identifier; see below. Set a stable, deployment-unique name in production (FERROEHR__REST__SYSTEM_ID).
body_limit_bytesintunsetThe largest body the ordinary clinical surface accepts, below server.body_limit_bytes, which the bulk routes keep. Unset, every route takes the listener’s limit. A value at or above the listener’s is refused at startup. The largest example composition in the vendored corpus is 526 KB; raise it if your compositions embed large DV_MULTIMEDIA data.

base_path: shortening the REST base path

ITS-REST leaves the API base to the deployment and fixes only the version segment at its end, so you may shorten base_path. The shape is checked at boot, and a value that breaks a rule stops the server with an error naming the key and every rule it broke:

  • The first segment is ferroehr. /ferroehrx/v1 and /x/ferroehr/v1 are 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 characters A-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_pathREST rootStatus document
/ferroehr/rest/openehr/v1 (default)/ferroehr/rest/ferroehr/rest/status
/ferroehr/openehr/v1/ferroehr/ferroehr/status
/ferroehr/v1/ferroehr/ferroehr/status
/ferroehr/cdr/v1/ferroehr/cdr/ferroehr/cdr/status

The Swagger UI ({rest root}/swagger-ui), the OpenAPI documents ({rest root}/api-docs/…) and the SMART discovery document ({rest root}/.well-known/smart-configuration) move with it. The health family is served under server.base_path, which is empty by default, so it stays at the process root: /health, /health/liveness, /health/readiness.

Set it from the environment with:

FERROEHR__REST__BASE_PATH=/ferroehr/v1

Note

The ferroehr healthcheck subcommand, which the published container images run as their Docker health check, asks GET /health/readiness on the server.listen port over the loopback, so rest.base_path does not move it.

Behind a path-prefixed reverse proxy

A proxy that mounts the CDR under a prefix stacks that prefix on top of base_path. A proxy serving the server at /cdr with the default base path gives clients URLs like:

https://cdr.example.org/cdr/ferroehr/rest/openehr/v1/definition/template/adl1.4

Two ways to shorten that. Strip the prefix at the proxy, so the server sees the path it serves (nginx proxy_pass http://cdr:8080/; with the trailing slash, Caddy handle_path, Traefik StripPrefix). Or shorten the base path:

FERROEHR__REST__BASE_PATH=/ferroehr/v1

which gives https://cdr.example.org/cdr/ferroehr/v1/definition/template/adl1.4.

Whichever you choose, tell every client. The server builds its Location headers, its served OpenAPI paths and its SMART discovery document from its own rest.base_path, and it cannot see a prefix the proxy adds. The FerroEHR Viewer has its own mirror key, cdr.base_path, which must match.

system_id: the data-authoring identity

system_id is the identifier this CDR stamps into the data it authors. It appears on the wire in three places:

  • EHR.system_id, recorded when an EHR is created. The openEHR RM (EHR Information Model, EHR Identifier Allocation) says the EHR.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_id on every commit for which the client did not supply one through the openehr-audit-details header. The openEHR REST API requires that “when system_id is 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_id segment of every version identifier the server mints and an illegal value would produce ids the server’s own reader rejects. A DNS-style name like cdr.hospital.example is valid; an empty value, or one containing the :: field separator, is not.
  • Choose it before going live and keep it stable. The value is stored with each EHR and each version; changing it later affects only newly authored data: existing EHR ids, audit rows and version identifiers are never rewritten, and previously issued OBJECT_VERSION_IDs stay valid.
  • Make it unique per system, so data exchanged between openEHR systems keeps unambiguous provenance. A node of a federation must hold a system_id no other member uses (Federation Tier N42a); the default ferroehr.local is the same on every node that leaves it unset. See FerroEHR as a federation node.
  • system_id is not [rest.identity]. system_id says which system authored the data; [rest.identity] is the display identity of the OPTIONS System-Options manifest. Rebranding changes the manifest and nothing in stored data; changing system_id changes what new data says about its origin and leaves the manifest alone.

[rest.identity]

The System-Options manifest identity (OPTIONS on the API base path, e.g. OPTIONS /ferroehr/rest/openehr/v1, the System API’s one location). This is the deployment’s display identity only; the identifier stamped into authored data is system_id.

KeyTypeDefaultDescription
solutionstringFerroEHRProduct name.
solution_versionstringthe build’s versionProduct version.
vendorstringCadasto B.V.Providing organisation: by default the manufacturer of the release, the name ferroehr --version and GET /management/info give.
restapi_specs_versionstringthe ITS-REST release this build implements (1.1.0)The openEHR REST API edition advertised.
conformance_profilestringthe profile the build’s recorded conformance verdict earnedAdvertised 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
KeyTypeDefaultDescription
urlsecret URLpostgres://ferroehr:ferroehr@localhost:5432/ferroehrConnection 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_filepathunsetRead 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_urlsecret URLunsetDSN that PREPARES the schema, used for that one boot step and then closed. Preparation spans every schema of a database at once, which no domain-scoped runtime credential can do, so a deployment separating the runtime roles names the credential that can here. It prepares every domain whose DSN reaches the SAME DATABASE — which is the whole of the separated-credential posture, where each domain has a login role of its own on one database; a domain whose DSN reaches a different database is prepared on that database’s storage.migrate.<domain>.url (on the domain’s own DSN when that is unset). Which it is, is read from the server (pg_control_system() plus current_database()), never from the DSN text. Unset, it falls back to url. See Operations → Which credential prepares the schema.
migrate_url_filepathunsetRead migrate_url from a file instead, for a mounted secret. At most one of the pair.
migrateenum{apply,verify}applyWhether 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_connectionsint20Pool ceiling. Write-heavy deployments benefit from raising it.
min_connectionsint2Idle connections kept open, avoiding cold-reopen churn under variable load.
acquire_timeout_secsint30Seconds to wait for a free connection before failing.
statement_timeout_msint60000statement_timeout applied to every pooled connection; 0 leaves the server default.

statement_timeout_ms is the backstop for every statement. An AQL query (ad hoc or stored) and the composition-count statistics stop sooner when their caller goes away: a client that disconnects, or the AQL engine’s own query.timeout_ms, closes the statement’s connection, its pool slot frees at once, and every pooled session sets PostgreSQL’s client_connection_check_interval to one second, so the backend stops the statement within about a second. Other statements run to their end or to this timeout, so keep it above query.timeout_ms: the AQL engine’s typed refusal then fires first and this only catches what the engine does not govern.

[storage]

One DSN per storage domain. Every domain always lives in its own schema (clinical, party, linkage, audit) and every pool carries only its own on its search_path, so no statement can reach another domain’s relations without naming a schema it is not granted. Setting a url here adds the CREDENTIAL separation, and — if the DSN names another host — lets the domain live in a database or cluster of its own.

[storage.party]
url_file = "/run/secrets/db-party-dsn"

[storage.linkage]
url_file = "/run/secrets/db-linkage-dsn"
KeyTypeDefaultDescription
storage.clinical.urlsecret URLunsetDSN the clinical domain connects on (role ferroehr_clinical). Unset, it uses db.url.
storage.party.urlsecret URLunsetDSN the party (demographic) domain connects on (role ferroehr_party). Unset, it uses db.url.
storage.linkage.urlsecret URLunsetDSN 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.urlsecret URLunsetDSN the audit repository is written through. Unset, it uses db.url.
storage.<domain>.url_filepathunsetRead 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 under production, where there are no grants to separate anything with.

Each database is prepared on its own migration credential. [db] migrate_url names one database; a domain whose DSN reaches another database is prepared there, and verified there under migrate = "verify", on the credential named in [storage.migrate.<domain>]:

[storage.linkage]
url_file = "/run/secrets/db-linkage-dsn"           # postgres://ferroehr_linkage:…@linkage-db/…
[storage.migrate.linkage]
url_file = "/run/secrets/db-linkage-migrate-dsn"   # postgres://ferroehr_migrator:…@linkage-db/…
KeyTypeDefaultDescription
storage.migrate.<domain>.urlsecret URLunsetDSN that prepares the database the domain lives in, when that is not the database db.migrate_url reaches. Used for that one step and closed again. Unset, such a database is prepared on the domain’s own url, which a DML-only runtime role cannot do: it is refused on the first bookkeeping table it cannot read.
storage.migrate.<domain>.url_filepathunsetRead it from a file instead, for a mounted secret. At most one of the pair.

The server refuses to start when a domain’s url and its storage.migrate URL reach different databases. A database that holds the linkage domain without the party domain also receives the party migration set, its schema and grants and never a row, because the linkage set revokes a function the party set creates.

[telemetry]

The console, the request log and the OpenTelemetry export: the section every FerroHEALTH product shares (ferrohealth-telemetry). With no [telemetry.otlp] table nothing is exported.

[telemetry]
format = "auto"
filter = "info,hyper=warn,tower=warn,h2=warn"
logged_query_parameters = []
# environment = "production"

# [telemetry.otlp]
# endpoint = "http://collector:4317"
# traces = true
# metrics = true
# sample_ratio = 1.0
KeyTypeDefaultDescription
formatenum{auto,json,pretty}autoStdout rendering. auto picks json when stdout is not a TTY and the human format when it is. NO_COLOR turns colour off.
filterstringinfo,hyper=warn,tower=warn,h2=warnConsole filter directives, refused at boot when they do not parse; also the value /management/loggers resets to. RUST_LOG is a lower-priority fallback. The filter decides what the console writes and never thins a trace.
logged_query_parameterslist of strings[]The query parameters whose values the request log may carry, each cut to 64 characters. Empty, the request log carries no query value.
environmentstringunsetThe deployment.environment.name resource attribute of every span and metric.
otlp.endpointsecret URL(required in the table)OTLP/gRPC collector endpoint; https:// speaks TLS. Credentials in it are masked in every rendering.
otlp.tracesbooltrueExport FerroEHR’s spans.
otlp.metricsbooltruePush the metrics over OTLP beside the Prometheus pull surface. One meter provider feeds both.
otlp.sample_ratiofloat1.0The share of traces sampled (0.1 is a common production start). A client’s traceparent is never read: every request starts a trace of the server’s own.
otlp.trust_roots_filepathunsetPEM trust roots the collector’s certificate may chain to, beside the platform’s.
otlp.client_identity_filepathunsetPEM client certificate chain and key, for mutual TLS with the collector.

The service.name of every span and metric is ferroehr, and service.version is the release and its commit.

The request log writes one line per request: the method, the matched route template, the status, the latency and the server’s request id, never a body, the raw path or a header value. It records the http.server.request.duration histogram, scraped as http_server_request_duration_seconds with the http_request_method, http_route, http_response_status_code and url_scheme labels.

Tip

The ASCII boot banner follows the rendering that is actually installed, not the configured word: it prints for pretty, and for auto only when stdout is a terminal. Under json, and under auto off a terminal (a container, a pipe into a log collector) stdout is parseable JSON from the first byte.

Note

Instrument names carry no Prometheus suffix; the exporter derives one. A counter named auth_failures is scraped as auth_failures_total, and units add _seconds/_bytes. Over OTLP the unsuffixed name is what a collector receives.

The scrape endpoint itself is not opened here; it is management.endpoints.prometheus, which is off until you name a level.

[usage_report]

The daily installation report to FerroPULSE, the collector Cadasto B.V. runs. It is on by default, and every boot logs one line saying whether it is on. What a report carries, when it is sent, the network it needs and the privacy notice are on the Usage report page.

[usage_report]
enabled = true
endpoint = "https://report.ferropulse.eu/v1/report"
deployment = "unknown"
KeyTypeDefaultDescription
enabledbooltrueSend the report. false sends nothing and writes nothing to the database for it. In a CI run (GITHUB_ACTIONS or CI set to true or 1) the report is off whatever this says. Environment: FERROEHR__USAGE_REPORT__ENABLED.
endpointURLhttps://report.ferropulse.eu/v1/reportWhere reports go. Must be an absolute https URL without credentials; plain http is accepted for a loopback host only. Anything else is refused when the configuration is read. Environment: FERROEHR__USAGE_REPORT__ENDPOINT.
deploymentenum{helm,compose,binary,unknown}unknownHow the instance was deployed, as the report states it. The Helm chart sets it to helm. Environment: FERROEHR__USAGE_REPORT__DEPLOYMENT.
state_dirpathunsetRefused. The section is the one every FerroHEALTH product shares, where this key names the directory of the instance id; FerroEHR keeps the instance id and the counts in the clinical database, so every replica reports as one installation.

slow_aql_ms is no longer a key: the report carries no AQL series. A configuration that still sets it is refused at boot; remove the line.

Under the Helm chart, set the switch with usageReport.enabled. The chart writes enabled and deployment = "helm" into the rendered file itself and refuses to render if either is also set under config.usage_report; endpoint passes through config.usage_report unchanged.

Authentication & access

Who may call the server, what they may do once identified, and the surfaces that are gated rather than always-on: [auth], [authz], [admin], [smart], [management], and [signing]. Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.

[auth]

Authentication: bearer tokens of one or more trusted issuers, Basic credentials, or both. [auth] is the section every FerroHEALTH product shares, checked by the ferrohealth-smart crate. FerroEHR is a resource server: it checks tokens and never issues them.

[auth]
enabled = true
issuer = "https://keycloak.example.com/realms/ferroehr"
audience = ["ferroehr"]

[[auth.basic.users]]
user = "clinician"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…$…"   # never a plaintext password
scopes = ["user/composition-*.cruds", "user/template-*.cruds", "user/aql-*.cruds"]
KeyTypeDefaultDescription
enabledbooltrueMaster switch. false = all requests pass unauthenticated (development only). With true and no mechanism configured the server refuses to start; see below.
issuerstring, or [[auth.issuer]] tablesunsetThe issuer whose tokens are accepted. Its metadata is read at boot, at the RFC 8414 §3 location first and the OpenID Connect Discovery 1.0 §4 location after a 404, and with it the key set at its jwks_uri, so an authorization server that cannot be reached refuses the start. An https URL with no query and no fragment and no user name or password; plain http is accepted on a loopback host only. Several issuers are [[auth.issuer]] tables, each carrying the keys from issuer to jwks_file.
audiencelist of stringrequired with an issuerAccepted aud values; a token naming none of them is refused (RFC 9068 §4, RFC 8725 §3.9).
algorithmslist of string["RS256","RS384","PS256","PS384","ES256","ES384"]Accepted signature algorithms. Asymmetric only: an HS* algorithm is a boot error, and none is no algorithm at all (RFC 8725 §3.1, §3.2).
token_profilerfc9068 or jwtrfc9068rfc9068 requires typ at+jwt and the client_id, iat and jti claims (RFC 9068 §2.2, §4). jwt admits a plain JWT bearer token (typ absent or JWT) for an issuer that mints no RFC 9068 tokens, and reads a scp array when scope is absent. A token typed as another kind of JWT is refused under either.
modejwt or introspectionjwtHow a bearer token is checked. jwt checks it as a JWT against the issuer’s key set. introspection posts an opaque token to the issuer’s RFC 7662 endpoint (see Opaque tokens); it needs a flat issuer, and an [auth.introspection] table beside jwt is a boot error.
clock_skew_secondsint60Leeway on exp, nbf and iat. At most 300.
jwks_max_age_secondsint600A key set older than this is read again.
jwks_refresh_interval_secondsint60A token naming an unknown kid reads the key set again at most once per interval.
jwks_filepath""A JWK Set of public keys handed over out of band, read instead of the issuer’s metadata. A symmetric key (kty: "oct") or a private-key member (d, p, q, dp, dq, qi, oth, k) is a boot error. An issuer read from a file has no metadata, so it serves the SMART discovery document only with [smart] issuer_metadata_file set.
timeout_msint10000One call to the authorization server.
ca_filepathunsetA PEM bundle of certificate authorities trusted in addition to the platform roots, on every platform, for an issuer behind a private PKI: the issuer’s metadata and key set are read trusting it, and under mode = "introspection" the metadata, introspection and token calls trust it beside the grant’s own ca_file. A file that cannot be read, holds no certificate, or does not parse refuses the start.
realmstring"" ⇒ ferroehrThe realm every WWW-Authenticate challenge names.
role_claimslist of string["roles","groups","entitlements"]Token claim paths the caller’s roles are read from, in order; dotted paths walk nested claims, and a role is matched without regard to case. A path that is empty, has an empty step or carries whitespace is a boot error. scope is not a role source; see Security. [authz.rbac] role_claims, where this key lived before, is refused at boot.
scopeless_subjectslist of string[]The exact sub values whose token, when it carries no resource scope of either the SMART on openEHR or the SMART on FHIR grammar, meets every SMART on openEHR requirement. A token that carries resource scopes gets only its scopes, and the allowlist reaches neither the ADMIN API nor the DEMOGRAPHIC API, which need the product scope.
capabilitieslist of string[]SMART capabilities the discovery document adds: one SMART App Launch or the SMART on openEHR draft lists, or a full URI.
scopes_supportedlist of string[]The scopes the discovery document lists. Empty, it lists the scopes the route table honours.
product_scopestring"" ⇒ __ferroehr.platformFerroEHR’s own scope. It admits the ADMIN API and, beside the bearer clients in [smart] demographic_clients, the DEMOGRAPHIC API, the two surfaces no SMART on openEHR resource family covers. A full URI or a string starting with __ (SMART App Launch 2.2.0, Scopes and Launch Context, §Extensibility).

Warning

auth.enabled = true with 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: a 401 challenge must name a scheme applicable to the target resource. Configure an issuer, configure [[auth.basic.users]], or set auth.enabled = false for a development server.

The key-set fetches verify the identity provider’s certificate against the platform trust store and follow no redirects. An identity provider whose certificate comes from a private CA is trusted once that CA is in the image’s system store or in a directory or file named by SSL_CERT_DIR or SSL_CERT_FILE. A key set that cannot be read when a token needs it answers 503, never 401: the server cannot decide, and a 401 would tell a caller with a valid token that its credential was refused.

A validated token must also carry a non-blank sub claim: the authenticated subject is stamped into the audit trail, so a token without one is refused with 401 rather than recorded under a placeholder identity. A bearer token outside the RFC 6750 §2.1 b64token alphabet is refused the same way, with 401 and error="invalid_token".

Opaque tokens by introspection

An authorization server that issues opaque access tokens is served with mode = "introspection". Each bearer token is posted to the issuer’s introspection endpoint (RFC 7662 §2.1), and the active response stands in for the JWT claims: sub, scope, the roles of role_claims, aud against audience and iss against the issuer. An inactive token is a 401. An endpoint that cannot answer is a 503, because the server cannot decide. An active response is reused until its exp, at most cache_max_age_seconds.

KeyTypeDefaultDescription
endpointURLunsetThe introspection endpoint; unset reads the introspection_endpoint of the issuer’s metadata. https, or http on a loopback host.
cache_max_age_secondsint60The longest an active response is used; 0 caches nothing.
cache_capacityint10000The most responses held at once.
clienttableunsetThe client authentication of the call (RFC 7662 §2.1), with the keys of an outbound grant: client_id and client_secret, private_key_jwt or mutual TLS (see the grant keys).
bearertableunsetA client credentials grant whose token authorizes the call instead (SMART App Launch 2.2.0, Token Introspection). Exactly one of client and bearer.
[auth]
issuer = "https://idp.example.org/realms/ferroehr"
audience = ["ferroehr"]
mode = "introspection"

[auth.introspection.client]
client_id = "ferroehr-rs"
client_secret_file = "/run/secrets/introspection-client"

The Basic-auth user store

[[auth.basic.users]] is an array of tables and therefore file-only: the environment grammar cannot spell an array index.

KeyTypeDefaultDescription
userstringrequiredThe user-id. Empty, or with a colon or a control character, is a boot error (RFC 7617 §2).
password_hashsecretrequiredArgon2id PHC hash ($argon2id$v=19$…), never a plaintext password. Boot-validated against the OWASP floor; see below.
password_hash_filepathunsetRead the hash from a file instead, for a mounted secret. A hash is an offline cracking target, so prefer this wherever the configuration file itself is not treated as sensitive. Exactly one of the pair is required.
scopeslist of string[]The SMART on openEHR scopes the user holds, checked on every route exactly like a token’s scope claim. A user with none is refused everywhere a requirement applies.
roleslist of string[]The user’s RBAC roles, upper-cased on authentication. A user with none holds no role and is refused wherever [authz.rbac] asks for one. The former [authz.rbac.basic_roles] table is refused at boot.

[auth.basic] itself takes one key beside the users:

KeyTypeDefaultDescription
cache_ttl_secondsint300How long a credential that verified is kept, so a repeat request skips the Argon2id derivation; 0 turns the cache off and checks every request. The entry is named by a keyed hash of user-id:password under a key drawn at boot, never the password, and only a credential that verified is kept, so a wrong password always pays the derivation. The cache holds at most 4096 credentials and empties at a restart. The TTL bounds how long a removed user keeps working, which matters only until the restart that applies the new user list. Argon2id is slow by design, so with the cache off its cost bounds how many Basic requests a core can answer.

Warning

Every password_hash must meet the OWASP Argon2id floor: m>=19456 (19 MiB), t>=2, p>=1, algorithm argon2id. Anything weaker (or a non-argon2id PHC string, or an unparsable one) is a boot error naming the user. This is checked at startup because the verifier takes its cost parameters from the stored hash, so a deliberately cheap hash would otherwise verify happily and silently weaken every password in the store. The floor is the OWASP Password Storage Cheat Sheet §Argon2id minimum.

An unknown user-id is checked against a decoy hash of the same cost, so the time a refusal takes does not say whether the user exists.

[authz]

Role-based (RBAC) and attribute-based (ABAC) authorization, and the patient-data assurance gate. The full evaluation order and design rationale are in Security.

[authz.assurance]: the assurance level patient data requires

Off by default. What it refuses and the 401 challenge it sends are described in Security.

KeyTypeDefaultDescription
claimstringacrThe token claim carrying the authentication context class; a dotted path reaches into a JSON object.
levelstable of string → low | substantial | highemptyMaps each value your identity provider puts in claim to an eIDAS level of assurance. A value the table does not name carries no level. File-only: the keys are usually URIs.
minimumlow | substantial | highunsetThe level a patient-data request must carry. Unset refuses nothing.
[authz.assurance]
minimum = "substantial"

[authz.assurance.levels]
"http://eidas.europa.eu/LoA/substantial" = "substantial"
"http://eidas.europa.eu/LoA/high" = "high"

Boot refuses a minimum with an empty levels table or with no value mapped at or above it (every patient-data request would be refused), a blank claim beside a declared table, and a levels value that is blank or carries whitespace, a quote or a backslash, because the refusal names the accepted values in one quoted, space-separated acr_values parameter (RFC 9470 §3).

[authz.professional]: the natural person behind a token

Absent by default, and tokens are then not judged on it. Present, a patient-data request whose token names no natural person is refused with 403, unless it is a client token acting for a named professional.

KeyTypeDefaultDescription
claimstringsubThe claim identifying the natural person; a dotted path reaches into a JSON object. A token lacking it is a client token.
client_tokenssub_is_client | claim_absentsub_is_clientHow else a client-credentials token is recognised. sub_is_client also treats a token whose sub equals its client_id or azp as a client token (RFC 9068 §2.2); claim_absent relies on the missing claim alone.
acting_for_claimstringunsetThe claim a client token carries the professional’s identifier in, such as act.sub. Unset refuses every client token on patient data.

The access record then carries the acting mode and the professional; see Audit trail. Both tables are reachable from the environment too (FERROEHR__AUTHZ__ASSURANCE__MINIMUM, FERROEHR__AUTHZ__PROFESSIONAL__ACTING_FOR_CLAIM), except the levels map.

[authz.rbac]

KeyTypeDefaultDescription
enabledbooltrueThe coarse role gate (active when authentication is enabled). Off, every authenticated caller may use every enabled surface, reported as the rbac_off deployment gap. ehr_access_default and admin_role apply either way.
admin_rolestringADMINRole required for admin-class operations. A blank value is a boot error.
readonly_rolestringREADONLYRole marking a principal read-only: refused on every write operation (create/update/delete/upload), even alongside granting roles. Reads and AQL queries are still allowed.
emergency_access_rolestringunsetRole that reads data under an EHDS Art. 8 access restriction when the caller also declares one of [audit] emergency_purpose_codes (EHDS Art. 11(5)); the access record then carries the emergency mark. Unset, nobody may override a restriction. See restriction of access.
identifier_reader_rolestringunsetRole that may read a party’s protected national identifiers through GET /ferroehr/rest/extensions/v1/admin/party/identifiers, beside the admin role. Every read is an access record naming the reader, the party and the scheme. Unset, nobody may. See reading a party’s protected identifiers.
subject_audit_rolestringunsetRole that may read the access log for one subject at a time: GET /fhir/r4/AuditEvent with the patient parameter required, refused without it. The grant a patient portal holds to serve a person’s right to know who accessed their record (GDPR Art. 15, EHDS Art. 9), instead of an admin credential over every patient’s log. Unset, the log stays admin-only. See Audit trail.
ehr_access_defaultenum{open,restricted}openWhat an EHR carrying no ACCESS_CONTROL_SETTINGS admits. restricted is object-level default-deny: only admin_role reaches a setting-less EHR, so an operator can still author the settings that open it. It is enforced whether enabled is on or off. With [auth] enabled = false nothing could enforce restricted, so that combination is a boot error. See Security.

Note

The management surface is not configured under [authz.rbac]. [management.endpoints] owns it, one level per endpoint, with no global default beside it: an endpoint you do not name is off and is not mounted. Only the admin_only level consults authz.rbac.admin_role.

[authz.abac]

KeyTypeDefaultDescription
enabledboolfalseMaster ABAC switch.
engineenum{cedar,remote}cedarEmbedded Cedar, or a remote decision point.
organization_claimstringorganization_idJWT claim carrying the caller’s organization.
patient_claimstringpatient_idJWT claim carrying the patient id. Set, it scopes every request to the patient it names (see below). Empty turns the patient scope off.
patient_namespacestringunsetThe subject namespace (EHR_STATUS.subject.external_ref.namespace) the patient ids in patient_claim are issued in. Set, a claim names the EHR whose subject is that id under this namespace. Unset, a claim names the EHR holding that id only when no other namespace holds the same id.
check_directoryboolfalseSubmit DIRECTORY (FOLDER) operations to the decision point. Engine-independent, so it works under Cedar as well as a remote PDP.

The patient scope. With ABAC on and patient_claim set, a token without that claim is refused with 403. With the claim:

  • A route that names an EHR (/ehr/{ehr_id}/…) answers 403 unless the claim names that EHR’s subject. An EHR that records no subject is in no patient’s scope and is refused.
  • Without patient_namespace, an id held under two or more namespaces names none of those subjects, so it reaches none of their EHRs. Set patient_namespace to read the claim in one namespace.
  • An AQL query returns rows only from EHRs the claim names, whatever the query selects, including SELECT e/ehr_id/value FROM EHR e.

After a query runs, the decision point receives the subject ids of every EHR the query touched as its patient attribute, so a patient-keyed policy applies to queries whether or not patient_claim is set. Without patient_namespace, a query that touches a subject id held under several namespaces is refused with 403, because a bare id would name both subjects.

  • [authz.abac.cedar]: policy_dir (path, required when engine = "cedar" and ABAC is on) and reload_secs (int, unset, an optional hot-reload interval).
  • [authz.abac.remote]: server (secret URL, required when engine = "remote", and it must end with /, because the policy name is appended; credentials embedded in it are redacted from every rendering), connect_timeout_ms (int, 2000), request_timeout_ms (int, 5000).
  • [authz.abac.remote.oauth]: calls the policy service as an OAuth 2.0 client instead of with the Basic credentials of server: FerroEHR obtains a token with the client credentials grant and sends it as a bearer token. The keys are those of [audit.fhir_feed.oauth] (token_endpoint or issuer, client_id, client_secret or client_secret_file, client_auth, scope, audience, resource). When the policy service refuses the token with 401, a new one is fetched and the decision asked once more; a token that cannot be obtained, or that is refused again, is no decision, and the request fails closed with a 500, never a 403. server with credentials beside this table stops startup.
  • [authz.abac.policy.<kind>]: one entry per resource kind, with kind ∈ ehr, ehr_status, composition, contribution, query, directory. Keys: name (string, the policy to evaluate) and parameters (list of enum{organization,patient,template}). A key that is not one of those six kinds is a boot error, and template is rejected on ehr and ehr_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, plus directory when check_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 blanket 403s on live traffic. The Cedar engine reads its policies from authz.abac.cedar.policy_dir and needs no entries here.

[admin]

KeyTypeDefaultDescription
enabledboolfalseMount the ADMIN API (physical, irreversible delete). Off ⇒ every admin route answers 405 Method Not Allowed with an empty Allow header, never 403, and never touches the backend.

Physical deletion is irreversible, so the group stays off by default. With it on, /admin also joins the group list the OPTIONS System-Options manifest advertises, so the manifest never names a group that answers 404.

[smart]

The SMART on openEHR scopes are enforced on every route whenever [auth] is on: each operation carries a requirement from FerroEHR’s route table, which the ferrohealth-smart crate checks against the token’s scopes or a Basic user’s scopes. There is no switch for it. This section decides what beside the route table: whether the discovery document is served, and whose grants count for what. See SMART App Launch.

KeyTypeDefaultDescription
enabledboolfalseServe /.well-known/smart-configuration. It is built from the first [auth] issuer’s metadata, so it needs an issuer whose metadata names a token_endpoint (SMART App Launch 2.2.0, Conformance, §Metadata); an issuer read from a jwks_file refuses the start with enabled = true unless issuer_metadata_file supplies that metadata.
issuer_metadata_filepathunsetA JSON file holding the RFC 8414 §2 metadata of the first [auth] issuer when its key set is a jwks_file, read at boot. It must name that issuer, repeat no member name, and every endpoint it names must be https (a loopback host excepted); the discovery document takes its token_endpoint and the other endpoints from it. Set for an issuer whose metadata is read from the issuer, it is a boot error.
platform_base_urlstringunset ⇒ the REST rootThe path the discovery document hangs off, e.g. /gateway/v1.
public_base_urlstringrequired when enabledThe server’s externally reachable origin (e.g. https://cdr.example.com), from which the discovery document’s absolute services.*.baseUrl values are built.
launch_base64_jsonboolfalseAdvertise the launch-base64-json capability. Experimental, and advisory: the base64 JSON launch object is consumed by the application, not the CDR.
backend_clientslist of string[]The client_ids a system/aql-* grant counts for: such a grant “would grant access to all registered and ad-hoc AQL queries system-wide” (master08 §Resource Scopes).
patient_issuerslist of string[]The issuers whose patient/ grants count, each confined to the EHR the token’s ehrId claim names (master07 §Context Selection). Empty, a patient/ grant grants nothing.
demographic_clientslist of string[]The bearer client_ids that reach the DEMOGRAPHIC API, which no SMART on openEHR scope covers. A Basic user-id never matches an entry. A caller holding [auth] product_scope reaches it too.

[smart.episode]: enabled (bool, false) advertises episode context and accepts the launch/episode scope and episodeId claim, but applies no episode-scoped filtering.

No smart URL may carry a user name or password, whether or not the document is served: the discovery document publishes these URLs to every application, so a credential in one would be served to anyone who asks.

[management]

The ops-introspection surface: build info, Prometheus, metric views, the effective configuration, runtime log control, and the on-demand profiler. Off by default, and every endpoint off individually.

The health probes are not configured here: /health, /health/liveness and /health/readiness are always served on the main API port without authentication, whatever this section says (see Operations → Health probes).

[management]
enabled = false
base_path = "/management"

[management.endpoints]
info = "off"
metrics = "off"
prometheus = "off"
env = "off"
loggers = "off"
flamegraph = "off"

[management.profiling]
max_seconds = 30
max_frequency = 999
KeyTypeDefaultDescription
enabledboolfalseMount the management router.
base_pathstring/managementBase path for the management endpoints.
portintunset ⇒ share the main listenerServe management on its own listener and port. Must differ from the server.listen port, and it is plain HTTP, an internal surface.
flame_filepathunsetSpan-timing capture: write the folded stacks of every span to this file, and render offline with inferno-flamegraph. Unset, the layer is not installed. For diagnostic sessions: the file grows with span traffic.

[management.endpoints]: info, metrics, prometheus, env, loggers and flamegraph, each enum{off,admin_only,private,public}, default off.

LevelMeaning
offNot mounted at all; the route answers 404.
admin_onlyRequires an authenticated principal carrying authz.rbac.admin_role (401 unauthenticated, 403 authenticated but not admin).
privateRequires any authenticated principal.
publicServed 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):

KeyTypeDefaultDescription
max_secondsint30Longest sample window one request may ask for. A request asking for more is refused with 400, never clamped.
max_frequencyint999Highest sampling frequency (Hz) a request may ask for. Same refusal semantics.

Warning

probes_enabled and endpoints.health do not exist. Configuration is strict, so a file or environment variable still setting either one fails at boot with an unknown-key error: delete the key; the probes are always on.

[licence]

The commercial licence token. Every build embeds the licensor’s non-commercial grant; a deployment that holds a commercial licence installs its token here. The server behaves identically under either grant and reports the one in force on GET /management/status (licence.use, licence.licensee, licence.not_after, licence.configured_token). See Licensing & legal.

KeyTypeDefaultDescription
filepathunsetThe licence token the licensor issued. A token that cannot be read or does not verify is reported as refused on /management/status; the embedded grant stays in force.

[signing]

VERSION signing. On by default in digest mode, with read-time verification of the server’s own signatures strict by default.

KeyTypeDefaultDescription
enabledbooltrueServer-side signing of committed versions.
modeenum{digest,pgp}digestA SHA-256 integrity digest, or an OpenPGP (RFC 4880) detached signature.
key_pathpathunsetArmored secret key; required for pgp (a boot error otherwise).
key_passphrase / key_passphrase_filesecret / pathunsetKey passphrase. At most one of the pair.
retired_key_pathslist of path[]Armored public keys retired from signing and kept for verification, so versions signed before a key rotation keep verifying.
verify_on_readenum{off,warn,strict}unset ⇒ strict when signing is enabledRead-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_read resolves to strict when signing is enabled. On every read the server recomputes the signature of a version it signed and, on a mismatch, returns a 500 integrity fault rather than silently serving a provably corrupt record. Set it explicitly to warn (log + meter version_signature_invalid_total, still serve) or off (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

pgp mode fails closed at boot if the key is missing or unusable: the server will not start. Verify the key and passphrase before switching modes.

Choose an Ed25519 signing key

The server refuses an ECDSA (P-256, P-384, P-521, secp256k1, Brainpool) or Ed448 signing key at boot: their private-key arithmetic runs on crypto-bigint 0.5, whose constant-time operations can branch on secret values (RUSTSEC-2026-0387), with no fixed release on that line. The boot error names the advisory. Move to Ed25519 with the steps below; the old certificate goes in retired_key_paths, where it keeps verifying, because verification uses only public values.

An RSA signing key is accepted, but it makes every commit perform an RSA private-key operation, the operation the Marvin timing sidechannel concerns (RUSTSEC-2023-0071 / CVE-2023-49092), for which the underlying rsa crate has no fixed release. An Ed25519 key keeps that code off the signing path entirely.

The server does not refuse an RSA key: a repository whose history is already RSA-signed needs that key to keep verifying, and signatures are immutable committed facts that cannot be re-issued. Instead it logs a warning at boot naming the advisory. To clear it:

  1. Generate a new signing key: gpg --quick-generate-key "…" ed25519 sign, and export the armored secret key to key_path.
  2. Export the public half of the old certificate and add it to retired_key_paths, so versions signed with it still verify (see above).
  3. Restart. New versions are signed with Ed25519; old ones keep verifying.

Rotation inside one certificate is cheaper still: add an Ed25519 signing subkey to the existing certificate, export the armored secret key again to key_path, and restart. The server signs with the newest subkey whose binding grants signing and that is neither expired nor revoked (the rule), with no retired_key_paths entry needed: the certificate keeps the previous subkey, so past signatures continue to verify. For a refused ECDSA or Ed448 key this works when the new Ed25519 subkey is the one chosen. The steps above, with a new certificate, work in every case.

On Kubernetes, digest mode needs nothing; it is the default. pgp mode needs the key as a file and its passphrase as a secret, both of which the chart mounts for you:

# values.yaml
config:
  signing:
    enabled: true
    mode: pgp
    key_path: /etc/ferroehr/signing-key.asc
  files:
    signing-key.asc: |
      -----BEGIN PGP PRIVATE KEY BLOCK-----
      …
secrets:
  signingKeyPassphrase: "…"

Every config.files key becomes /etc/ferroehr/<key>, mounted read-only from a chart Secret at mode 0440, and that volume holds the private key, so it is never world-readable inside the container. To go back to digest, set mode: digest and drop the key material; versions already signed keep their signatures and still verify.

Integrations

Query execution, the patient identity source and the three optional integrations: [query], [ehr_index], [events], [terminology], [multimedia]. Everything except the query knobs and the identity source is off by default, and a disabled integration contacts nothing and mounts no routes. Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.

[query]

AQL execution bounds.

[query]
plan_cache_capacity = 256
timeout_ms = 30000
max_result_rows = 10000
KeyTypeDefaultDescription
plan_cache_capacityint256Maximum 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_msint30000Per-query database execution budget; 0 disables it. Overrun is refused 408.
max_result_rowsint10000The largest page one query execution serves: the page of a query nothing else bounds, and the maximum an explicit LIMIT or fetch may ask for (a larger page is refused 400); 0 means unbounded.

timeout_ms is on by default, and deliberately tighter than db.statement_timeout_ms. Keeping this budget the tighter of the two means an overrun surfaces as the engine’s own typed 408 rather than a driver error. When it fires, or when the client disconnects or the HTTP request timeout gives up first, the query’s connection is closed instead of returned to the pool, so its pool slot frees at once and PostgreSQL stops the statement within about a second (the session’s client_connection_check_interval).

max_result_rows is the largest page one execution serves. Without it, SELECT c FROM COMPOSITION c with no fetch generates SQL with no LIMIT and materialises every matching row: one request, unbounded allocation. A query that nothing else bounds takes the ceiling as its page. An explicit AQL LIMIT or a fetch parameter is honoured as written up to the ceiling; a page larger than the ceiling is refused with 400 naming the ceiling. The refusal is deliberate: a silently shortened page would let a client that pages with its own fetch as the stride skip the rows between the shortened page and its next offset, and the RESULT_SET has nothing to say so. ITS-REST leaves both the fetch default and its maximum to the implementation. A bulk consumer pages with offset and a fetch at or below the ceiling, or the operator raises the ceiling deliberately.

[ehr_index]

Who holds the cross-reference from a patient to the EHR that records them (the EHR index). No openEHR specification governs where it lives: the RM’s PARTY_SELF section says the association “may be done elsewhere for security reasons”, and FerroEHR’s design puts it in an IHE patient identity source.

The IHE patient identity source is the default. FerroEHR asks a PIX Manager, as a PIXm Patient Identifier Cross-reference Consumer (IHE ITI TF-2 §3.83, $ihe-pix), which identifier the clinical domain holds for a patient that another domain’s identifier names. That identifier is the pseudonym EHR_STATUS.subject carries. FerroEHR sends only what ITI-83 specifies, so any conformant PIX Manager serves; FerroPIX is the one the compose quickstart and the Helm chart run.

The in-instance map, provider = "local", is the other supported posture, for production as much as for development, tests and the conformance suites: the subject-id and subject-namespace model. A client names a patient in EHR_STATUS.subject.external_ref by any namespace and identifier (benchmark / 1793131381), as OBJECT_REF.namespace and a local GENERIC_ID.scheme allow, and the map answers the openEHR REST API’s GET /ehr?subject_id=…&subject_namespace=… lookup over EHR_STATUS content, matching both exactly. An EHR created for a subject through the service’s create_ehr_for_subject enters the map with its creation, as a Primary association with no location, so a subject cohort finds it; an EHR created over POST /ehr or PUT /ehr/{ehr_id} enters the map only through add_ehr_subject. With neither provider configured, the server refuses to start and the refusal names both choices.

What the local posture means for pseudonymisation: with no [privacy] subject_namespaces declared, the server stores a subject reference as the client sends it, a real identifier included. Pseudonymisation then belongs to the client, or to the demographic service the reference points at (GDPR Art. 4(5)). There is no mixed mode: a deployment that declares pseudonym namespaces keeps the rule in force under local too, so every subject reference must name a declared namespace and carry a UUID, and no other namespace is accepted beside them. A production deployment runs the subject-id posture by accepting the open_subject_namespace gap by name (deployment_profile), which boot and GET /management/status then state.

provider alone switches the index, in the file or as FERROEHR__EHR_INDEX__PROVIDER. The keys of the provider not chosen are kept and not used, and boot names them, so a configuration written for the IHE source runs on the local map with FERROEHR__EHR_INDEX__PROVIDER=local and no other change; its declared pseudonym namespaces stay in force. The compose quickstart selects its local configuration with one variable (The local EHR index).

KeyTypeDefaultDescription
providerenum{ihe,local}iheWhich provider holds the cross-reference.
pixm_urlsecret URLrequired for iheThe PIX Manager’s FHIR base, [base] of [base]/Patient/$ihe-pix. Credentials embedded in it are masked in every rendering.
assigning_authorityURIrequired for iheThe clinical domain, such as urn:oid:2.999.7.1: the targetSystem of every ITI-83 query and the namespace the answered pseudonym is stored under. It must be one of privacy.subject_namespaces.
invocationenum{post,get}postpost sends a Parameters body, so a patient identifier never appears in a URL or an access log; get is the form ITI-83 prescribes, for a PIX Manager that accepts no POST.
request_timeout_msint10000How long one ITI-83 exchange may take, connection included.
[privacy]
subject_namespaces = ["urn:oid:2.999.7.1"]

[ehr_index]
provider = "ihe"
pixm_url = "https://pix.example.org/fhir"
assigning_authority = "urn:oid:2.999.7.1"

[ehr_index.oauth]
token_endpoint = "https://idp.example.org/realms/pix/protocol/openid-connect/token"
client_id = "ferroehr"
client_secret_file = "/run/secrets/pix-client"
scope = "system/Patient.rs"

[ehr_index.oauth] is the client credentials grant every ITI-83 request carries, with the keys of the terminology server’s grant; a 401 is answered once more under a new token. The client id is the one the identity source mints pseudonyms in the clinical domain for, and the scope is the one its PIX Manager takes from a Consumer. [ehr_index.tls] holds the client’s TLS material: trust_roots_file, a PEM bundle trusted beside the platform roots for a PIX Manager behind a private PKI, client_identity_file (or client_identity inline), the chain and key presented for mutual TLS, and min_version ("1.3", or "1.2").

What each operation asks:

OperationUnder iheUnder local
has_ehr_for_subject, create_ehr_for_subjectthe subject’s clinical pseudonym, asked of the source; a subject already in the clinical domain names itselfthe subject as given
the EHRs of a subjectthe one EHR whose EHR_STATUS names the subject’s pseudonymthe in-instance map
the subjects of an EHRthe pseudonym its EHR_STATUS carries; the source is never asked to resolve it backthe in-instance map
writing the index (add, update, remove)refused: the source holds the cross-referencethe in-instance map

No fallback. At start the server reads the source’s CapabilityStatement and refuses to start when it does not answer. A request the source then fails, refuses or times out on answers with an error that carries what the source said, and nothing is answered from the in-instance map. An answered pseudonym the [privacy] subject rule refuses, or more than one identifier in the clinical domain, is refused too.

FerroEHR does no identity work in either mode. It matches no patients, merges none and links no identifier domains; the identity source does. It asks only for the cross-reference ITI-83 answers. PDQm (ITI-78) is not used: its search answers with the demographic Patient, which FerroEHR would only discard, so asking for it breaks data minimisation (GDPR Art. 5(1)(c)), and its $match is patient matching.

[events]

Contribution-outbox eventing to an AMQP broker, plus its admin API. Off by default; the envelopes are PHI-free by design.

KeyTypeDefaultDescription
enabledboolfalseSpawn the outbox publisher. It also gates the per-commit outbox INSERT, so with it off the commit path writes no outbox rows at all.
urlsecret URLamqp://guest:guest@localhost:5672/%2fAMQP broker URL; credentials are redacted from every rendering.
url_filepathunsetRead 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”.
exchangestringferroehr.eventsTopic exchange for the PHI-free envelope stream.
tlsboolfalseUpgrade an amqp:// URL to amqps:// (an already-amqps:// URL is TLS regardless). Under deployment_profile = "production", an enabled outbox that still publishes over amqp:// opens the plaintext_broker gap.
batch_sizeint128Rows drained per poll.
poll_interval_msint1000Idle poll interval.
retention_daysint7Published-row retention window.
prune_interval_secsint3600Retention-prune cadence.
publish_max_retriesint3Per-row publish retries before backing off.
admin_apiboolfalseMount the /admin/event_subscription CRUD routes.

Note

Eventing needs the events build feature, which is on in the published binary and container images. A binary built with --no-default-features refuses at startup if events.enabled is set, rather than running with the publisher silently absent.

[terminology]

The terminology extension API and the one FHIR terminology server the CDR asks.

[terminology]: api_enabled (bool, false) mounts the terminology extension API.

[terminology.server]:

KeyTypeDefaultDescription
enabledboolfalseBuild the client. With it off, nothing is requested and validation stays on the in-process openEHR terminology bundle.
urlsecret URLrequired when enabledThe server’s FHIR R4B base URL, for FerroTERM http://ferroterm:8080/r4b. Empty while enabled is a boot error. Credentials embedded in it are redacted from every rendering.
fail_on_errorboolfalseWhat an unresolvable binding does to a commit: false accepts it (fail-open), true rejects it.
request_timeout_msint10000How long one request may take, connection included.

Every key is a scalar, so the whole section is reachable through the environment: FERROEHR__TERMINOLOGY__SERVER__ENABLED, FERROEHR__TERMINOLOGY__SERVER__URL, FERROEHR__TERMINOLOGY__SERVER__FAIL_ON_ERROR, FERROEHR__TERMINOLOGY__SERVER__REQUEST_TIMEOUT_MS.

[terminology.server]
enabled = true
url = "http://ferroterm:8080/r4b"
fail_on_error = false

The server answers every terminology the bundled openEHR terminology does not carry: the /terminology/* extension API, AQL TERMINOLOGY(…) resolution, the ADL 2 binding check and the composition-commit binding checks below. A response that is not a valid FHIR R4B Parameters or ValueSet resource (for example an $expand result missing the required ValueSet.status or expansion.timestamp) is treated as an upstream fault rather than partially read, so it takes the same path as an unreachable server and fail_on_error decides what the commit does.

Note

The terminology server needs the fhir build feature, which is on in the published binary and container images. A binary built with --no-default-features refuses at startup if terminology.server is enabled; the in-process openEHR terminology bundle remains available.

Authenticating to the terminology server

A server behind an authorization server takes the OAuth2 client-credentials grant under [terminology.server.oauth], with the same keys as [audit.fhir_feed.oauth] and [authz.abac.remote.oauth]. The access token is cached and re-requested shortly before it expires, and a request the server answers with 401 is sent once more under a new token.

KeyTypeDefaultDescription
token_endpointURLone of token_endpoint and issuerThe OAuth2 token endpoint. Credentials embedded in it are masked in every rendering.
issuerstringone of token_endpoint and issuerThe issuer whose metadata names the token endpoint (RFC 8414).
client_idstringrequiredThe registered client identifier. Empty is a boot error.
client_secret / client_secret_filesecret / pathone is requiredThe client secret, inline or read from a mounted file.
client_authenum{client_secret_basic,client_secret_post,private_key_jwt,tls_client_auth,self_signed_tls_client_auth}client_secret_basicHow the client authenticates at the token endpoint.
scopestringunsetThe scope request parameter.
audiencestringunsetThe audience request parameter some authorization servers read.
resourceURLunsetThe RFC 8707 resource request parameter.
private_key_filepathunsetThe PEM private key that signs the client assertion under private_key_jwt (RFC 7523).
kidstringthe RFC 7638 thumbprintThe kid the client assertion names.
assertion_algstringby key typeThe algorithm the client assertion is signed with.
assertion_audienceenum{token_endpoint,issuer}token_endpointThe aud of the client assertion; issuer needs issuer set.
client_certificate_file / client_key_filepathunsetThe client certificate chain and its key presented over mutual TLS (RFC 8705); set together.
ca_filepathunsetPEM trust roots added to the built-in ones, for a token endpoint behind a private certificate authority.
refresh_margin_msint30000How long before the token expires a new one is fetched.
timeout_msint10000How long one call to the authorization server may take.
[terminology.server.oauth]
token_endpoint = "https://idp.example.org/realms/ts/protocol/openid-connect/token"
client_id = "ferroehr-cdr"
client_secret_file = "/run/secrets/ts-client"
scope = "system/*.read"

A grant beside credentials in the server URL’s userinfo is a boot error: the server would receive two authorizations.

Archetype value-set bindings at commit

With [terminology.server] enabled, committing a COMPOSITION also resolves the archetype constraint bindings its template declares: where a template binds an ac code to an external terminology query, the coded value in the composition must be a member of the value set that query returns.

  • The code is in the value set → the commit proceeds.
  • The code is not in the value set → 422 naming the path, the code, and the bound query. That is a real constraint violation, not a service failure, so fail_on_error does not change it.
  • The value set could not be resolved (server down, error response, unknown value set) → fail_on_error decides: false (the default) accepts the commit and logs a warning; true rejects it with 422.

With [terminology.server] disabled (the default) no binding is resolved and no request is made, so commit behaviour is exactly as if this section did not exist.

Note

The composition’s terminology_id is sent verbatim as the FHIR system parameter, and no openEHR specification defines a mapping between terminology_id values (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.

KeyTypeDefaultDescription
enabledboolfalseExternalize large multimedia data.
threshold_bytesint262144 (256 KiB)Decoded size strictly above which data is offloaded; at or below it stays inline.
endpointsecret URLunset ⇒ default AWS endpoint resolutionS3-compatible endpoint. Must be an absolute http/https URL when set. Credentials embedded in it are redacted from every rendering.
bucketstringopenehr-multimediaTarget bucket for content-addressed blobs.
regionstringus-east-1AWS region; S3 requires one even for non-AWS endpoints.
access_key_idstringunsetS3 access key id. Unset, with no secret key either, runs the client anonymously.
secret_access_key / secret_access_key_filesecret / pathunsetS3 secret access key. At most one of the pair.
allow_httpboolfalseAllow 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 multimedia build feature (on in the published binary and container images); a slim build refuses at startup rather than silently keeping blobs inline.

Audit

The IHE ATNA audit trail: [audit] and its three sinks. Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.

[audit]

The IHE ATNA audit trail (see the Audit trail chapter for what a record contains and how to search it). On by default with only the local store active: every deployment gets a queryable audit trail with nothing leaving the node, and forwarding to an external Audit Record Repository is opt-in per sink.

[audit]
enabled = true
source_id = "ferroehr"
value_if_missing = "UNKNOWN"
suppress_login_events = true
fail_mode = "open"
resolve_subject = true
queue_capacity = 8192
purpose_header = "x-purpose-of-use"
permit_header = "x-data-permit"
purpose_codes = []
emergency_purpose_codes = []
KeyTypeDefaultDescription
enabledbooltrueMaster audit switch.
enterprise_site_idstringunsetThe AuditEnterpriseSiteID field.
source_idstringferroehrThe audit source id, also used for the destination participant.
value_if_missingstringUNKNOWNFill value for empty mandatory fields.
suppress_login_eventsbooltrueSkip successful-login records. Rejected accesses (401/403) are always recorded.
fail_modeenum{open,closed}openWhat 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_subjectbooltrueEnrich 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_capacityint8192Bounded 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_hoststringunset ⇒ the value_if_missing fillThis node’s advertised network address, reported as the destination NetworkAccessPointID.
purpose_headerstringx-purpose-of-useThe request header a caller declares its purpose of use in, recorded on every access record. NEN 7513 asks on whose authority a record was read and EHDS Art. 9 asks why; neither is derivable from the request, so the caller declares it. IHE carries the equivalent in a SAML attribute rather than a header, so the header is FerroEHR’s own.
permit_headerstringx-data-permitThe request header a secondary-use caller declares the data permit it acts under in (EHDS Art. 68), recorded on every access record as permit. The value is the permit’s identifier, 1 to 128 characters of letters, digits and . _ : / # -; any other value is recorded as absent. No specification names a header for it, so the header is FerroEHR’s own. See Secondary use.
purpose_codeslist of string[]The purpose codes this deployment accepts. Empty records whatever the caller declares. A non-empty list records a declared code only when it is on the list, so an unagreed string does not sit in the trail reading like an established purpose.
emergency_purpose_codeslist of string[]The purpose codes that mark an access as an emergency access in the vital interest of the patient (EHDS Art. 11(5)). Each must also be on purpose_codes, or the server refuses to start. Empty marks nothing. The mark is recorded on the access record and shown to the patient through the access log; it lifts no restriction.
legal_basisstringunsetThe legal basis this deployment processes under, recorded on every access record. A deployment-level fact: the controller establishes the GDPR Art. 6/9 condition once. Unset records nothing rather than a guess.

Note

The local store renders a FHIR R4 AuditEvent from each record when it is read, and the ATX:FHIR Feed sends one, so both need the fhir build feature, on in the published binary and container images. A binary built with --no-default-features refuses at startup if audit.store.enabled or audit.fhir_feed.enabled is 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

KeyTypeDefaultDescription
enabledbooltruePersist every record in the audit schema, served through the ITI-81 GET /fhir/r4/AuditEvent search.
retention_daysint0Days to keep records; 0 keeps them forever. Applied hourly by the retention reaper. A non-zero value below the retention floor of a jurisdiction the active [privacy.identifier_scan] rules name is a boot error naming both numbers; see Audit trail.
retention_yearsintunsetCalendar years to keep records, in place of retention_days. Compared exactly against the floors and ceilings, which are written in years, and reaped with calendar arithmetic. Setting it together with a non-zero retention_days, or to 0, is a boot error.
sgb_v_309_controllerboolfalseSet only if the deploying organisation is, or acts for, one of the controllers SGB V § 307 names for a German telematics-infrastructure application. It caps the horizon at the three-year period of SGB V § 309 Abs. 1, after which Abs. 3 requires deletion without delay, so a declared controller cannot keep records forever.
verify_interval_secondsint3600Seconds between two scheduled verifications of the store’s hash chain; 0 turns the schedule off. Each run verifies the records written since the newest link an earlier run found intact. A finding is logged at ERROR, counted in atna_audit_chain_findings and reported by the audit_chain indicator on GET /health/readiness. See Tamper evidence.
full_verify_interval_secondsint604800Seconds between two scheduled full passes over the hash chain, which also recompute the records before the checkpoint; 0 leaves the full pass to SELECT * FROM audit.verify_audit_chain() on demand. A run that found damage is always followed by a full pass.

The local store is the durability anchor of the whole subsystem: with it on, the FHIR feed drains from it, so a down repository loses nothing.

The floors and ceilings are written in calendar years. In every EU Member State the floor is three years (EHDS Art. 9(2)), and a longer national floor wins: five years in the Netherlands. Switzerland keeps its one-year floor. A retention_years value is compared against them exactly. A retention_days value is held to the most days the years can span for a floor (1096 for three years, 1830 for the Dutch five, 366 for one) and to the fewest for a ceiling (1095 for the German three). A declared § 309 controller in Germany therefore has no retention_days value that passes both the EHDS floor and the § 309 ceiling, and sets retention_years = 3:

[audit.store]
retention_years = 3
sgb_v_309_controller = true

[audit.categories]

The map every access record is classified through by EHDS category (Annex II 3.2(c); see the EHDS logging elements). The classification is done by ehds-logging, the EHDS logging component the FerroHEALTH products share (FerroAUDIT); this section is the deployment’s map. A template id, or failing that a root archetype id, maps to a list of categories, or to the word none for data of no category. An access the map in force cannot classify is recorded unclassified with its ids, never refused.

The shipped map. While the section declares no template and no archetype, the map FerroEHR ships applies (app/ferroehr/assets/audit-categories.default.toml). It names the published CKM templates and COMPOSITION archetypes whose purpose places their data in one priority category:

KeyCategory
template International Patient Summary, archetype openEHR-EHR-COMPOSITION.health_summary.v1Patient-Summaries
template ePrescription (FHIR), archetype openEHR-EHR-COMPOSITION.prescription.v0Electronic-Prescriptions
template Generic lab test result example simpleLaboratory-Reports
template CCTA reportMedical-Imaging
archetype openEHR-EHR-COMPOSITION.transfer_summary.v1Discharge-Reports

It leaves out the generic report.v1 and report-result.v1 containers, which carry laboratory, imaging and other reports alike, so an access through them is recorded unclassified instead of guessed. CKM publishes no composition for an electronic dispensation. A deployment that declares any template or archetype replaces the shipped map whole, so copy its entries into your own map to keep them. A national category declared alone keeps the shipped map. shipped_map = false with nothing declared leaves no map: every access is then recorded unclassified, and deployment_profile = "production" refuses to start on the no_category_map gap unless deployment_accepts names it.

[audit.categories]
national = ["urn:oid:2.16.840.1.113883.2.4.3.11.60.1|beeld"]

[audit.categories.templates]
"International Patient Summary" = ["Patient-Summaries"]
"Laboratory Report" = ["Laboratory-Reports"]

[audit.categories.archetypes]
"openEHR-EHR-COMPOSITION.report-result.v1" = ["Laboratory-Reports"]
"openEHR-EHR-COMPOSITION.encounter.v1" = "none"
KeyTypeDefaultDescription
shipped_mapbooltrueApply the map FerroEHR ships while no template and no archetype is declared.
nationallist of <system>|<code>emptyThe national categories (EHDS Art. 14(1) third subparagraph) the map may name.
templatestable of template id → list of category, or "none"emptyConsulted first, by the template id the object was committed against.
archetypestable of archetype id → list of category, or "none"emptyConsulted for an object whose template the map does not name, by its root archetype id. A key that is not an archetype id refuses boot.

A priority category of EHDS Art. 14(1) is named by its code in HL7 Europe’s EEHRxFDocumentPriorityCategoryCS: Patient-Summaries, Electronic-Prescriptions, Electronic-Dispensations, Medical-Imaging, Laboratory-Reports and Discharge-Reports, or as <system>|<code>. A national category is named by the <system>|<code> it is declared with. Keys compare case-insensitively. An unknown code, an empty list, a word other than none, or two keys that differ only in case refuses boot with a message naming the entry. A record stores each category as its <system>|<code> token.

The map’s SHA-256 digest is on the boot line, on GET /management/info under audit.category_map, and on every access record. At boot the map is also mirrored into the retention register, so a retention period can be keyed on a category through the admin marks API.

[origins]: the origins a deployment recognises

Every version records where its data came from at commit: each FEEDER_AUDIT originating-system id its body carries, and the access log reports them (EHDS Annex II 3.2(e)). Annex II 3.4 also asks for retention periods and access rights that take the origin into account. The ids a sending system writes are free text, so this register declares the origins your deployment recognises, which a retention period and an ABAC rule may then name. A top-level table, not part of [audit].

[[origins.registered]]
id = "lab.example"
clients = ["lab-gateway"]
KeyTypeDefaultDescription
registeredlist of tablesemptyThe recognised origins.
registered[].idstring(required)The id the origin’s data carry: the FEEDER_AUDIT system_id its feed writes, compared exactly, and the origin a direct commit by one of its clients is stamped with.
registered[].clientslist of stringemptyThe clients whose direct commits come from this origin: a Basic user name, or an access token’s client_id, failing that its azp.

A body that carries no FEEDER_AUDIT, committed by a listed client, is stamped with that client’s origin; a direct commit by any other caller keeps this server’s [rest] system_id, as before. A blank id or one with whitespace, an id registered twice, a blank client, or a client listed under two origins refuses boot.

[audit.syslog]: the classic DICOM/syslog feed (ITI-20)

The DICOM PS3.15 audit message, framed as RFC 5424 syslog and sent over RFC 5425 TLS 1.2 or later by ihe-atna (FerroAUDIT). TLS is the one transport: an audit message names a patient (ITI TF-2 §3.20.4.1.2.1.1).

KeyTypeDefaultDescription
enabledboolfalseShip DICOM PS3.15 audit records to an external repository over syslog.
hoststringlocalhostRepository host.
portint6514Repository port, the RFC 5425 port by default.
tls_ca_filepathunsetPEM file with the repository CA to trust beside the platform’s roots.
tls_identity_cert_filepathunsetClient-certificate PEM for mutual TLS.
tls_identity_key_filepathunsetClient-key PEM for mutual TLS.
spool_dirpathunsetThe directory of the spool every frame is stored in before it is sent. It must give no access to the group or to other users. Unset, the spool is held in memory, and a restart loses the frames not yet delivered.
spool_max_messagesint100000The most frames the spool holds; a frame past it is refused and counted in atna_audit_dropped.
spool_max_bytesint268435456The most bytes the spool holds.
retry_max_msint60000The longest wait between two delivery attempts after a failure.

The drain stores each frame in ihe-atna’s spool, and the crate’s forwarder delivers it: one connection held open, a failed send retried with an exponential backoff and jitter, and the connection closed with a TLS close_notify on shutdown, so nothing is lost to a reset (ITI TF-2 §3.20.4.1.1, RFC 5425 §4.4). The local audit store keeps only the FHIR document, never the syslog frame, so the spool, not the store, is syslog’s outbox. delivered_syslog_at is no longer written.

[audit.fhir_feed]: the RESTful-ATNA feed (ITI-20 ATX:FHIR Feed)

KeyTypeDefaultDescription
enabledboolfalsePOST each FHIR AuditEvent to an external FHIR Audit Record Repository.
urlsecret URLhttp://localhost:8080/fhirThe repository’s FHIR base; records go to {url}/AuditEvent. Credentials embedded in the URL are redacted from every rendering.
poll_interval_msint2000How long the forwarder waits for a new record before it reads the store again.
retry_max_msint60000The longest wait between two delivery attempts after a failure.

ihe-atna’s forwarder delivers the feed. With the local store on, it delivers from the store, oldest record first: a record not yet delivered has no delivered_fhir_feed_at, so a repository outage loses nothing, and the record is stamped once the repository accepts it. A record the repository refuses (a 4xx other than 401, 408 or 429) is set aside until the next start. It is reported on GET /management/health as audit_delivery, and the records behind it keep flowing. With the store off, the feed delivers from an in-memory spool. batch_size and max_retries are gone and stop startup by name: the forwarder retries until the repository answers.

[audit.fhir_feed.oauth]: authenticating with OAuth 2.0 client credentials

The feed authenticates with HTTP Basic through the credentials in url, or, with this table, as an OAuth 2.0 client (the IHE IUA authorized-client model): FerroEHR obtains a token with the client credentials grant, sends it as a bearer token, renews it before it expires, and when the repository refuses the held token with 401 fetches a new one and sends the record once more. Set one of the two, never both: a url with credentials beside this table stops startup.

KeyTypeDefaultDescription
token_endpointURLunsetThe token endpoint. Set this or issuer.
issuerURLunsetThe issuer whose metadata (/.well-known/oauth-authorization-server) names the token endpoint.
client_idstringunsetThe client identifier. Required.
client_secret / client_secret_filesecretunsetThe client secret, inline or read from a file. One is required.
client_authenum{client_secret_basic,client_secret_post,private_key_jwt,tls_client_auth,self_signed_tls_client_auth}client_secret_basicHow the client authenticates to the token endpoint.
scopestringunsetThe scope of the token request.
audiencestringunsetThe audience parameter, for an authorization server that reads it.
resourceURLunsetThe resource indicator (RFC 8707) naming the repository.
private_key_filepathunsetThe PEM private key that signs the client assertion under private_key_jwt (RFC 7523).
kidstringthe RFC 7638 thumbprintThe kid the client assertion names.
assertion_algstringby key typeThe algorithm the client assertion is signed with.
assertion_audienceenum{token_endpoint,issuer}token_endpointThe aud of the client assertion; issuer needs issuer set.
client_certificate_file / client_key_filepathunsetThe client certificate chain and its key presented over mutual TLS (RFC 8705); set together.
ca_filepathunsetPEM trust roots added to the built-in ones, for a token endpoint behind a private certificate authority.
refresh_margin_msint30000How long before the token expires a new one is fetched.
timeout_msint10000How long one call to the authorization server may take.

With the local store on, the feed delivers from the store and loses nothing across a repository outage. With the store off it delivers from an in-memory spool, so a restart loses what the spool holds.

Privacy & data minimisation

[privacy] is what the clinical side refuses to hold. Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.

What this section is for

FerroEHR keeps clinical content, demographic parties and the map between them in three separate database schemas reached by separate roles. That separation is only worth something if the clinical side does not carry the subject’s identity anyway, in the subject reference, in a party proxy, or in free text. This section is the three rules that keep it out.

No openEHR specification governs any of them. The Reference Model leaves EHR_STATUS.subject.external_ref open and only advises against identifying content on a party proxy (PARTY_IDENTIFIED: “Should not be used to include patient identifying information”). GDPR Art. 4(5) and Art. 25(2) are what make it a duty; this section is where a deployment states its side of it.

All three rules run on the write path, before anything is stored. A refusal is 422 Unprocessable Entity with one errors[] entry per finding (sent when the request carries Prefer: return=representation), each naming the RM path and the rule that matched. A refusal never echoes the offending value — it would otherwise travel into the response body, the access log and the traces, which is the leak the rule exists to prevent.

[privacy]
subject_namespaces = []
allow_identified_parties_in_ehr = false

[privacy.identifier_scan]
mode = "strict"
rules = ["ch-ahvn13", "ch-epd-pid", "de-kvnr", "fi-hetu", "gb-nhs-number", "nl-bsn", "no-fodselsnummer", "se-personnummer"]
patterns = []

[privacy]

KeyTypeDefaultDescription
subject_namespaceslist of strings[]The pseudonymisation domains this deployment issues subject pseudonyms in.
allow_identified_parties_in_ehrboolfalseAccept 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 clientaccepted with any namespace and any identifier, as the openEHR REST API admitsmust name a declared namespace and carry a UUID; anything else is a 422
The databaseno shape helda trigger on ehr refuses a non-UUID subject reference whichever session writes it
MintingFerroEHR mints noneFerroEHR mints none: the IHE patient identity source does

FerroEHR mints no pseudonym. Under the IHE patient identity source ([ehr_index]), the source answers the pseudonym the clinical domain holds for a patient, and the identity source mints it on the first ask; that domain’s assigning authority is a declared namespace. Every value that arrives from elsewhere, a client writing EHR_STATUS directly, an EHR-Extract, an archive load, meets the same rule, which is why declaring the namespace is the deployment’s act.

Empty is the default, and it leaves the rule out of force. A pseudonym namespace is a deployment fact — which service mints the tokens — and there is no name a server could invent for an operator. Declaring one is the same act as turning the rule on. An EHR with no subject reference at all is unaffected either way: that is the default EHR_STATUS the openEHR REST API describes, and it carries nothing to minimise.

Both spellings of a UUID’s case are accepted; the braced, URN and unhyphenated forms are not, because the promoted subject column is compared as text and four spellings of one pseudonym would be four subjects.

The rule has a second line of defence in the database. On every boot the server stamps whether namespaces are declared, and a trigger on the ehr table then refuses a subject reference that is not a UUID whichever code path or session writes it (ehr_subject_pseudonym_guard). Declaring namespaces over an existing store does not rewrite stored rows: the boot log names how many EHRs carry a subject reference that is not a pseudonym, and those need re-pseudonymising.

Identified parties

A party proxy inside clinical content — the composer, participations, the health care facility and the feeder-audit party slots — is governed by which class it is:

nameidentifiers
PARTY_IDENTIFIEDacceptedaccepted
PARTY_RELATED, relationship a third party (mother, guardian, donor, …)acceptedaccepted
PARTY_RELATED, relationship selfrefusedrefused

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.

KeyTypeDefaultDescription
modestrict or warnstrictstrict refuses the write; warn accepts it and records a warning naming the RM path and the rule.
ruleslist of rule keysevery rule the build shipsThe national personal-identifier rules to scan for. An unknown key is a boot error listing the shipped keys.
patternslist 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.

KeyJurisdictionIdentifierPublished by
ch-ahvn13CHAHV-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-pidCHPatientenidentifikationsnummer (EPD-PID)EPDV-EDI Anhang 1, vendored at docs/law/ch/epdv-edi/: the fixed prefix 76133761 and a mod-10 check digit
de-kvnrDEKrankenversichertennummer (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-hetuFIhenkilötunnusValtioneuvoston asetus väestötietojärjestelmästä 128/2010, 2 §: the date of birth, the century sign, the individual number and the mod-31 check character
gb-nhs-numberGBNHS NumberNHS Data Model and Dictionary
nl-bsnNLburgerservicenummerRijksdienst voor Identiteitsgegevens, Logisch Ontwerp BSN
no-fodselsnummerNOfødselsnummerSkatteetaten
se-personnummerSEpersonnummerSkatteverket

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_string is 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-bsn at 1 in 11 is the loosest shipped rule, which is why the code-slot carve-out matters most to it. Every figure here is measured, over 100 000 random tokens of each rule’s own shape, by a test that fails if a rule drifts from the rate it publishes.

A deployment measuring its own content runs warn first, reads the recorded findings, then moves to strict.

Local patterns

Medical-record numbers, payer references and postcode forms have no single issuing register to transcribe, so a deployment declares them itself as Rust regular expressions:

[privacy.identifier_scan]
patterns = [
  "\\bMRN-[0-9]{6}\\b",
  "(^|[^0-9A-Za-z])[1-9][0-9]{3} ?[A-Z]{2}[^0-9A-Za-z]{1,3}[0-9]{1,4}([^0-9A-Za-z]|$)",
]

The second refuses a Dutch postcode paired with a house number — the pair is what identifies a household; a postcode alone does not. A pattern that does not compile is a boot error naming it.

[cohort]

Cross-domain cohort queries: select a population in the demographic domain, resolve it to EHRs through the linkage domain, and run AQL over exactly those EHRs. A FerroEHR extension — no openEHR spec governs it. The wire contract and a worked example are in Querying with AQL.

Off until a predicate is bound. Which demographic leaf may be selected on is a deployment fact about the archetypes in use, never something the server can infer, so [cohort.predicates] is empty by default and POST /query/cohort answers 404 until it is not. subjects = true turns the surface on without a predicate, for cohorts that name their subjects by identifier. That is the one form an IHE patient identity source serves ([ehr_index] provider = "ihe"): it holds no party-to-EHR map, so a predicate is refused there.

[cohort]
small_cell_threshold = 5
max_cohort_size = 100000

[cohort.predicates]
city = { archetype = "openEHR-DEMOGRAPHIC-ADDRESS.address.v1", node = "at0012", kind = "text" }
KeyTypeDefaultDescription
small_cell_thresholdint5The 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_sizeint100000The largest cohort a predicate may select. A wider one is refused 422 rather than truncated: a silently shortened cohort is a wrong denominator.
predicatestableemptyThe allow-list, keyed by the name a caller uses. Empty, with subjects off, leaves the surface off.
subjectsboolfalseLet a request name its subjects by identifier, in any identifier domain, each resolved through the [ehr_index] provider: under the IHE provider by a forward PIXm translation into the server’s clinical domain, never back.

The allow-list

Exactly five keys are bindable — city, postcode_area, sex, age_band and organisation — and an unknown key is a boot error. Each binding carries three fields:

FieldDescription
archetypeThe 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.
nodeThe ELEMENT’s archetype_node_id at-code, e.g. at0012.
kindHow a caller’s value is matched: text (exact, against value/value), text_prefix (prefix, LIKE-escaped), coded (exact, against value/defining_code/code_string), or birth_date (an inclusive age band in whole years, 40-49).

age_band must be bound birth_date, and no other key may be — both halves are boot errors, because a mismatch would bind a predicate that matches nothing while reporting an empty cohort.

The archetype must be one the node model reaches. A party is decomposed into its own rows for the party root, for each PARTY_IDENTITY, CONTACT, ADDRESS and CAPABILITY nested in it, and for every archetyped ITEM_TREE or CLUSTER under any of their details. An ADDRESS under a CONTACT is therefore bindable like anything else.

Interaction with the audit trail

The ATNA Patient-Number participant is filled from the promoted subject column, so constraining the subject reference to an opaque pseudonym is also what keeps the audit trail and its forwarding sinks free of the identity. The contribution outbox carries no subject at all. Both are covered by a CI test that fails when any subject identifier other than the opaque UUID appears in an outbox payload, an ATNA message or a trace record.

Protecting national identifiers in the demographic domain

The rules above keep national identifiers off the clinical side. The demographic side is where a party’s identifiers legitimately live, and [demographic.identifier_protection] decides how they are held there.

With it on, an identifier of a configured scheme never sits in the versioned body. The value moves to party.national_identifier, sealed with AES-256-GCM under a key derived per domain, and the body keeps a reference in its place. Beside the ciphertext sits an HMAC-SHA-256 digest of the value, which is what makes “which party holds this identifier” answerable without decrypting anything — and, because it is keyed, what stops the database, a backup or a read replica from reversing a nine-digit space.

[demographic.identifier_protection]
enabled = false
schemes = ["nl-bsn"]
#? key_file = "/run/secrets/ferroehr-identifier-key"
KeyTypeDefaultDescription
enabledboolfalseWhether protection is in force. Off leaves identifiers stored as written.
schemeslist of string["nl-bsn"]The DV_IDENTIFIER.type values to protect. Each must exist in the party.identifier_scheme registry; with protection on, the server refuses to boot when one does not.
keysecretunsetThe root key, 64 hex characters. Prefer key_file.
key_filepathunsetA file holding the root key, read at boot.

Turning it on with no key, with an empty schemes list, or with a scheme the registry does not hold, is a boot error. A server that believes it seals national identifiers and does not is worse than one that never claimed to.

Four properties worth knowing before you enable it:

  • The stored, signed and served body are the same form. Sealing runs before the body is decomposed and signed, exactly like the multimedia offload, so a signature still verifies against the bytes the server holds. What a reader receives carries the reference; the value is available through the resolution path below. A create or update response serves the stored form as well, so the value you submitted does not come back in it.
  • Each party version keeps its own sealed rows. A later version retires the rows of the one before it without rewriting them, so an older version still expands to the value it committed. Only live rows take part in resolution.
  • Resolution is audited. Going from an identifier to a party is recorded as a linkage-domain access naming the scheme and whether it matched — never the value. A miss is recorded too: it says someone asked whether this deployment holds that identifier.
  • Reading a value back is audited and needs its own role. GET /ferroehr/rest/extensions/v1/admin/party/identifiers serves a party version with its values put back, for a caller holding both the admin role and authz.rbac.identifier_reader_role, and records each scheme it expands (reading a party’s protected identifiers).
  • The key is load-bearing and rotation is a re-encryption. The runbook is in Operations.

Only the demographic writer role reaches the sealed value. The read-only twin sees that a party holds a protected identifier and which party it is, and is refused the ciphertext and the digest by column-level grant; the clinical roles are refused the table outright.

CLI & production checklist

The binary’s command-line surface, what a zero-configuration boot actually gives you, the minimum a production deployment sets, and which material belongs in a mounted file rather than an environment variable. Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.

The command line

Two flags are global: they apply to the server and to every subcommand:

FlagDescription
--config <path>The configuration file to load, overriding FERROEHR_CONFIG. Fatal if missing or unreadable.
--set <key>=<value>A dotted-path override, repeatable, highest precedence of all layers (e.g. --set db.max_connections=40).

With no subcommand, or with serve, the binary boots the server. ferroehr --version prints the version and the manufacturer of the release:

ferroehr <version>
Manufactured by Cadasto B.V., Comeniusstraat 2d, 1817 MS Alkmaar, The Netherlands, info@cadasto.com
https://www.cadasto.com/contact/

ferroehr config …

ferroehr config check [--config P]  # validate file + environment + --set

The fully-commented template every key’s default comes from, the starting point for a real ferroehr.toml, is app/ferroehr/assets/ferroehr.default.toml.

config check runs the same validation the server runs at boot (the strict unknown-key sweep, the type pass, the aggregated semantic rules, and the “authentication enabled needs a mechanism” rule) and touches no database, so it is safe in CI and before a rollout. It exits 0 when the configuration is valid and 1 otherwise; on success it prints the effective configuration as TOML with every secret redacted, and notes on stderr when db.url is still the built-in development default.

ferroehr db …

ferroehr db migrate   # apply the embedded migrations and exit
ferroehr db verify    # verify, issuing no DDL, that the schema matches this build

These exist so a least-privilege deployment can separate the two database identities: run db migrate once under a DSN that holds DDL rights (a Kubernetes Job, an init container, a CI/CD stage), then boot the server with db.migrate = "verify" under a DSN with no DDL rights at all. verify still reads all five _sqlx_migrations bookkeeping tables, which no least-privilege role can do, so point [db] migrate_url at the credential that prepares the schema. See Operations.

ferroehr healthcheck

Asks the running server’s GET /health/readiness and exits 0 on 200, 1 otherwise, with the reason on stderr: the container HEALTHCHECK and the Kubernetes exec-probe fallback. A draining server answers 503, so it reads unhealthy. The subcommand loads the same configuration as the server (file, environment, --set) and asks the server.listen port under server.base_path, on the loopback when the server listens on every interface. With [server.tls] set it speaks TLS and accepts exactly the leaf in server.tls.certificate_file, presenting server.tls.healthcheck_identity_file to a listener that requires a client certificate. It waits three seconds for the answer.

ferroehr usage-report --print

ferroehr usage-report --print                  # the start report
ferroehr usage-report --print --event daily    # the daily report, with its metrics

Prints the exact JSON body of the usage report this instance would send now, on one line to stdout, and sends nothing. It loads the same configuration as the server and connects to the database to read the instance id and the shared metrics window, writing nothing. Notes go to stderr: a sample id when the database holds none yet, and a line saying so when the report is off. --print is required; printing is the only action.

ferroehr report

ferroehr report                              # writes ferroehr-report-<UTC time>.json here
ferroehr report --output /tmp/report.json    # writes to the path you name
ferroehr report --output -                   # writes the JSON to stdout

Writes one JSON document naming what this deployment runs. Attach it to a complaint, a non-conformity finding or a serious-incident report: it is the “which FerroEHR does this deployment run” answer those start from. It loads the same configuration as the server and must pass the same validation. It reads the databases and writes nothing to them.

PartWhat it holds
manifestevery part below with its status (included, partial or unavailable) and, for the last two, the reason
buildversion, commit, build date, rustc, the active spec_profile and the specification versions it selects, the PostgreSQL target, the audit posture, and the manufacturer
imagenamed in the manifest only: a running binary cannot read the digest of the image it was started from, so attach docker inspect --format '{{index .RepoDigests 0}}' <image> or the pod’s status.containerStatuses[].imageID
targetthe CPU architecture and operating system the binary was built for, and whether it is a debug build
featuresthe optional integrations compiled in (events, fhir, multimedia)
openehr_crateseach openehr-* crate the binary links, with its version
licencethe licence summary GET /management/status serves (state, use, licensee, last valid day, what the configured token contributed); never the token
deploymentthe deployment profile, the open gaps and the accepted ones, measured over the databases, or from the configuration alone when they do not answer
schemafor each database, the highest migration version applied and the highest this build embeds, per schema
configurationthe effective configuration as config check prints it

Nothing in it identifies a person: it reads no clinical, demographic or audit table. Every secret in the configuration shows ***, every URL shows *** in place of its user name and password, and a database error quoted as a reason has each configured database user name and password masked too. A database that does not answer within 15 seconds leaves the schema part out and names it unavailable with unreachable: … as the reason.

Zero-config boot and the production checklist

With no file and no environment, the effective configuration is: listener 0.0.0.0:8080 at the ITS-REST base path with Swagger UI, reading bodies up to 64 MiB; the database at the built-in development DSN with migrations applied at boot; RBAC on; signing on in digest mode with read-time verification strict; the audit trail on with only the local store; no caller rate limit; logs in auto format at info; and every integration off. The one outbound connection on by default is the usage report, which usage_report.enabled = false switches off.

One thing that configuration does not do is serve requests. auth.enabled defaults to true, and authentication enabled with no mechanism configured is a boot error rather than a running server that refuses everything: RFC 9110 §11.6.1 requires a 401 challenge to name a scheme applicable to the resource, and a server with no mechanism has none: it could only refuse every request while advertising a scheme it does not implement. The error names the three ways out: add [[auth.basic.users]], add an [auth] issuer, or set auth.enabled = false for development. So a bare docker run of the image with no configuration stops at startup with that message, while the downloadable Compose quickstart boots because it ships a user.

For production, set at least:

  • db.url: the real DSN, via FERROEHR__DB__URL from a secret or a url_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, plus db.migrate_url naming the credential that reads every schema’s migration state at boot. The serving credential cannot.
  • deployment_profile = "production" once the deployment holds real personal data. The server then refuses to start while a separation is open and not accepted by name, instead of running as a sandbox that looks identical.
  • an authentication mechanism: a Basic user store and/or an [auth] issuer.
  • telemetry.format = "json" for cluster log collectors.
  • rest.cors_permissive stays false; rest.swagger_ui per posture.
  • rest.system_id: this deployment’s own openEHR system identifier. Choose it before the first EHR is created: it is stored with every EHR, audit entry and version identifier, and changing it later never rewrites what is already committed.
  • management.* per posture. A dedicated management.port is recommended so the introspection surface is never reachable on the clinical listener, and every endpoint stays off until you name a level for it.
  • TLS everywhere a transport supports it: server.tls (or a TLS-terminating ingress), events.tls, HTTPS for the object store; the audit syslog feed always speaks TLS.
  • real secrets via the environment or a *_file sibling, never inline.

Before a deployment holding real patient data goes live, work through the go-live checklist: it turns each item above into something you run and read back, and adds the privacy and audit checks a controller is asked for.

What belongs in a mounted file (versus the environment)

The environment cannot carry an array of tables, so the Basic-auth user store ([[auth.basic.users]]) is file-only.

Genuinely file-shaped material (the PGP signing key, Cedar policies, ATNA and terminology-server PEMs, a JWKS blob) is referenced by an in-TOML *_path / *_file key pointing at a mounted path. On Kubernetes the chart’s config.files map materialises each entry under /etc/ferroehr/, read-only, from a Secret.

Prefer a *_file sibling over the environment form for any secret in a container: an environment value is readable through /proc/<pid>/environ and is inherited by every child process the container spawns.

Everything else is a plain key you can set in the file or override with a FERROEHR_* variable.

For a worked development example (the server section, CORS, admin, management and the Basic-auth user store) read the configuration carried inline in the quickstart docker-compose.yml; see Docker Compose.

Variables outside the server’s namespace

The PostgreSQL init container’s variables are PG_INIT_USER, PG_INIT_PASSWORD and PG_INIT_DB; they configure the database container, not the server, and sit outside the server’s reserved FERROEHR_ namespace.

Inside that namespace, a handful of names are deliberately not configuration keys and pass the strict sweep untouched: FERROEHR_CONFIG (the config-file pointer), the build-stamp variables, and the Compose parameterization (image tags, host ports, CPU and memory limits). They keep a single _ by design, which is exactly what distinguishes them from configuration keys, and why a single-underscore misspelling of a real key is reported at boot with the uniform spelling it should have had.

Concepts

This part explains the ideas you need to use FerroEHR effectively. Four chapters; the first two read in either order, the last two go deeper:

  • openEHR primer: the standard itself: the Reference Model, archetypes and templates, compositions, versioning, and AQL, with no prior openEHR knowledge assumed. Read this first if the words “archetype” and “composition” are new to you.
  • System architecture: how this server is put together and where your data actually lives, so the behaviour you see through the API makes sense: why the specification layer is generated, how storage and versioning work on PostgreSQL, and how an AQL query becomes SQL.
  • Storage architecture: the deep-dive on the tables: the temporal version table, the decomposed node model with its nested-set index, the one-transaction write path, and the cold archival tier, each with a diagram.
  • The AQL engine: the query pipeline from lexer to RESULT_SET, what each stage refuses, and the design reasons queries are fast.

None of them is a prerequisite for Getting started; you can commit a composition and run a query without them. Come back when you want to know why the API behaves as it does, or before you make one of the decisions you then live with: a specification generation, a template design, a query shape.

openEHR primer

openEHR is an open standard for storing health records in a way that outlives any single application. Its central idea is to keep what clinical data means separate from the software that stores it. This chapter introduces the pieces you meet when using FerroEHR (the Reference Model, archetypes and templates, compositions, versioning, and AQL) in plain terms. It is enough to follow the rest of this book; the openEHR specifications are the full reference.

The Reference Model: a fixed vocabulary of shapes

At the bottom is the Reference Model (RM), a fixed, general set of building blocks that never changes per project. It defines generic structures such as a COMPOSITION (a clinical document), a SECTION (a heading), an OBSERVATION, EVALUATION, INSTRUCTION and ACTION (the kinds of clinical statement), and the data types that carry actual values: DV_QUANTITY (a measured amount with a unit), DV_CODED_TEXT (a term from a terminology), DV_DATE_TIME, DV_TEXT, and so on.

The RM is deliberately generic: it knows about “a quantity with a unit” but not about “systolic blood pressure in mmHg”. That specificity comes from the layer above.

FerroEHR carries two Reference Model generations, and one configuration key, spec_profile, chooses which one a deployment runs: RM 1.2.0 (the development default) or RM 1.1.0 (stable, the latest released generation). openEHR’s minor releases are additive, so data written against 1.1.0 reads unchanged under 1.2.0; the reverse is not guaranteed, which is why the choice is worth making before you commit clinical data. See spec_profile.

Archetypes and templates: the meaning layer

An archetype is a reusable, computable definition of one clinical concept (“blood pressure”, “body weight”, “medication order”) expressed as constraints over the Reference Model. It says which fields exist, how many times each may occur, what units and value ranges are allowed, and which terminology codes are valid. Archetypes are authored once by clinicians and modellers (often drawn from the international Clinical Knowledge Manager) and shared across systems.

A template assembles and further constrains a set of archetypes for a specific use: a particular form, message, or dataset. It picks the archetypes you need, narrows their optionality (mandatory here, hidden there), and pins down defaults. The template is what a CDR is actually loaded with.

FerroEHR ingests templates as Operational Template (OPT) 1.4 XML, and ADL 2 artefacts as ADL 2 source. Once a template is uploaded, the server derives what it needs to validate incoming data and to describe the data’s shape to client applications; see Templates & validation.

Note

The order is always: agree on archetypes → build a template from them → upload the template to the CDR → commit data that conforms to it. You do not define a database schema; the template is the schema, and it lives in the clinical model, not the code.

Compositions: the unit of clinical data

A composition is the openEHR unit of committed clinical content: one document, conforming to one template, stored inside one patient’s record. A blood-pressure reading, an encounter note, a lab result set: each is a composition. Compositions are grouped and organised inside an EHR, the container that represents a single subject of care.

Every EHR also has an EHR_STATUS (metadata about the record, including whether it is queryable and modifiable, and the link to the subject) and, optionally, a directory, a folder tree for organising compositions.

Versioning: nothing is ever overwritten

openEHR records are versioned and indelible. When you update a composition, the previous version is retained and a new version is created alongside it. You can read any composition as of a point in time, list its full history, and never silently lose clinical data. Deletion is logical: the object is marked deleted but its history remains readable.

Every change is wrapped in a contribution, an atomic change-set that also records an audit entry (who, when, why). A single contribution can commit several compositions together, and either all of them land or none do.

FerroEHR serves both the latest version and all versions of an object, and AQL can query across version history; see Querying with AQL.

AQL: querying by meaning, not by table

The Archetype Query Language (AQL 1.1) is how you get data back out. Instead of SQL over hidden tables, you query against the clinical model using archetype and template paths. A query names the RM types and archetypes it wants, uses CONTAINS to express structural nesting, and selects values by their path within the archetype:

SELECT
    o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic
FROM EHR e
    CONTAINS COMPOSITION c
        CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140

The same query runs unchanged against any conformant openEHR system holding that archetype. That is the portability payoff. The Querying with AQL chapter is a full walkthrough, including which constructs this engine supports and how it refuses the ones it does not.

How it fits together

flowchart TB
    rm["Reference Model<br/>(generic building blocks)"]
    arch["Archetypes<br/>(one clinical concept each)"]
    tmpl["Template (OPT 1.4 or ADL 2)<br/>(archetypes assembled for a use)"]
    comp["Compositions<br/>(committed clinical documents)"]
    ehr["EHR<br/>(one subject's record)"]
    aql["AQL<br/>(query by clinical path)"]

    rm --> arch --> tmpl
    tmpl -->|validates| comp
    comp --> ehr
    aql -->|reads| ehr

With these concepts in hand, the System architecture chapter shows how FerroEHR realises them.

System architecture

This chapter explains how FerroEHR is built and where your data lives, in practical terms. You do not need any of it to use the API, but it clarifies why the server behaves the way it does: why the conformance claims are checkable, why versioning is exact, and why AQL does not degenerate into a document scan. Two ideas run through everything: the openEHR specification layer is generated from the official machine-readable models, and the storage is designed natively for PostgreSQL 18.

Two layers

flowchart TB
    specs["openEHR machine-readable specifications<br/>(Reference Model · XML schemas · OpenAPI — vendored &amp; pinned)"]

    subgraph gen ["Specification layer (generated, never hand-edited)"]
        types["Reference Model types (two generations) · canonical JSON &amp; XML<br/>ITS-REST contract (Release-1.1.0) · AQL 1.1 parser · Simplified Formats"]
    end

    subgraph app ["Application layer (the server)"]
        rest["REST adapter (axum)<br/>authentication · authorization · wire mapping"]
        sm["Service layer<br/>(our own design)"]
        core["Platform: PG18 storage · versioning ·<br/>AQL→SQL engine · validation · signing"]
        ext["Optional integrations<br/>(FHIR audit and terminology · events · multimedia — compiled in by cargo feature)"]
    end

    db[("PostgreSQL 18")]

    specs -->|deterministic codegen, drift-checked in CI| gen
    rest --> sm
    core -->|implements| sm
    core --> ext
    app --> gen
    core --> db

The specification layer is generated. openEHR publishes its Reference Model, serialization schemas, and REST contract as machine-readable models. FerroEHR generates its Rust types, canonical JSON/XML (de)serialization, the REST API contract, and the AQL front end directly from those models. The consequence for you: the server’s data shapes and wire contract cannot silently drift from the standard. A continuous-integration check regenerates everything and fails the build on any divergence. A specification update is a regeneration. That layer is also published for reuse, as standalone Rust crates.

The application layer is the server. It holds everything the generated layer does not: storage, the query execution engine, validation, and security. This is where design choices specific to FerroEHR live. The optional integrations (the FHIR audit rendering and terminology client, change events, S3 multimedia) sit beside it in their own crate behind additive cargo features, so a build without them contains none of their code; see Beyond the core. FHIR R4 resource mapping is not part of FerroEHR: it is FerroBRIDGE, a separate server that reaches FerroEHR over ITS-REST. The Helm chart deploys it in the same release with bridge.enabled=true; see Kubernetes & Helm.

What you actually deploy is small: one self-contained server binary plus PostgreSQL. No JVM, no language runtime, and a pure-Rust TLS stack. The published container image is distroless and non-root, with no shell and no package manager. (It is not a static binary: the server links the system C library dynamically, which is why the image is the cc distroless variant. See Operations.) The viewer is a separate, optional binary and image that talks to the server strictly over the public REST API.

Two specification generations, one selectable set

The Reference Model is not pinned to a single version. The generated layer emits two generations side by side (the latest released one and the development one), and a single configuration key, spec_profile, picks which set the server runs: development (Reference Model 1.2.0 with BASE 1.3.0, the default) or stable (Reference Model 1.1.0 with BASE 1.2.0). Because openEHR’s minor releases are additive supersets, everything valid under stable is valid under development; the reverse is not guaranteed, so the profile also acts as an acceptance boundary in both directions:

  • Surface the selected generation does not define is refused: an AQL FROM class or a path attribute RM 1.1.0 does not declare is rejected at planning time, with the active profile named in the error.
  • Released surface the development line later dropped stays accepted under stable: the request is read by that generation’s own reader at ingress, and the one attribute the newer generation removed is validated and then dropped (the server recomputes it) rather than stored.

Stored content is never silently rewritten to fit another generation. See spec_profile for the direction contract and how to change it on an existing deployment.

The native service API

Internally the server is organised around the openEHR Platform Service Model, a standard catalogue of service components (EHR, Composition, Directory, Contribution, Query, Definition, Terminology, Admin, Messaging, System Log, and more), with one module per component and its methods following that component’s own operations. The REST layer is a thin protocol adapter over that native API. Practically, this means the HTTP behaviour you observe maps onto the standard’s own service definitions, and the same core can be driven by adapters other than REST.

Three pools, three roles, one request path

The server holds three connection pools, one per pseudonymisation domain, and which pool a service module uses is fixed by what that module stores. The clinical pool serves the openEHR record, the demographic pool serves parties and their identifiers, and the linkage pool serves the map that says which party is the subject of which EHR.

flowchart LR
    rest["REST adapter"]
    subgraph clin ["clinical modules"]
        ehrsvc["service::ehr · query · admin<br/>definition · message · validity"]
    end
    subgraph dem ["identity module"]
        demsvc["service::demographic<br/>(parties, sealed identifiers)"]
    end
    subgraph lnk ["linkage module"]
        lnksvc["service::linkage"]
    end
    audit["system_log store"]
    pc[("clinical pool<br/>ferroehr_clinical → clinical")]
    pd[("party pool<br/>ferroehr_party → party")]
    pl[("linkage pool<br/>ferroehr_linkage → linkage")]

    rest --> ehrsvc
    rest --> demsvc
    rest --> lnksvc
    ehrsvc --> pc
    audit --> pc
    demsvc --> pd
    lnksvc --> pl
    lnksvc -. "resolve_ehr_for_identity, hop 1" .-> demsvc

resolve_ehr_for_identity is the one call that needs two domains. It asks the demographic pool for the party holding a sealed identifier, by keyed digest and without decrypting anything, then asks the linkage pool for that party’s EHR. The two hops are two connections in the application. No statement performs the join, and under separated credentials no role could issue one.

Each pool takes its own DSN from [storage.<domain>] url, defaulting to [db] url. Leave every domain unset and all four pools authenticate as the first, which keeps the schema separation and drops the credential separation. Preparing the schema spans every schema at once, so it runs on [db] migrate_url rather than on any of them. The local Audit Record Repository is written on the clinical pool, into its own audit schema. See Operations for the roles and the threat model for what each credential can reach.

Storage: the node model on PostgreSQL 18

A clinical composition is a deep tree. Storing each as one large JSON blob makes queries slow: extracting a single value forces the database to read and decompress the whole document every time. FerroEHR instead decomposes each versioned object into one row per structural node, in a single unified table:

  • Each node carries an integer interval index so that AQL’s CONTAINS (structural nesting) becomes a fast integer-range join rather than a tree-walk.
  • Hot query predicates (RM type, archetype, name, path, and the owning EHR) are promoted to indexed columns. The archetype identifier is stored split into its parts as well, which is what lets a query naming a parent archetype match data created with a specialisation of it.
  • The node’s own content is stored as canonical openEHR JSON, verbatim (compressed, with the structural children pruned into their own rows). There is no proprietary encoding and no translation step: what the storage holds is exactly what the API serves, which makes both querying and debugging straightforward.

Versioning: one table, one interval per version

Versioning uses a single version table rather than separate “current” and “history” tables. Each version is a row carrying the validity interval during which it was the version of record; the current one is the row whose interval is still open. Branches are modelled explicitly, so an imported or branched version coexists in time with the trunk without ambiguity.

Non-overlap is a property of how writes are performed, not a constraint the database re-checks per row: a partial unique index admits at most one open row per lineage, and every write closes the outgoing row and inserts its successor in the same transaction at the same instant, so the intervals meet exactly. PostgreSQL’s exclusion constraints would enforce the same property directly, but they serialize concurrent inserts on the write path, so this design does not use them.

Because history is just rows in the same table, FerroEHR serves both LATEST_VERSION and ALL_VERSIONS (the record as it is now, or across its entire history) from one place. Time-ordered UUIDv7 keys keep inserts index-friendly, and every write emits a contribution and an audit row in the same transaction, so the change-control trail is never out of step with the data.

There is also a cold tier: an administrator can archive EHRs and demographic parties into a separate schema in the same database, shrinking the tables that serve everyday traffic. Archived records stay readable by id and come back automatically on write, but they leave the AQL-visible store until then. The trade is spelled out in Admin & messaging APIs.

Note

openEHR does not define a database schema; it defines semantics (versioning, indelibility, canonical data fidelity). FerroEHR is free to choose the storage design that best serves those semantics on PostgreSQL, and its versioning behaviour is verified against the specification, not against any particular table layout.

The AQL engine

An AQL query is parsed, then its paths are typed against the generated Reference Model (which types an attribute may hold, whether it is multi-valued, which concrete types a slot can contain). From that typed form it is lowered to a single SQL statement: CONTAINS chains become interval joins on the node table, leaf values are extracted with PostgreSQL’s JSON path functions, and ordered comparisons on quantities and date/times go through small immutable helper functions that implement openEHR’s own magnitude and temporal semantics, which also makes them usable in indexes. The result is assembled into the standard RESULT_SET shape.

Anything the engine cannot lower is a typed refusal naming the construct, never a silently different answer. See Querying with AQL for the language and its supported feature envelope.

What this means for you

  • Checkable conformance. The wire contract and data types are generated from the standard and drift-checked, and the conformance catalogue is executed against a live server with its records committed to the repository, so the conformance claims are machine-derived. See Conformance.
  • Exact versioning. Nothing is overwritten; every version and its audit are retained and readable.
  • Operational simplicity. One self-contained binary and a PostgreSQL 18 database; see Installation.

Storage architecture

How FerroEHR physically stores clinical data. This page goes one level below the system architecture: the tables, the write path, the read paths, and the reasons the layout looks the way it does. If you read the source, the schema itself is the authority: every column in app/ferroehr/migrations/ carries a COMMENT ON line with its citation.

One thing up front: openEHR defines no SQL schema. What the specs do define, and what this storage realizes, are the versioning and change-control semantics (RM common change control), canonical data fidelity (ITS-JSON), and the contribution and audit duties. The relational layout below is FerroEHR’s own PostgreSQL-18-native design, and the RM explicitly sanctions that freedom: “Although the figure implies physical containment of Versions by a Versioned object, this is only one possible implementation. Other implementations (e.g. using orthodox relational structures) might use references, separate compressed copies, or any other mechanism.”

The big picture

Every versioned object (COMPOSITION, EHR_STATUS, EHR_ACCESS, FOLDER, the demographic party kinds) is stored twice over, deliberately, in one transaction:

  1. version holds 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.
  2. node holds 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:

SchemaHolds
extFerroEHR’s own IMMUTABLE helper functions (openehr_magnitude, openehr_timestamp), the runtime roles and the deployment posture
clinicalthe clinical CDR: versions, heads, nodes, EHRs, contributions, templates, queries, tags
partythe demographic pseudonymisation domain: party versions, heads, nodes, contributions, commit audits, and the sealed national_identifier values
linkagethe linkage pseudonymisation domain: subject_ehr, which party and which subject identifier name which EHR
auditthe IHE ATNA Audit Record Repository (audit_event)

Each carries its own migration set and its own _sqlx_migrations bookkeeping table, applied in that order. The archival tier is not a sixth schema: it is a partition of the relations it archives, described below.

The instance is single-tenant. No relation carries a tenant column and no row policy scopes a read: several organisations are served by several instances. openEHR puts multi-tenancy at the layer that hosts several logical EHR systems rather than inside one (BASE architecture_overview master06-design_of_the_ehr.adoc §The EHR System).

The three pseudonymisation domains

Parties (PERSON, ORGANISATION, GROUP, AGENT, ROLE, PARTY_RELATIONSHIP) live in party, never in clinical, and the split is enforced by the database in both directions: each domain’s version relation carries a CHECK admitting only its own kinds. The clinical record and the identity of its subject are therefore never in the same schema, the same archival tier, or the reach of the same runtime role — GDPR Art. 4(5) and Art. 32(1)(a), https://eur-lex.europa.eu/eli/reg/2016/679/oj. Which role reads which domain, and how a deployment turns the schema split into a credential split, is Operations → Database roles.

The mechanism inside the server is deliberately small: the two domains’ change control and node relations are rendered from one DDL template, so they carry the same names, the same column shape, the same indexes and the same foreign keys and cannot drift; and the pool serving each domain sets its own search_path. One set of storage code — the nested-set node codec, the versioning engine, the AQL path machinery — therefore serves both domains unchanged, and no SQL in the server names a domain schema. A test renders both files and refuses any difference beyond the kind CHECK and the foreign keys into the ehr relation, which the party domain has none of.

The third domain is one table. linkage.subject_ehr is the EHR id / subject cross-reference (the EHR index), and it records which party and which subject identifier name which EHR, temporally: a merge, a split or an index correction closes the row in force and opens its successor, and the temporal key (UNIQUE (party_id, sys_period WITHOUT OVERLAPS)) admits one open mapping per party. It carries identifiers, their association metadata, and a validity period, and nothing else, because a row here is already the additional information that re-attributes a record to a person. It holds no foreign key into either schema it joins, since PostgreSQL enforces a foreign key by reading the referenced row and no credential here may. ferroehr_linkage holds SELECT, INSERT and UPDATE on it and no DELETE, so a mapping is closed rather than removed — with one exception, and it is the one the law requires: linkage.erase_ehr is a SECURITY DEFINER function the role may execute, and deleting an EHR calls it so no row survives naming a record that no longer exists.

The wire is unaffected. The ITS-REST Demographic API, the RM change-control semantics and every version identifier are exactly what they were; only where the rows physically sit has changed. No openEHR spec governs storage layout — this is FerroEHR’s own design.

Core tables and how they relate

erDiagram
    ehr ||--o{ contribution : "owns (NULL for parties)"
    contribution ||--|| commit_audit : "its own audit"
    contribution ||--o{ version : "change set members"
    commit_audit ||--o{ version : "commit_audit"
    vo_head ||--o{ version : "the object's current heads"
    version ||--o{ node : "decomposed content (per version)"
    version ||--o{ vo_attestation : "appended attestations"
    template_ref ||--o{ version : "template identity (FK)"
    template_store ||--|| template_ref : "registers"
    ehr ||--o{ ehr_folder : "folder hierarchies (rank order)"
    ehr ||--o{ item_tag : "ITEM_TAGs"

    version {
        text tier PK "hot | cold, the partition key"
        uuid vo_id PK
        int sys_version PK "opaque commit ordinal"
        text kind "COMPOSITION | EHR_STATUS | ..."
        uuid ehr_id FK "NULL for parties"
        int trunk_version "VERSION_TREE_ID part 1"
        int branch_number "0 = trunk"
        int branch_version "0 = trunk"
        timestamptz committed_at "validity is derived from it"
        text lifecycle_state "532/553/523/800/801"
        text creating_system_id "OBJECT_VERSION_ID middle segment"
        text preceding_version_uid
        text signature "VERSION.signature, 0..1"
        jsonb wrapped_original "IMPORTED_VERSION discriminator"
        text body "canonical JSON bytes, lz4"
    }
    vo_head {
        uuid vo_id PK
        int head_sys_version "latest_version, any lineage"
        int trunk_head_sys_version "LATEST_VERSION"
        text lifecycle_state "of the trunk head"
        text tier "hot | cold"
        timestamptz archived_at
        timestamptz restricted_at
        timestamptz retention_hold_at
    }
    node {
        text tier PK "hot | cold, the partition key"
        uuid vo_id PK
        int sys_version PK
        int num PK "pre-order number, root = 0"
        int num_cap "subtree = num..=num_cap"
        int parent_num
        text rm_type
        text archetype "case-folded"
        text name_code "promoted name/defining_code"
        text path "materialized, COLLATE C"
        jsonb data "what no column holds, NULL when nothing remains"
        timestamptz context_start "promoted, COMPOSITION root only"
        text value_type "typed ELEMENT value"
        numeric value_num
        text value_text
        text value_code
        text value_terminology
    }

Supporting tables not drawn above: stored_query (stored AQL, qualified name plus SemVer), archetype_store and adl2_artefact (the two DEFINITION dialects), and the restriction and retention registers. The ehr table itself carries the three creation-immutable values the RM names (system_id, id, time_created) plus promoted copies of the current EHR_STATUS subject reference and is_queryable / is_modifiable flags, which back the one-EHR-per-subject rule, the AQL full-population gate, and the content-write guard without probing a JSON root per request.

Versioning: an append-only table and one mutable head row

Most CDRs split storage into a “current” table and a “_history” table. FerroEHR does not, and it does not carry a validity interval either.

  • version is written once. A version row and its node rows are never updated after commit, which is the property BASE architecture_overview master07-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 (RM common change 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 greatest committed_at at or before the instant”, one descending index probe.
  • vo_head is the one mutable row per object, and the only row a commit updates. trunk_head_sys_version IS LATEST_VERSION (the RM’s latest_trunk_version); head_sys_version is its latest_version across 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_VERSIONS is the unfiltered table; LATEST_VERSION is 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 as vo_id + creating_system_id + the trunk_version/branch_number/branch_version triple and held unique together. sys_version is deliberately not that number: it is an opaque per-object commit ordinal (1..n across trunk and branch commits) used as the join key for node and vo_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 in wrapped_original, while the row’s own contribution and audit columns record the local act of committal. NULL there means a locally created ORIGINAL_VERSION; NOT NULL means the row is an IMPORTED_VERSION.

One valid version per lineage at any instant now holds by construction rather than by any constraint: a version is superseded exactly when a later commit ordinal exists under the same branch number, and a fork onto a branch carries a branch number of its own, so it does not supersede the trunk.

flowchart TD
    subgraph one_object ["one versioned object (vo_id)"]
        v1["sys_version 1<br/>1.0.0 (trunk)<br/>committed_at t1"]
        v2["sys_version 2<br/>2.0.0 (trunk)<br/>committed_at t2"]
        v3["sys_version 4<br/>3.0.0 (trunk)<br/>committed_at t3"]
        b1["sys_version 3<br/>2.1.1 (branch tip)<br/>committed_at t2b"]
        head["vo_head<br/>trunk_head_sys_version 4<br/>head_sys_version 4"]
        v1 --> v2 --> v3
        v2 -.->|branch 1| b1
        head -.->|LATEST_VERSION| v3
    end

Content decomposition: the node table

At commit, the accepted composition is decomposed into one row per RM structure node. Each row holds the node’s canonical openEHR JSON fragment (the ITS-JSON encoding) with its structure children pruned out: no alias compaction, no synthetic fields. A row stores only the part its own columns do not hold. _type is the rm_type column, a lowercase archetype_node_id is archetype, a plain or simply coded name is name with its code columns, and an ELEMENT value in one of the common basic shapes (a quantity, a count, an ordinal, a coded or plain text, a date, time or duration, an identifier, a boolean, a URI) sits in typed value columns. Everything else stays in node.data verbatim, and data is empty on two rows in three. The database functions ext.node_fragment, ext.node_name and ext.node_value put the omitted keys back, so every reader, AQL included, sees exactly the fragment the API would serve. A filter or projection on a quantity’s magnitude or a code reads the typed column directly.

A party body decomposes the same way. The party root, each PARTY_IDENTITY, CONTACT, ADDRESS and CAPABILITY nested in it, and the ITEM_STRUCTURE under each get their own row, so a predicate over a contact address reaches it by the same interval join a clinical predicate uses.

The tree shape is captured as a nested-set interval: nodes are numbered in pre-order (num, root = 0), and each row records the maximum number in its subtree (num_cap). “B is contained in A” is then the integer test A.num < B.num AND B.num <= A.num_cap, which makes AQL CONTAINS chains plain integer range joins instead of JSON tree walks.

flowchart TD
    c["COMPOSITION<br/>num 0, cap 5"] --> s["SECTION<br/>num 1, cap 5"]
    s --> o1["OBSERVATION<br/>num 2, cap 3"]
    o1 --> e1["ELEMENT<br/>num 3, cap 3"]
    s --> o2["EVALUATION<br/>num 4, cap 5"]
    o2 --> e2["ELEMENT<br/>num 5, cap 5"]

For the tree above, “OBSERVATIONs inside the SECTION” is section.num (1) < obs.num AND obs.num <= section.num_cap (5): rows 2 and 4 qualify by arithmetic alone.

Beside the interval, each row promotes the predicates AQL actually filters on, so hot paths never open the JSON:

  • rm_type (full RM type names, never compacted), name, archetype (case-folded at write, because openEHR identifier equality is case-insensitive);
  • the archetype-subsumption columns arch_entity / arch_concept / arch_major, parsed from full archetype HRIDs so a query naming a parent archetype matches specialised children through an indexed prefix scan (the major-version boundary stays hard, as the AM requires);
  • citem_num, the nearest archetyped ancestor, for archetype-anchored path resolution;
  • context_start, the promoted EVENT_CONTEXT.start_time on COMPOSITION roots, serving ordering from two partial indexes: one per EHR for a dashboard, one over the whole population for a query that orders every composition and takes the first page;
  • path, the materialized path from the root (COLLATE "C", so byte order equals tree order), used only for reassembly, never as an AQL predicate.

The write path: one transaction per commit

Every write realizes the openEHR contribution rule: a CONTRIBUTION lists the affected VERSIONs and carries its own audit, and it commits only if every member commits. In storage terms, one transaction per service-level write:

sequenceDiagram
    participant R as REST adapter
    participant S as service layer
    participant PG as PostgreSQL 18

    R->>S: commit (COMPOSITION, EHR_STATUS, ...)
    S->>S: validate (RM invariants, WebTemplate, terminology)
    S->>PG: BEGIN
    S->>PG: advisory lock on vo_id (serializes the lineage)
    S->>PG: INSERT commit_audit (change_type, committer, time_committed = now())
    S->>PG: INSERT contribution (commit_audit_id, ehr_id)
    S->>PG: INSERT version (new tip, committed_at = now(), body bytes)
    S->>PG: UPSERT vo_head (the object's new heads) — heap-only
    S->>PG: INSERT node rows (decomposed fragments, nested-set numbers)
    S->>PG: COMMIT
    S-->>R: OBJECT_VERSION_ID of the new version

Details that matter:

  • time_committed is 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.body are materialized from the accepted, uid-stamped value before decomposition, stored as text (not jsonb, which would re-order keys) so a point read serves the canonical _type-first field order verbatim.

Read paths

Point reads (GET composition, EHR_STATUS, a named version) resolve the version row and serve version.body verbatim: one detoast, no re-aggregation, zero translation between storage and wire.

AQL plans over node, with one exception: a whole-object projection loads the matching body rows in a batch instead of reassembling fragments. CONTAINS chains become nested-set interval joins, class and archetype predicates hit the promoted columns and their indexes, a COMPOSITION’s archetype_details/template_id/value compares the indexed version.template_id, a typed ELEMENT value’s magnitude, units, text or code reads its typed column, other leaf values are extracted from the rebuilt fragments with jsonb_path_query_first, arrays are unnested with jsonb_path_query as a lateral set-returning function, and comparison and ordering go through ext.openehr_magnitude (the IMMUTABLE helper realizing DV_ORDERED ordering semantics) and ext.openehr_timestamp (STABLE, because its result depends on the session time zone). The engine uses no jsonpath item methods, no JSON_TABLE and no GIN index. The whole pipeline has its own page.

Time travel (a version at a point in time) is the trunk row with the greatest committed_at at or before the instant, served by a descending index on the same one table.

The archival tier is a partition

version, node and vo_attestation are each PARTITION BY LIST (tier) with a hot and a cold partition and no default partition. Archiving an EHR is one statement — UPDATE version SET tier = 'cold' WHERE ehr_id = $1 — and PostgreSQL moves the rows between partitions; the node and vo_attestation foreign keys carry their rows across with ON UPDATE CASCADE. Restore is the reverse statement. Archiving never merges the two pseudonymisation domains: each has its own partitions.

The consequences are deliberate and visible:

  • foreign keys hold across the tier, which a separate mirror table could never do, so an archived version still references its contribution and its commit audit;
  • every read path reaches cold by naming the parent relation, so an archived object stays retrievable in one statement with no union view, no primary-miss retry and nothing to rebuild when a column is added;
  • AQL stays hot-only: the emitter writes tier = 'hot' as a literal, which PostgreSQL prunes at plan time, so archived content leaves the queryable store until it is restored;
  • a write to an archived object thaws it back to the hot tier first, so a versioned object is never split across tiers;
  • the cold partitions carry the primary key alone, because nothing queries them, and can sit on a cheaper tablespace.
flowchart LR
    subgraph version ["version — PARTITION BY LIST (tier)"]
        vh[(hot)]
        vc[(cold)]
    end
    subgraph node ["node — PARTITION BY LIST (tier)"]
        nh[(hot)]
        nc[(cold)]
    end
    vh -- "UPDATE version SET tier = 'cold'" --> vc
    vc -- "the reverse statement" --> vh
    nh -. "carried across by ON UPDATE CASCADE" .-> nc
    aql["AQL: tier = 'hot' — pruned at plan time"] --> vh
    aql --> nh
    point["point read: the parent relation, either tier"] --> version

No openEHR spec governs archival tiers; this is FerroEHR’s own design.

Why this design

The shape follows documented PostgreSQL behaviour rather than habit:

  • JSONB has no documented partial detoast: a big single-document design pays whole-document decompression for every leaf access. Decomposed fragments are small enough to stay under the TOAST threshold, so an AQL leaf access touches only the rows it needs. A point read takes the other route on purpose: it serves the whole body in 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_VERSIONS a 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 UPDATE that changes a partition key moves the row, so the tier becomes a property of a row in one relation instead of a second relation with no foreign keys and a union view over it.

Four comments in the baseline migration cite measurements (a POC-window p99, the share of node rows carrying at-code archetype text, the average fragment size, an index-order profile) whose records live on closed tracker issues rather than in a committed artifact. They are historical notes on the decisions, not claims this page makes; a number reaches this site only through a generated include over a committed record.

The performance this buys is measured, not asserted: see Performance for the earned deployment classes and the committed measurement records behind them.

The AQL engine

How FerroEHR turns an AQL query into SQL, and why the result is fast. This page is about the engine’s internals; for writing queries against the API, see Querying with AQL.

The language is openEHR AQL 1.1. openEHR defines the language, not its execution, so everything after the parse is FerroEHR’s own design.

The pipeline

One query passes through six stages. Each stage has a single job, a typed output, and a typed refusal: a construct the engine does not support is rejected with an error naming the QUERY specification section it comes from, never answered approximately.

flowchart LR
    q["AQL text"] --> lex["lexer<br/>(logos)"]
    lex --> parse["parser<br/>(chumsky)"]
    parse --> ast["AST"]
    ast --> an["path analysis + typing<br/>(generated RM model)"]
    an --> ir["typed query IR<br/>(cached per query text)"]
    ir --> sql["SQL builder<br/>(sea-query)"]
    sql --> pg[("PostgreSQL 18")]
    pg --> rs["RESULT_SET"]

Lexer and parser

The front end is a hand-written crate (openehr-query) with no ANTLR runtime: a logos tokenizer transcribed from the official AqlLexer.g4 grammar and a chumsky parser that builds one AST type per AqlParser.g4 rule. AQL keywords are case-insensitive; quoted temporal literals stay strings at this layer because the QUERY specification resolves their typing from the path context, not from the literal. The crate stops at the AST and is validated against the grammar’s own example corpus.

Path analysis and typing

The planner types every identified path (c/content[...]/data/events[...]/...) against a generated RM attribute model: the same code generator that produces the RM types from openEHR’s machine-readable meta-model also emits a static table of every class’s attributes, their declared types, containers and cardinalities, plus the abstract-to-concrete descendant sets. Path resolution is therefore a table lookup, not runtime reflection and not a hand-maintained list, and it cannot drift from the RM: regenerating the spec layer regenerates the oracle.

This stage answers, per path step: which RM classes can this step land on, is it a structural hop or a data leaf, what value type does the leaf carry, and which archetype predicates bound it.

The typed IR

Analysis and lowering produce a typed query intermediate representation. The IR carries no SQL and bakes in no request state: no parameter values, no paging window, no EHR scope. That purity is deliberate. Because the IR is a pure function of the query text, the query service caches lowered plans keyed on that text, so a stored query is planned once, not once per call; per-request parameter checking runs separately against the cached plan.

SQL generation

The IR lowers to one SELECT built entirely with sea-query’s typed expression API: no string-concatenated SQL anywhere, every literal bound as a parameter. The shapes it emits are where the storage design pays off:

  • CONTAINS is integer arithmetic. The node store keeps a nested-set interval per RM node: “B inside A” is A.num < B.num AND B.num <= A.num_cap. A CONTAINS chain becomes a chain of integer range joins over one table. No JSON is opened to answer containment. The interval is strict: a node is never its own parent, so CLUSTER CONTAINS CLUSTER reaches nested clusters only, and NOT CONTAINS holds for a node with no such descendant.
flowchart LR
    subgraph aql ["AQL"]
        contains["COMPOSITION c<br/>CONTAINS OBSERVATION o"]
    end
    subgraph sql ["generated SQL (shape)"]
        join["JOIN node o<br/>ON o.vo_id = c.vo_id<br/>AND c.num &lt; o.num<br/>AND o.num &lt;= c.num_cap<br/>WHERE o.rm_type = 'OBSERVATION'"]
    end
    contains --> join
  • A path step is one attribute. o/data/events reaches the EVENT nodes that are the events of the HISTORY that is the data of o: a direct child, through that attribute, never the node itself, a deeper descendant, or a sibling attribute of the same type.
  • A projected path binds its nodes once per row. Every structure step a SELECT path descends through is bound as if it were a FROM variable: one row per matching node, one row with null cells where nothing matches. Paths that share a prefix share its binding, so two values read below one EVENT always come from the same event, and paths through different repeated steps give every combination of their nodes. WHERE and ORDER BY read through those bindings.
  • The WHERE clause binds its own steps once. Every repeated step a WHERE path descends below the projected bindings is bound once for the whole clause, as a SELECT step is (one node per binding, one null binding when nothing matches), and the clause becomes one EXISTS over the assignments of those bindings: a row is kept when some assignment makes the clause true under three-valued logic, so AND, OR and NOT keep their boolean laws per assignment. Elements of a list attribute inside one node’s data are not bindings; a test of one holds when any element satisfies it and is unknown over an empty list. EXISTS <path> is path-level: it reads through the projected bindings and checks the rest of the path in its own EXISTS, outside the clause’s assignment. An ORDER BY key below the bindings takes the first match in document order.
  • A variable of an OR branch binds like a projected step. A branch operand a path reads is bound under the object the branches share: one row per node that matches the branch, one row with null cells where it does not, and two branches that both match pair up as two independent steps do. A branch that is its own versioned object (a COMPOSITION directly under the EHR, or one a FOLDER references) binds its node together with its version row, in the scope the same object has outside the OR: the latest version under LATEST_VERSION, never a deleted or restricted one, with its uid read from that version.
  • An aggregate binds nothing. It reads every item of its path per row in a correlated subquery and combines the rows, so COUNT(*) beside it is unaffected; inside that subquery the WHERE clause is evaluated again with the steps the item shares fixed to its own nodes, so an item counts when some assignment of the remaining bindings makes the clause true, the items a plain projection of the path would keep. Elements of a list attribute inside one node are not filtered one by one: a row the clause keeps contributes every element.
  • Class and archetype predicates hit promoted columns. rm_type, the parsed archetype identifier columns and names are plain indexed columns; a predicate naming a parent archetype matches specialised children through an indexed prefix scan.
  • Leaf values come out through SQL/JSON path functions. Data values are extracted from the canonical node fragments with jsonb_path_query_first, and arrays are unnested with jsonb_path_query as a lateral set-returning function. Ordering and comparison on clinical magnitudes use openehr_magnitude, an IMMUTABLE helper realizing DV_ORDERED ordering semantics; date/time comparison uses openehr_timestamp, which is STABLE because its result depends on the session time zone. The engine uses no jsonpath item methods and no JSON_TABLE.
  • Version scope is a predicate, not a join through history tables. LATEST_VERSION is a partial-index predicate on the one temporal version table; ALL_VERSIONS is 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:

  1. 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.
  2. 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.
  3. Plans are cached. The IR is request-independent and cached on query text; repeated and stored queries skip lexing, parsing and typing.
  4. PostgreSQL 18 does the heavy lifting. B-tree skip scan, OR to = ANY rewriting, self-join elimination, asynchronous I/O and the SQL/JSON function family are exactly the features the generated SQL leans on.
  5. Everything is measured, nothing declared. The performance FerroEHR publishes comes from committed, re-checkable measurement records under the conformance instrument: see Performance and Benchmarks. This page explains the design; those pages carry the numbers.

Using the API

FerroEHR exposes the openEHR REST API (ITS-REST Release-1.1.0, the version the server reports and is conformance-tested against): a resource-based HTTP interface for creating EHRs, committing and retrieving versioned clinical documents, managing folders and contributions, and running queries. This part is the practical reference for client developers: the resources and their operations, the headers that drive versioning and content negotiation, and the error contract. For the complete endpoint reference (every path, parameter, and schema), open the Swagger UI of the live sandbox at https://sandbox.ferroehr.eu/ferroehr/rest/swagger-ui and sign in with the public demo credentials ferroehr / ferroehr. That server generates the document from its own handlers, so it describes the running release and its “Try it out” buttons issue real requests. The sandbox’s landing surface, https://sandbox.ferroehr.eu, is the viewer over the same server and takes the same credentials. This book explains how to use the API.

Base path

The openEHR REST API hangs off a configurable base path, which defaults to:

/ferroehr/rest/openehr/v1

Only what openEHR ITS-REST 1.1.0 defines is served there. FerroEHR’s other interfaces sit beside it, under the same REST root:

NamespaceDefault pathHolds
openEHR REST API/ferroehr/rest/openehr/v1the ITS-REST 1.1.0 routes: EHR, composition, directory, contribution, demographic parties, definition, query, the admin EHR deletes
FerroEHR extensions/ferroehr/rest/extensions/v1FerroEHR’s own routes, on their own version line: terminology, event subscriptions, party relationships, the archetype library, EHR Extract messages, the cohort query and the admin extensions
FHIR and IHE/ferroehr/rest/fhir/r4the IHE ITI-81 AuditEvent retrieval

Every path in these chapters is relative to the openEHR base unless the page says otherwise. So “POST /ehr” means POST http://your-host:8080/ferroehr/rest/openehr/v1/ehr. The base path is set with FERROEHR__REST__BASE_PATH, and it may be shortened as far as /ferroehr/v1 (see Shortening the REST base path).

The status and documentation routes hang off the REST root, which the server derives from the base path by dropping the segments that name the openEHR API: /ferroehr/rest by default. The public, unauthenticated status probe is at /ferroehr/rest/status and interactive docs at /ferroehr/rest/swagger-ui at the access level swagger_ui names (by default any authenticated principal, so a browser prompts for the API credential; the documents list the whole enabled operation surface). Every deployment serves that UI from its own routes, so your own server always documents its own surface. The health probes stay at the process root whatever the base path is: /health, /health/liveness, /health/readiness.

Capability discovery

An OPTIONS request to the API base path returns the server’s conformance manifest: the product name and version, the vendor, the openEHR REST API version it implements, the conformance profile it claims, and the API groups this deployment actually mounts.

curl -X OPTIONS -i \
  http://localhost:8080/ferroehr/rest/openehr/v1

The response carries an Allow header and a JSON body:

{
  "solution": "FerroEHR",
  "solution_version": "…",
  "vendor": "Cadasto B.V.",
  "restapi_specs_version": "1.1.0",
  "conformance_profile": "…",
  "endpoints": ["/ehr", "/definition", "/query", "/demographic"]
}

Two things to know before you build discovery on it:

  • endpoints is the live set, not a fixed list: /admin appears only when the admin API is enabled. It covers the standardised openEHR groups only; FerroEHR’s own extension families (health, management, messaging, item tags, the archetype-source routes) declare themselves through the served OpenAPI document instead.
  • The identity fields are configurable ([rest.identity], see Server, database & telemetry); their defaults are the build’s own provenance and the last machine-computed conformance verdict, so an unmodified deployment never over-claims.

Note

The manifest lives at the API base path and nowhere else. A bare / alias existed in earlier versions and was removed. Point discovery at the base path.

Authentication

Requests are authenticated unless auth is explicitly disabled. Two mechanisms ship:

  • HTTP Basic: a configured user store; send Authorization: Basic …. The examples in this book use -u user:password with 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”:

SituationStatusWhat it means
No Authorization header401with 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 rejected401the challenge carries error="invalid_token": expired, revoked, malformed (a bearer token outside the RFC 6750 §2.1 b64token alphabet included), or simply wrong
Authorization header malformed400two Authorization headers, or a header that is no scheme followed by a space and a credential. The server never got as far as a credential, so this is a request defect (error="invalid_request")
Authenticated, not permitted403for a bearer caller the challenge carries error="insufficient_scope", naming what is missing
The token issuer is unreachable503with 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 Prefer header, ETag/If-Match optimistic 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: the VERSIONED_EHR_STATUS object,
  • 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. PUT takes the object uuid (the versioned object), while DELETE takes the full version id (the version you are superseding). GET accepts either.

Note

A version’s lifecycle state (set through the openehr-version: lifecycle_state.code_string header; see Content negotiation & errors; the default on a commit is 532|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 the 801|abandoned| state cannot be updated straight to complete: you must first retrieve it back to 553|incomplete|, then complete it.

Two further rules apply to the state a commit may claim:

  • 523|deleted| belongs to DELETE alone. Deleting is one act (a new version whose data is removed and whose state is deleted), so a PUT or POST that carries content may not claim it. Such a request is rejected 422. Conversely, a DELETE that supplies a lifecycle other than 523|deleted| is rejected 400: the value would have to be discarded, and the server tells you rather than pretending to honour it. A DELETE with no lifecycle header at all is the normal case and is unaffected.
  • 553|incomplete| relaxes what a commit must contain. Content committed as incomplete may leave mandatory attributes absent and 1..* 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. The EHR_STATUS resource is the one exception: it does not accept the incomplete state.

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; requires If-Match; 200/204.
  • DELETE /ehr/{ehr_id}/directory: logical delete; requires If-Match; 204.
  • GET /ehr/{ehr_id}/directory: the current folder tree, optionally filtered by ?version_at_time= and ?path= (slash-separated folder names). 204 if deleted at that time.
  • GET /ehr/{ehr_id}/directory/{version_uid}: a specific version, optionally ?path=.

Folder items are OBJECT_REFs, and the server validates the ones that claim this system: a reference whose namespace is local (or the server’s configured system id) must resolve to a versioned object in that EHR, or the commit is refused with 422 naming each unresolvable reference at its tree path. References into other namespaces (another system’s id, or unknown) are stored verbatim without a resolvability check, since openEHR object references are explicitly allowed to point outside the current system. The same rule applies to folder hierarchies committed through the CONTRIBUTION endpoint.

CONTRIBUTION

A CONTRIBUTION is an atomic change-set: a group of versioned-object changes (compositions, statuses, folders) committed together with one shared audit. Use it when several changes must land as a unit.

curl -u ferroehr:ferroehr \
  -H 'Content-Type: application/json' \
  --data-binary @contribution.json \
  http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/contribution

POST /ehr/{ehr_id}/contribution takes a contribution whose versions array each describe a change (the RM object, its change_type, its lifecycle_state, and per-version commit_audit) plus a shared audit. It returns 201 with the contribution id in ETag, or 400/404/409/422 on invalid input, unknown EHR, a uid conflict, or a change set that is well-formed but cannot be followed.

Five things about the payload are worth calling out:

  • The shared audit must carry its own change_type and committer. They are your account of the change set as a whole and are never derived or invented by the server. A contribution without an audit, or whose audit omits either one, is refused with a 400. The server fills in time_committed, and system_id when you do not supply one.
  • Every commit_audit names its own committer, an attestation included. The server never copies the shared audit’s committer into a version that leaves it out: that version is refused with a 400 naming its index. A committer that is not a PARTY_PROXY object at all, such as a bare string, is a 400 too; one that is a PARTY_PROXY but breaks its own rules, such as a PARTY_IDENTIFIED with no name, identifiers or reference, is a 422. A version’s committer is stored as you send it, even when it differs from the shared audit’s. A version that omits system_id gets the shared audit’s, and every audit in the contribution carries the same time_committed.
  • lifecycle_state is required on every version and is not defaulted. Omitting it is a 400, on an attestation entry (see below) as well, even though an attestation commits no new version. A code outside the openEHR version lifecycle state group is a 422.
  • A version entry carries exactly the six declared members (preceding_version_uid, signature, lifecycle_state, attestations, data, commit_audit) plus an optional _type self-tag. Anything else is refused 400 naming the offending key and its index, never silently ignored.
  • other_input_version_uids and item are not accepted on a commit. Merge provenance is read-only (it appears on ORIGINAL_VERSION reads and is preserved when a version arrives through an EHR-Extract import or an archive load), and item is the shape of an imported version, which only the import route produces. Either one on a version entry is a 400.

A commit_audit may instead be an ATTESTATION: set its _type to ATTESTATION (or the wire form UPDATE_ATTESTATION) and add reason (required) plus is_pending (required), and optionally proof, items and attested_view. This is how content is committed already signed, or marked as awaiting signature (is_pending: true). A coded reason must be a member of the openEHR attestation reason group, and items, when present, must be non-empty. The attestation is stored as part of that version’s commit audit and read back on the version envelope, in the revision history, and in exports. A description may be a plain string, a DV_TEXT, or a DV_CODED_TEXT; a coded description keeps its defining_code.

An attestation entry signs or reviews a version that is already stored, without committing a new one. Give it the change type 666 (attestation), name the attested version in preceding_version_uid, and leave data out. Its commit_audit is the UPDATE_ATTESTATION itself, with its own committer, reason and is_pending. The entry still needs a lifecycle_state, which the server checks against the version lifecycle state group (a 422 when it is outside it) but does not store, because no new version carries it.

GET /ehr/{ehr_id}/contribution/{contribution_uid} returns 200 with the contribution, or 404. Add Prefer: resolve_refs to get full VERSION objects instead of OBJECT_REFs (see Content negotiation & errors).

GET /ferroehr/rest/extensions/v1/ehr/{ehr_id}/contribution (no uid) lists the EHR’s contributions, newest first. It is a FerroEHR extension (the openEHR REST API defines only the by-uid read), so it sits under the extensions root beside the openEHR API. Paginate with ?offset= (default 0) and ?fetch= (default 20, capped at 100). It returns 200 with a JSON summary, or 404 for an unknown EHR:

{
  "rows": [
    {
      "uid": "…",
      "time_committed": "…",
      "committer": "…",
      "change_type": "251",
      "change_type_rubric": "amendment"
    }
  ],
  "total": 123
}

committer is the audit committer’s name only; the by-uid read returns the full PARTY_PROXY. change_type is the openEHR audit-change-type code and change_type_rubric its display rubric from the same terminology the by-uid read uses, so a client never maps codes locally. total counts all of the EHR’s contributions, not just the returned window.

Note

The contribution envelope is canonical JSON only: openEHR publishes no CONTRIBUTION XML document, so an XML Accept on these routes is a 406 and an XML Content-Type a 415. The FLAT and STRUCTURED formats, when used, apply only to the inner composition data of 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 optional tag_key, tag_value and tag_target_path filters 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}/tags and the same pair under …/ehr_status/{uid_based_id}/tags — read or replace the addressed collection. The PUT body is a bare JSON array of tags (key required, value and target_path optional; target and owner_id come 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. PUT answers 204 by default, 200 with the resulting list under Prefer: 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

CodeMeaning across these resources
200Retrieved, or updated with Prefer: return=representation / return=identifier.
201Created (EHR, composition, directory, contribution).
204Success with no body (return=minimal), or deleted / deleted-at-time.
400Malformed request, missing required header/parameter, an undeclared payload member, or already-deleted.
404Unknown EHR, object, version, or no version at the requested time.
405Method not allowed on this resource (always with an Allow header), or the resource is switched off by configuration.
406The Accept header names no representation this resource has.
409Conflict: 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.
412If-Match did not match the latest version (current id returned in ETag).
415The request Content-Type names a format this resource cannot process.
422Well-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.
429Too many requests, with Retry-After and the x-ratelimit-* headers the limiter computed.
503The 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

FerroEHR speaks canonical JSON and canonical XML for the RM-typed resources. Choose with the standard HTTP headers:

  • Request body: set Content-Type: application/json or application/xml (text/xml works the same as application/xml).
  • Response: set Accept: application/json or application/xml. With no Accept, 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:

LineageRoot namespaceStatus
v1http://schemas.openehr.org/v1The schema release openEHR labels stable, frozen against an older Reference Model
v2 (default)http://schemas.openehr.org/v2The 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 stays Content-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:type and 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 on Accept and 415 Unsupported Media Type on Content-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=1 and 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 version parameter 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 always application/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 typeMeaning
application/openehr.wt.flat+jsonFLAT — one flat JSON object of path: value pairs
application/openehr.wt.structured+jsonSTRUCTURED — the same data as nested JSON
application/openehr.wt+jsonA 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 matching Accept.
  • 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 via Accept. The example party of a demographic template comes in canonical JSON or XML only.
  • Template definitions: GET …/definition/template/adl1.4/{id} with Accept: application/openehr.wt+json returns the Web Template document. Accept: application/json returns the same document (it is the only JSON representation of a template) under Content-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-id request header is required; the commit is rejected with 422 without it.
  • There is no ?format= query parameter: format selection is done exclusively through the standard Accept and Content-Type headers.

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 valueEffect
return=minimal (default)Empty body; the identifier is in ETag/Location. Status 204 on update, 201 on create.
return=representationThe full created/updated resource in the body, status 200/201.
return=identifierJust 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 ETag header carrying the object or version identifier as a weak ETag, W/"…".

  • Updating or deleting a versioned object requires an If-Match header set to the current version id. Both the weak form and a bare quoted value are accepted; echoing the ETag you 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 VERSION read (…/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 create 201;
  • 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 either Prefer setting; with return=minimal there 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 in If-Match exactly as received) and it works the same for trunk and branch versions. ALL_VERSIONS queries 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 contribution and commit_audit are this server’s act of importing: our system id, the importing user, the instant the import landed here, change type 249|creation|;
  • item is the received ORIGINAL_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_VERSION has no uid of its own. It shares the wrapped version’s identity, so read the version id from item.uid.value. The ETag still carries that id, so the If-Match round 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, inside item.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:

HeaderAttributeSub-keys
openehr-versionlifecycle_statecode_string
openehr-audit-detailschange_typecode_string
openehr-audit-detailsdescriptionvalue (or a bare value)
openehr-audit-detailscommittername, external_ref.id, external_ref.namespace, external_ref.type (defaults to PERSON)
openehr-audit-detailssystem_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 bare openEHR-AUDIT_DETAILS). The specification keeps them available for backward compatibility and so does this server, so an older client keeps working. If both forms appear, the value-form header above wins.

Item tags via headers

Item tags (small key/value annotations, optionally pointing at a node inside the data via target_path) can ride the same request as a write, so tagging does not need a second round trip (the dedicated endpoints are in Resource walkthroughs). Two headers carry them:

  • openehr-item-tag: tags targeting the versioned object;
  • openehr-version-item-tag: tags targeting the version being committed.

The value is a ;-separated list of tags, each a comma-separated set of key="…", value="…", and optional target_path="…" pairs:

openehr-version-item-tag: key="diagnosis",value="confirmed",target_path="/content[0]"; key="reviewed",value="true"

They are accepted on the EHR-group change-controlled writes (composition create/update, EHR_STATUS update, directory create/update) and on demographic party writes. Sending the header with an empty value removes all tags. A defective tag refuses the whole request before the content is committed, so a bad tag never leaves a half-applied write behind.

The two headers address different targets, so the response echo keeps them apart: openehr-item-tag confirms the tags now stored on the versioned object, openehr-version-item-tag those stored on the version just committed, and a header you did not send is not echoed at all. (Demographic parties store tags against the versioned object only, so both headers carry the same list there.)

Error responses

Errors use conventional HTTP status codes (see the summary in Resource walkthroughs) with one body shape on every openEHR and extension route: RFC 9457 problem details, media type application/problem+json. The body is JSON, so a client that sends Accept: application/json reads it as well. The FHIR routes under /ferroehr/rest/fhir/r4/ answer a FHIR OperationOutcome instead.

{
  "type": "https://ferroehr.eu/docs/latest/using-the-api/problems.html#validation-failed",
  "title": "The content fails validation",
  "status": 422,
  "detail": "the content fails validation at 2 path(s); a request with `Prefer: return=representation` receives each path and its violation",
  "instance": "urn:uuid:0190a6f3-7c1e-7d7a-9f43-1c5b8a2e4d10"
}
  • type names the problem. A refusal you can act on has a FerroEHR type, documented on Problem types; every other error is about:blank, whose title is the status phrase, such as Not Found.
  • title is the same for every occurrence of a type; detail explains this occurrence.
  • status repeats the HTTP status.
  • instance is the server’s request id, the one the X-Request-Id response header and the server log carry, so an operator can find the request.

The openEHR REST specification lets a server return additional error details when the request carries Prefer: return=representation. FerroEHR does: with that header, a problem carries its extension members, and the response says Preference-Applied: return=representation.

  • validation-failed lists every violation in errors, as {"path": …, "detail": …}, so a client can point the user at the exact offending node:

    "errors": [
      { "path": "/content[0]/data/events[0]/data/items[1]/value/magnitude", "detail": "value out of range" },
      { "path": "/content[0]/data/events[0]/data/items[2]/value/defining_code", "detail": "code not in group" }
    ]
    

    A refusal from the data-minimisation rules uses the same list, with one entry per finding, and deliberately never echoes the value that matched: repeating it would carry the identifier into the response body, the access log and the traces.

  • aql-invalid gives the byte range of the AQL text it points at as position, {"start": …, "end": …}.

  • version-conflict names the latest version as current_version, the same value its ETag carries.

No problem carries a stack trace, SQL, a server path or a value from the store: a server fault is a 500 with a fixed detail.

Match on the HTTP status first, then on type; read detail for the human-readable explanation.

405 always names the allowed methods

Every 405 Method Not Allowed carries an Allow header listing the methods the target resource currently supports, as RFC 9110 §15.5.6 requires, so a client can discover the right method without guessing:

HTTP/1.1 405 Method Not Allowed
Allow: GET,HEAD,PUT
Content-Type: application/problem+json

{ "type": "about:blank", "title": "Method Not Allowed", "status": 405, "detail": "the request method is not allowed on this resource" }

Whether a method is served depends on the resource, never on your credential. With authentication on, an authenticated request with a method the resource does not serve gets this 405 whatever its token grants; the role and SMART scope checks apply only to a served method. A request without a credential is still 401.

When a resource is switched off by configuration (the admin API with FERROEHR__ADMIN__ENABLED=false), the header is present but empty, which RFC 9110 §10.2.1 defines as “the resource allows no methods”: nothing you can send to that path will be served until the gate is opened.

A method the server does not recognize at all is answered 405 as well (with Allow), not 501. The openEHR spec suggests 501 there, but the two rules it states overlap for any method outside its own list, and a blanket 501 would also mislabel requests to paths that simply do not exist and are owed a 404. 501 Not Implemented remains reserved for a recognized operation this server does not implement.

Problem types

Every error on FerroEHR’s openEHR and extension routes is an RFC 9457 problem details document (Content negotiation & errors). Its type is either about:blank, an error that means no more than its HTTP status, or one of the FerroEHR types below. Each type’s URI is this page at the type’s anchor, such as https://ferroehr.eu/docs/latest/using-the-api/problems.html#aql-invalid. The title is the same for every occurrence; the members marked “with Prefer: return=representation” appear only when the request carries that header.

aql-invalid

Status: 400 Bad Request. Title: The AQL query is not valid.

The AQL text does not parse, or the query engine refuses it (a construct outside the supported AQL, a path it cannot type, a parameter it cannot bind). Correct the query; detail says what was refused.

With Prefer: return=representation: position, {"start": …, "end": …}, the byte range of the query text a parse refusal points at.

query-timeout

Status: 408 Request Timeout. Title: The query ran longer than the server allows.

The query ran past query.timeout_ms and the server aborted it. Narrow the query (a tighter WHERE, a LIMIT, fetch and offset) or ask the operator to raise the limit.

validation-failed

Status: 422 Unprocessable Content. Title: The content fails validation.

The payload parsed but fails its template, the Reference Model, the terminology, or the data-minimisation rules. Correct the content at each path.

With Prefer: return=representation: errors, one {"path": …, "detail": …} per violation, keyed by the RM path of the offending node.

content-invalid

Status: 422 Unprocessable Content. Title: The content cannot be processed.

The payload is well-formed but cannot be processed, such as a template or an archetype the server cannot accept. detail names the rule.

conflict

Status: 409 Conflict. Title: The request conflicts with the resource’s current state.

The resource already exists (an EHR for the subject, a composition with the given id) or the write conflicts with a concurrent one. Read the resource and decide whether to retry.

version-conflict

Status: 412 Precondition Failed. Title: The version named is not the latest.

The If-Match header names a version other than the latest, so another write came first. The ETag header carries the latest version; read it, merge, and write again against it.

With Prefer: return=representation: current_version, the latest version, the same value as the ETag.

unauthenticated

Status: 401 Unauthorized. Title: The request needs credentials.

The request carried no credentials, or credentials the server did not accept. The WWW-Authenticate header says which scheme to use.

forbidden

Status: 403 Forbidden. Title: The caller may not perform this operation.

The caller is authenticated, and the access policy (roles, SMART scopes, EHR access, assurance level) refuses the operation. detail names the missing grant.

processing-restricted

Status: 403 Forbidden. Title: Processing of this record is restricted.

The record carries a restriction of processing (GDPR Art. 18), so the server keeps it stored and refuses other processing until the restriction is lifted.

unavailable

Status: 503 Service Unavailable. Title: The server is temporarily unavailable.

The server or its database is overloaded or draining. Retry after the Retry-After header’s delay.

rate-limited

Status: 429 Too Many Requests. Title: The caller sent more requests than its rate allows.

The caller passed [server.caller_rate]. Retry after the Retry-After header’s delay.

Querying with AQL

The Archetype Query Language (AQL 1.1) is how you read data out of FerroEHR. Instead of querying hidden database tables, you query the clinical model directly: you name the RM types and archetypes you want, express structural nesting with CONTAINS, and select values by their path within an archetype. The same query runs unchanged on any conformant openEHR system. This chapter is a practical walkthrough: the language, how to run queries over HTTP, parameters, stored queries, version scope, terminology, pagination and limits, and the supported feature envelope.

The shape of a query

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
  • FROM binds 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_pressure matches …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: .v1 never matches .v2 data.

  • CONTAINS expresses structural containment: “an EHR that contains a composition that contains a blood-pressure observation”. Chains can nest several deep, and combine with AND, OR, and NOT. Folder containment follows the RM’s reference model: FOLDER f CONTAINS COMPOSITION c matches the compositions a folder’s items reference, transitively over the folder’s sub-tree, and FOLDER f1 CONTAINS FOLDER f2 matches strict sub-folders plus the folders of a versioned folder the items reference — one reference hop, so a chain of references is expressed by chaining CONTAINS. A pair the RM defines no containment relationship for (say COMPOSITION CONTAINS COMPOSITION) is refused with a typed error. Containment is strict: a node does not contain itself, so CLUSTER cl1 CONTAINS CLUSTER cl2 binds only clusters nested inside another cluster. A variable declared inside an OR branch can be read in SELECT, WHERE, and ORDER BY: a row where its branch did not match reads null for it, and when both branches match, each match of one pairs with each match of the other. This holds for a branch that is a versioned object of its own too: EHR e CONTAINS (COMPOSITION c1[...] OR COMPOSITION c2[...]) returns the current version of each matching composition, its c1/uid/value included, and a FOLDER branch reads the compositions the folder references.

  • SELECT projects values by path. Paths use archetype node ids (at0004) and RM attribute names (value/magnitude); AS names a column. A path through a repeated node returns one row per node it reaches: selecting the systolic and the diastolic of a blood-pressure EVENT returns one row per event, each pairing that event’s own two values, with null where an event lacks one. Paths that share a prefix share that node in a row, and a path that reaches nothing reads null without removing the row. The AQL specification does not define this row shape; it is this engine’s documented convention.

  • WHERE filters on typed leaf values, with comparisons, EXISTS, LIKE, MATCHES, and boolean combinators. The AQL specification does not say how a test behaves on a path that reaches several nodes; this engine’s rule is:

    • Every repeated step a WHERE path descends, and the SELECT has not already bound, is bound once for the whole WHERE clause, the way a SELECT path binds it: one node per binding, and one null binding when nothing matches. Steps written identically (predicate included) are one binding, so events[at0006] and events are two.
    • A row is kept when some assignment of those bindings makes the whole clause true, with SQL’s three-valued logic: a test of a null value is unknown, and an unknown clause does not keep the row.
    • A path that shares a prefix with a selected path reads the node that row is bound to.

    The boolean laws therefore hold. <systolic> >= 140 AND <diastolic> >= 90 holds only for an event carrying both values, also under OR and NOT, whether or not the SELECT reads the event; NOT (<systolic> > 140) holds when some event’s systolic is 140 or lower, and is unknown when the path reaches nothing; NOT (A OR B) equals NOT A AND NOT B; adding a test with AND never adds rows. Paths compared with each other, and paths inside a function call, read through the same bindings. Elements of a list attribute inside one node’s data (links, participations, identifiers, …) are not bindings: a test of such a path against a value holds when any element of the bound node satisfies it, and is unknown when the list is empty or absent, so NOT (c/links/target/value = 'x') drops a composition without links. Beside another path or inside a function, such a path reads its first element.

    EXISTS <path> asks whether the data at that path exists (the AQL specification’s own definition). It reads through the SELECT bindings, and otherwise looks at the whole path on its own, outside the clause’s bindings: EXISTS holds when any node on the path carries the data and NOT EXISTS when none does, whatever the other tests bind.

    A list attribute in a projected path is read by one of two rules, depending on what the list holds:

    • Row rule: a list that holds structure nodes (c/content, p/identities, .../items, .../events) gives one row per node. SELECT o/data[at0001]/events/time/value over an observation with two events answers two rows, one time each.
    • Cell rule: a list inside one node’s data (links, context/participations, composer/identifiers, mappings, feeder_audit/originating_system_item_ids) gives one cell. A path that ends on the list answers the list itself, and a path that continues below it answers the array of its matches. Both are null where nothing matches. Over a composition with a placer and a filler order number, c/feeder_audit/originating_system_item_ids answers [{"_type": "DV_IDENTIFIER", "id": "ORD-PLACER-1", ...}, {"_type": "DV_IDENTIFIER", "id": "ORD-FILLER-1", ...}] and c/feeder_audit/originating_system_item_ids/id answers ["ORD-PLACER-1", "ORD-FILLER-1"].

    Paths through different repeated steps of one variable are bound independently, so selecting both returns every combination of their nodes.

  • Aggregates read every data item of their path in the rows FROM and WHERE select: MAX(<systolic>) over an observation measured twice sees both events, and COUNT over a list attribute counts its elements. An item counts when some assignment of the WHERE bindings, with the steps the item shares fixed to its own nodes, makes the clause true, so SELECT COUNT(<systolic>) ... WHERE NOT (<systolic> < 140) counts exactly the readings that selecting <systolic> would return. List elements inside one node are not filtered one by one: COUNT(<links path>) under a WHERE on that path counts every link of a row the clause keeps. An aggregate adds no rows, so COUNT(*) counts the rows the WHERE clause keeps whatever other aggregates sit beside it. COUNT(DISTINCT) compares numbers by value (120 and 120.0 are one value), a stored JSON null counts as no value, and AVG returns a decimal mean.

  • ORDER BY, LIMIT, and OFFSET behave 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:

FieldMeaning
qThe AQL text (required).
offsetRows to skip (default 0).
fetchMaximum rows to return.
query_parametersAn object of named parameter values (see below).

There is also a GET /query/aql form taking q, offset, fetch, an optional ehr_id, and query_parameters as query-string parameters, convenient for simple, cacheable reads.

offset, fetch, ehr_id and named parameters are accepted in the query string on the POST forms too. A value supplied in both the body and the URL must agree; a disagreement is a 400 Bad Request rather than a silent choice between them.

The query API is JSON only (Accept: application/json).

Scoping to an EHR

You can restrict a query to one EHR without writing the constraint into the AQL: pass an ehr_id query-string parameter, or the openehr-ehr-id request header. Both forms work on every execution endpoint: ad-hoc and stored, GET and POST alike. (openEHR-EHR-id is the deprecated spelling of the same header and still resolves, HTTP header names being case-insensitive.)

If a request carries both forms they must name the same EHR; a request whose parameter and header name different EHRs is self-contradictory and is rejected with a 400 Bad Request. So is a request repeating the header with two different values. An empty header value counts as “not supplied” and conflicts with nothing.

The id must exist: a malformed id is a 400, and a well-formed id that matches no EHR is an honest 404 Not Found rather than an empty result set, so a typo cannot masquerade as “no data”.

The result set

A query returns a RESULT_SET: a description of the columns and an array of row tuples.

{
  "meta": {
    "_type": "RESULTSET",
    "_schema_version": "1.0.0",
    "_created": "…",
    "_executed_aql": "SELECT e/ehr_id/value FROM EHR e"
  },
  "q": "SELECT e/ehr_id/value FROM EHR e",
  "columns": [
    { "name": "#0", "path": "/ehr_id/value" }
  ],
  "rows": [
    [ "7d44b88c-4199-4bad-9764-5da0e2a97441" ],
    [ "b1e2c3d4-5678-90ab-cdef-1234567890ab" ]
  ]
}

Each entry in columns names the column (the AS alias, or #<index> when you did not alias it) and its path. Each row in rows is an array of cells, one per column in column order. A cell can be a scalar or a full RM object (for example {"_type":"DV_TEXT","value":"Labs"}) depending on what you selected. A stored-query execution additionally carries the query’s name.

The meta block’s _executed_aql field is the AQL the server actually ran, with your named parameters substituted in as literals. Paste it straight back into an ad-hoc query when debugging a parameterised call. The top-level q keeps the text exactly as you submitted it, and _created stamps when this response was produced.

Query responses carry a weak ETag that is a content digest of the result set: two runs returning identical results carry the identical tag, so a client can cheaply detect “nothing changed” between polls. The digest deliberately covers the query, the executed AQL, the columns and the rows, and not the per-response _created stamp, so an unchanged result does not mint a new tag every second.

Parameters

Parameterise a query with named placeholders (a name preceded by a dollar sign) and supply the values in query_parameters. This is the safe way to inject values, with no string concatenation:

curl -u ferroehr:ferroehr -H 'Content-Type: application/json' -d '{
  "q": "SELECT c FROM EHR e CONTAINS COMPOSITION c WHERE c/name/value = $name",
  "query_parameters": { "name": "Vital signs" }
}' http://localhost:8080/ferroehr/rest/openehr/v1/query/aql

On the URL, each parameter can also be its own query-string key, which is the form the openEHR request documentation shows and is usually easier to build by hand:

curl -u ferroehr:ferroehr --get \
  --data-urlencode 'q=SELECT c FROM EHR e CONTAINS COMPOSITION c WHERE c/name/value = $name' \
  --data-urlencode 'name=Vital signs' \
  http://localhost:8080/ferroehr/rest/openehr/v1/query/aql

Four details about the URL form: the leading $ is optional ($name and name bind the same parameter); a value in double quotes binds the JSON string it denotes, so uid="123" binds the text 123, as {"uid": "123"} does in a POST body (a value that only starts with a quote keeps its text); any other value that parses as a JSON scalar binds as that type (36 as a number, true as a boolean) while everything else binds as text; and the reserved keys q, offset, fetch, ehr_id and query_parameters are request controls, never parameters. Where the same name appears both as its own key and inside query_parameters, the named key wins.

Stored queries

You can register a query once, under a qualified name and version, and execute it by name later. Storing is done through the definition API with the AQL as a plain-text body; executing is done through the query API.

# Store a query as org.example::bp_over, version 1.0.0
curl -u ferroehr:ferroehr -X PUT -i \
  -H 'Content-Type: text/plain' \
  --data-binary 'SELECT o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude FROM EHR e CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]' \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/query/org.example::bp_over/1.0.0

# List and fetch stored queries
curl -u ferroehr:ferroehr \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/query/org.example::bp_over

# Execute it
curl -u ferroehr:ferroehr \
  http://localhost:8080/ferroehr/rest/openehr/v1/query/org.example::bp_over/1.0.0

Storing. Both store forms answer 200 OK with an empty body and a Location header naming exactly the version that was written:

  • PUT /definition/query/{name}/{version} stores at that exact SemVer. The pair is immutable (re-storing an existing (name, version) is a 409 Conflict, never an overwrite) and a partial or malformed version segment is a 400.
  • PUT /definition/query/{name} (no version) stores or updates at the default version 1.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 same offset, fetch, ehr_id and named parameters as ad-hoc queries; a POST body of {} executes a parameterless stored query.

Deleting a stored query is an admin operation; see Admin & messaging APIs.

Cohort queries across the pseudonymisation boundary

Some questions are about a population rather than a patient: the blood pressures of everyone in a city, the medication history of an age band, the readmissions of the people one organisation cares for. Answering them normally means someone producing a list of patient identifiers first, which is exactly the disclosure the split between clinical and demographic storage exists to avoid.

POST /ferroehr/rest/extensions/v1/query/cohort answers them without producing that list. It is a FerroEHR extension — no openEHR spec governs it: AQL is defined over the clinical domain and has no demographic source, and ITS-REST publishes an ad-hoc and a stored query and nothing else.

How it runs. Three steps, each on its own database credential:

  1. 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.
  2. The linkage domain resolves those parties to the EHRs they are the subject of. Identifiers in, identifiers out.
  3. Your AQL runs on the clinical pool, scoped to exactly those EHRs.

The three statements never meet. With [storage.party] url and [storage.linkage] url set, each pool authenticates as a role revoked from the other two, and the server refuses to boot if one can read across. You never receive a party identifier: the response carries what your AQL projected, plus counts.

The allow-list. A cohort predicate can only name something the deployment declared, under [cohort.predicates] — the surface is off until at least one is bound, and the route answers 404 until then. Five keys are bindable: city, postcode_area, sex, age_band and organisation. Each binding names the archetype of the leaf’s nearest archetyped ancestor, the leaf’s at-code, and how a value is matched. See Privacy & data minimisation for the keys and their defaults.

Naming subjects by identifier. With [cohort] subjects = true a request may carry subjects, a list of { "id": …, "namespace": … } identifiers in any identifier domain (an optional type defaults to PERSON). Each one is resolved to its EHRs through the [ehr_index] provider, and the result intersects with what cohort selects. Under the local map the map answers. Under an IHE patient identity source FerroEHR asks the PIX Manager to translate the identifier forward into its own clinical domain (ITI-83 with targetSystem set to that domain) and finds the EHR whose EHR_STATUS carries that pseudonym. A subject no EHR belongs to adds nothing. A subject that cannot be resolved, because the identity source fails or no provider is installed, fails the whole query rather than shrinking the cohort.

Under an IHE patient identity source, cohorts name their subjects. That provider keeps no party-to-EHR map, and FerroEHR never asks it to translate a pseudonym back into another domain (openEHR RM EHR_STATUS.subject; GDPR Art. 4(5) and Art. 25). A demographic predicate would need one or the other, so a request with a non-empty cohort is refused 400 there, before any domain or the PIX Manager is asked. The cohort digest takes each subject by its namespace alone, so the access record carries no identifier value.

Running one.

curl -u ferroehr:ferroehr -X POST \
  -H 'Content-Type: application/json' \
  -d '{
        "q": "SELECT c/uid/value FROM EHR e CONTAINS COMPOSITION c",
        "cohort": [{ "name": "city", "value": "Groningen" }],
        "purpose": "secondary-use",
        "fetch": 100
      }' \
  http://localhost:8080/ferroehr/rest/extensions/v1/query/cohort

q, offset, fetch and query_parameters mean what they mean on the ad-hoc body. There is no ehr_id: the cohort is the scope, and supplying one is a 400. Several cohort entries intersect — city and sex, not city or sex.

The response is an ordinary RESULT_SET with one extra block:

{
  "meta": {
    "_type": "RESULTSET",
    "cohort": {
      "size": 412,
      "served_ehrs": 388,
      "suppressed": false,
      "definition": "9f2c…"
    }
  },
  "rows": [["8849fd1e-…::local::1"]]
}

size is how many EHRs the cohort resolved to, served_ehrs how many the rows actually came from, and definition a SHA-256 digest of the predicate list — the cohort’s identity, carrying none of its values.

Small-cell suppression. A result set narrow enough to name one person re-identifies that person out of content you hold no individual entitlement to. When fewer EHRs are served than cohort.small_cell_threshold (default 5), the rows are withheld and suppressed is true. The response says so rather than looking like an empty cohort: a caller who cannot tell the two apart will keep widening the predicate until the boundary leaks. Set the threshold to 0 to disable suppression.

The access record. Every execution — suppressed and failed ones included — records one linkage-domain access event naming the cohort by its digest, the purpose you declared, and how many EHRs were served. A crossing of this boundary that nobody can reconstruct afterwards is what the access log exists to prevent. The predicate values are never recorded: they are the cohort, and a trail carrying them would be a second copy of it.

Limits. A predicate matching more than cohort.max_cohort_size parties (default 100000) is refused 422 rather than truncated — a silently shortened cohort is a wrong denominator with nothing on the wire to say so. More named subjects than that ceiling are refused the same way. An unbound predicate name, a malformed age band (the form is <min>-<max> whole years, both inclusive), a predicate under an IHE identity source, or a request with neither a predicate nor a subject is a 400. An empty cohort runs no AQL and answers an empty result set.

Measured. The committed record docs/benchmarks/cohort/record.json in the repository (produced by the ignored cohort_bench test, not by the conformance instrument) measures the whole call, predicate to result set, over a corpus of 100 000 parties, each the subject of one EHR holding one composition, seeded through the service API, with the database in a local container. Selecting one composition path per EHR:

Cohortp50p95Demographic statementClinical statement
100 EHRs71 ms116 ms54 ms1 ms
1 000 EHRs102 ms145 ms51 ms11 ms
10 000 EHRs543 ms820 ms64 ms325 ms
100 000 EHRs2.6 s2.9 s260 ms702 ms

Measured over 100 000 parties, 100 000 EHRs and 100 000 compositions at commit 54495c274ebf on a consumer-laptop (8 cores, 16 GB, nvme); the two statement columns are EXPLAIN (ANALYZE) times.

The difference between the statement times and the wall clock is result assembly and the access records. Both statements run on indexes (idx_dem_node_archetype on the demographic side, the current-version and EHR indexes on the clinical side); no sequential scan appears in any of the recorded plans. The record names the commit it was measured at; a re-run replaces it rather than appending.

Queries rooted at a demographic class

The Demographic API reads a party by its identifier and has no search. To find a person, an organisation or a role by what it holds, such as an identifier in its details, FerroEHR serves a subset of the experimental AQL demographic extension on the same POST /query/aql and GET /query/aql routes. No openEHR specification governs it: QUERY 1.1 lets FROM name any Reference Model class but gives semantics only to the clinical repository, so everything below is FerroEHR’s own design, and the extension may change.

SELECT p/uid/value
FROM PERSON p
WHERE p/details[at0001]/items[at0002]/value/id = $identifier
ORDER BY p/uid/value

The grammar served. The query is ordinary AQL 1.1 syntax with these rules on the FROM clause:

  • The outermost class is PERSON, ORGANISATION or ROLE, optionally under a VERSION class. It is the only demographic class at the root of the query.
  • Below it, CONTAINS may name the content classes a party holds: CLUSTER, ELEMENT, the item structures, and the party containers PARTY_IDENTITY, CONTACT, ADDRESS and CAPABILITY, with archetype and node predicates as usual, for example CONTAINS CLUSTER c[openEHR-DEMOGRAPHIC-CLUSTER.person_identifier.v2].
  • SELECT, WHERE and ORDER BY take paths inside the party: its details tree, its identities, contacts and capabilities, p/uid/value, and the whole party (SELECT p). Named parameters, LIMIT/OFFSET, the REST offset and fetch, and the functions and aggregates of the clinical envelope apply unchanged.

What a row is. The query reads the latest trunk version of each party. A logically deleted party, and a party under a restriction of processing, answers no row. p/uid/value is the version identifier the Demographic API returns.

What is refused. Each of these is a 400 Bad Request naming the construct, so you can tell a refusal from an empty result:

  • GROUP, AGENT, PARTY_RELATIONSHIP, and the abstract PARTY and ACTOR;
  • a demographic class anywhere but the root, or a second one;
  • a query that names EHR or a clinical versioned object such as COMPOSITION anywhere but directly under the root (see the join below);
  • an ehr_id scope (query parameter or openehr-ehr-id header), because a party is in no EHR.

Joining a party to its EHR. An EHR operand directly under the root reaches the EHR the party is the subject of:

SELECT p/uid/value, c/name/value
FROM PERSON p CONTAINS EHR e CONTAINS COMPOSITION c[openEHR-EHR-COMPOSITION.encounter.v1]
WHERE p/details[at0001]/items[at0002]/value/id = $identifier
ORDER BY c/context/start_time/value DESC

A party is the subject of an EHR when either of these holds:

  • the EHR’s current EHR_STATUS.subject is a PARTY_SELF whose external_ref names the party: its id.value is the party’s uid without ::<system>::<version>, and its type is the root class or one of its abstract RM ancestors (ACTOR for PERSON and ORGANISATION, PARTY for all three). This is the reference POST /ehr stores, so an EHR created with that subject joins with no other call;
  • the linkage service’s party-to-EHR map holds the pair.

An EHR that both name answers once. The namespace of the reference is not compared: BASE OBJECT_REF.namespace takes any name a deployment chooses (local, demographic, or your own), and the id and the class already name one party. A deployment that pseudonymises the subject stores a pseudonym in id.value, and a pseudonym is not a party uid, so its EHRs join only through the map.

Party classes may sit beside the EHR in an AND (PERSON p CONTAINS (CLUSTER id[…] AND EHR e CONTAINS COMPOSITION c)). A row is a party row together with a clinical row of an EHR that party is the subject of, so a party with no EHR, and an EHR whose subject has no party, answer no row. FerroEHR runs the query as four statements, each on one database credential: the party half, the map, the EHRs whose subject names those parties, and the clinical half scoped to the EHRs the two reads name. It then joins, orders and pages the rows. The rules that follow:

  • each column, each WHERE condition joined by AND, and each ORDER BY key reads one side only, the party or the EHR; a condition comparing the two, or an OR across them, is a 400;
  • aggregates and SELECT DISTINCT are a 400;
  • each half, and the joined rows, may hold at most query.max_result_rows rows; above that the query is a 400 asking you to narrow it, never a shortened answer;
  • the clinical half carries your per-EHR access decision and patient scope as any query does, and the party read and the crossing to the EHRs each leave an access record in their own domain.

Who may run it. The query runs under the authorisation of a Demographic API read: the role check that admits a party read admits the query, and the per-EHR access decision and the attribute-based patient scope, which govern EHR content, do not apply to parties. The SMART aql-… scopes that govern every query still apply. The query runs on the demographic database credential, and its access record is filed under the demographic domain with the number of rows served.

Version scope: LATEST_VERSION and ALL_VERSIONS

By default a query sees the latest version of each object. FerroEHR also supports querying the entire version history, a capability many CDRs lack. Wrap a source in VERSION and choose the scope:

SELECT v/commit_audit/time_committed, c/name/value
FROM EHR e
    CONTAINS VERSION v[ALL_VERSIONS]
        CONTAINS COMPOSITION c

LATEST_VERSION (the default) reads only current trunk versions; ALL_VERSIONS reads across history (including branch versions, where a version tree has any) so you can see how a record changed over time. A version predicate on commit_audit/time_committed reads the trunk version current at that instant. The VERSION variable also exposes commit metadata: the audit, the committed time, and the version uid.

A VERSIONED_OBJECT operand between the EHR and the version names the versioned object itself. Its variable answers uid/value, the object id every version of it shares, and owner_id/id/value, the EHR that owns it; any other path on it is refused. It sets no version scope of its own: the VERSION below it does.

SELECT vo/uid/value, v/uid/value, c/name/value
FROM EHR e
    CONTAINS VERSIONED_OBJECT vo
        CONTAINS VERSION v[ALL_VERSIONS]
            CONTAINS COMPOSITION c

Terminology in queries

Value filters can be backed by terminology in three ways:

  • TERMINOLOGY('expand', …) as (or inside) a matches operand expands a value set so a coded field matches any code in it, rather than listing codes by hand;
  • TERMINOLOGY('validate'|'subsumes', …) = true as a boolean condition evaluates a code-membership or subsumption test once per query;
  • a terminology URI operand (matches { terminology://… }) expands the set the URI identifies.

These require a terminology source; if no terminology server is configured, the in-process openEHR bundle is used. See Terminology servers for wiring the FHIR terminology server.

Two rejections to expect here: only expand may stand as a matches operand (the other operations have no value-list meaning), and only validate and subsumes have boolean semantics, so TERMINOLOGY('lookup', …) = true is refused rather than guessed at. A value set the configured server does not know is a 400 naming it; a terminology server that fails mid-query is a 500, kept distinct from a bad query on purpose.

Pagination and limits

Combine LIMIT/OFFSET in the AQL with the fetch/offset request parameters to page through large result sets. Three bounds interact, and it is worth knowing which one you hit:

BoundWhere it comes fromEffect
LIMIT/OFFSET, or fetch/offsetyour query or requestExactly the window you asked for.
query.max_result_rowsserver config, default 10000The 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_msserver config, default 30000Per-query database execution budget. On by default; 0 disables it.

Two bounds on the shape of the query text are fixed in the server. Brackets and CONTAINS may nest at most 64 levels, and a WHERE expression may be at most 512 levels deep, where each AND, OR, NOT, CONTAINS and bracket on a path counts one level. A query beyond either returns 400 Bad Request naming the bound, and a stored query beyond either is refused when you store it. AND and OR chain one level per operator, so a long list of alternatives over one path belongs in MATCHES {…}, which the bound does not count:

WHERE c/name/value MATCHES {'Blood pressure', 'Pulse', 'Body weight'}

A query that exceeds the time budget returns 408 Request Timeout; narrow it (add archetype constraints, an ehr_id scope, or a WHERE filter) rather than retrying it unchanged. A LIMIT or fetch larger than the row ceiling returns 400 Bad Request naming the ceiling; the page is never silently shortened, because a client paging with its own fetch as the stride would skip rows without noticing. Page with offset and a fetch at or below the ceiling. A result that stops at the ceiling without either bound set is the default page: page explicitly to read past it.

Always order a query you intend to page

OFFSET skips rows of a result, and without ORDER BY a result has no defined row order. Two requests for consecutive pages can then repeat a row or miss one entirely, and nothing in the response says so. Give any query you page an ORDER BY on something unique, such as c/uid/value.

Ordering also changes what paging costs, in the direction that helps you. An ordered query sorts the whole matched set to answer any page, so the first page already pays most of the work and a deep page costs little more than a shallow one. An unordered query skips rows one at a time, so its cost grows with the offset, from almost nothing on page one. Either way what dominates is how much your FROM/CONTAINS matched, not how deep you paged.

One more reason to order a paged query, if the deployment runs attribute-based authorization: the authorization decision is made over every EHR and template the query would touch, not over the page it served, so that set is collected whatever the page size. An ordered query pays nothing extra for it, because it was producing the whole matched set anyway. An unordered page that looks cheap carries the whole collection on top of it. Narrow the query, and order it.

Tip

The more specific your FROM/CONTAINS (name the archetype, scope by ehr_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:

  • SELECT of paths, literals, aliases, DISTINCT, and the aggregates COUNT (including COUNT(DISTINCT)), MIN, MAX, SUM, AVG. MIN and MAX order their operand by type (a quantity by its openEHR magnitude, a date/time chronologically, text lexically) so they work over non-numeric leaves;
  • FROM over EHR, VERSIONED_OBJECT (its uid and owner_id), VERSION (LATEST_VERSION / ALL_VERSIONS), and the RM structure classes, with archetype and name predicates, and over PERSON, ORGANISATION and ROLE as the experimental demographic extension;
  • CONTAINS trees with AND, OR, and NOT CONTAINS;
  • WHERE comparisons on typed leaves (with openEHR magnitude ordering for quantities), EXISTS, LIKE, MATCHES value lists, and range predicates;
  • ORDER BY typed leaves, LIMIT/OFFSET, TOP n, named query parameters, and the ehr_id, offset, and fetch request parameters;
  • the single-row functions: LENGTH, SUBSTRING, POSITION, the string CONTAINS, CONCAT/CONCAT_WS, ABS, MOD, CEIL, FLOOR, ROUND, and CURRENT_DATE/CURRENT_TIME/CURRENT_DATE_TIME/NOW/CURRENT_TIMEZONE;
  • terminology-backed TERMINOLOGY() operands and terminology-URI matches operands (see above).

What is refused, and why

Every refusal below is a typed error naming the construct, so you never get a silently incorrect answer:

  • Demographic forms outside the served subset. GROUP, AGENT, PARTY_RELATIONSHIP, PARTY and ACTOR, a demographic class below the root, and a query mixing a demographic root with EHR or clinical content (see Queries rooted at a demographic class).
  • TOP … BACKWARD. TOP is 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). TOP and LIMIT in one query are also refused; pick one.
  • Regex and OR node predicates. [{/…/}] is archetype-definition syntax, not AQL value matching, and a disjunctive node predicate is outside the accepted subset.
  • Branch version addressing. A version predicate may address trunk versions; reading branch content is done by scoping ALL_VERSIONS, not by naming a branch.
  • Reading a variable declared under NOT CONTAINS. NOT CONTAINS states that no such node exists, so a path through its variable has nothing to read.
  • SELECT DISTINCT ordered 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 duplicate FROM variable name, LIMIT 0, a negative OFFSET, wrong function arity, SUM/AVG over a non-numeric path, and an archetype_node_id criterion 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

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_id or concept) and the full archetype-model artefact-validity catalogue: reference-model conformance of every constrained type and attribute, occurrence/cardinality consistency, terminology code definedness and language consistency, archetype-identifier well-formedness, and constraint pattern validity (temporal and duration patterns, boolean satisfiability, assumed values inside their own constraints). The response body lists the offending archetype-model rule codes (VCARM, VATID, VCORM, Pattern_validity, …) in the problem’s errors, each with a human-readable detail; a request with Prefer: return=representation receives them (Problem types).

The 422 gate also validates the template’s own meta-data against the RM resource-package invariants: the template’s language (and every description detail’s) must be in the openEHR languages code set, is_controlled must agree with the presence of a revision_history, and a description needs a non-empty original_author, lifecycle_state, and at least one detail with a non-empty purpose, with details keyed by distinct languages. Those refusals carry the RM invariant name (for example RESOURCE_DESCRIPTION.Lifecycle_state_valid) in errors. ADL 1.4 archetype source uploads enforce the same family; there, an empty purpose/use/misuse string is reported as a named warning rather than refused, because the empty string is how real-world 1.4 authoring spells absence.

A refusal lists everything it found, not the first thing. errors carries one entry per violation, so a template with an empty use and an empty misuse reports both, and a code list with several duplicated codes names each one. Fix the list, upload once. Two things are worth knowing about the shape of that list:

  • A constrained type the reference model does not have, or an attribute its parent type does not declare, makes every rule below it meaningless. Those violations say that the tree under them went unchecked, so a short list is never a claim that nothing else is wrong. Fix them and upload again.
  • A template with more than 200 violations reports the first 200 and a final TRUNCATED entry saying so.

What an OPT 1.4 export must carry

One requirement of the OPT 1.4 XML format catches exported templates. Inside a C_DV_ORDINAL, every list entry is a DV_ORDINAL whose symbol is a DV_CODED_TEXT, and a DV_CODED_TEXT carries a <value> element beside its defining_code. The element may be empty, <value/>: the rubric a user sees is resolved from the template’s term_definitions, not from this text. CKM’s exporter writes the empty element, so its templates load. Archetype Designer omits it, and such a template is refused with 422, naming the <symbol> element, its line, and the fix. Add <value/> to each ordinal symbol and upload again.

Whether the schema should require the element on an ordinal symbol is a question for openEHR, tracked at https://github.com/FerroHEALTH/FerroEHR/issues/3401.

Uploading ADL 2 artefacts

ADL 2 artefacts (archetypes, templates and operational templates) are accepted as text/plain source on …/definition/template/adl2 and validated by the full ADL 2 engine: the source is parsed, then checked against the AOM2 validity catalogue (phase-1 basic integrity, reference-model conformance, and, for a specialised artefact whose parent is already loaded, specialisation conformance).

The two failure modes are again distinguished by status:

  • A source that does not parse is a 400 Bad Request, carrying the ADL syntax (S-prefixed) error codes.
  • A source that parses but fails validation is a 422 Unprocessable Entity, whose errors list the AOM2 validity (V-prefixed) rule codes: VARD, VCORM, VACSD and friends.

A duplicate artefact id returns 409 Conflict.

A loaded ADL 2 template is retrieved in either of two representations, chosen by Accept: the stored ADL 2 source (text/plain, the default) or the operational template as JSON (application/json). Accept: application/xml has no declared body here and is a 406. A partial template_id resolves to the latest matching version, and a separate route addresses a version explicitly:

# The ADL 2 source, verbatim
curl -u ferroehr:ferroehr -H 'Accept: text/plain' \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl2/openEHR-EHR-COMPOSITION.t_vitals.v1.0.0

# The operational template as JSON, latest version matching the partial id
curl -u ferroehr:ferroehr -H 'Accept: application/json' \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl2/openEHR-EHR-COMPOSITION.t_vitals.v1

# Resolved inside one SemVer version (exact, or a `{major}` / `{major}.{minor}` prefix)
curl -u ferroehr:ferroehr -H 'Accept: text/plain' \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl2/openEHR-EHR-COMPOSITION.t_vitals/1.0

Note

The Basic-authenticated examples on this page run against the quickstart, whose user holds user/template-*.cruds. The template family is scope-governed on every deployment: a caller, Basic or bearer, whose scopes grant no template- access answers 403. See SMART App Launch.

Listing templates

# List all templates
curl -u ferroehr:ferroehr \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4

# Filter and page the list
curl -u ferroehr:ferroehr \
  'http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4?template_id=vital*&offset=0&fetch=20'

# Get the canonical OPT XML for one template
curl -u ferroehr:ferroehr -H 'Accept: application/xml' \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4/vital_signs

Both list endpoints (ADL 1.4 and ADL 2) accept the same three filters (template_id, concept, and version, each a glob pattern with * wildcards) plus offset (rows to skip, default 0) and fetch (maximum rows; absent or 0 = all). The filters AND together. An offset past the end of the match set is an empty list, not an error.

Note

When the version parameter is absent, the list collapses to the latest version of each template (the highest .vN axis of its template_id). Pass version=* to list every stored version, or a partial glob (version=*.v1*) to select specific ones.

Deleting a template

Deletes are deliberately not part of the openEHR definition API, and FerroEHR keeps them off the provisioning routes:

  • an OPT 1.4 template is removed with DELETE /ferroehr/rest/extensions/v1/admin/template/{template_id}, behind the admin gate (see Admin & messaging APIs);
  • an ADL 2 artefact is removed with DELETE /ferroehr/rest/extensions/v1/definition/artefact/adl2/{artefact_id}.

Both refuse with 409 Conflict while any committed version still references the template, and the message names how many versions still hold the reference. A physical delete can never orphan committed clinical data. The count spans archived content too, so an archived composition’s template cannot be deleted out from under it. Delete the compositions first.

Archetype sources (a FerroEHR extension)

Beyond operational templates, FerroEHR can also hold the source archetypes they were built from, so a deployment can keep its definition material in one place. ITS-REST defines no archetype routes, so they sit under the extensions root, /ferroehr/rest/extensions/v1, beside the openEHR API, and every route below is relative to it:

RouteWhat it does
`GETPOST /definition/archetype/adl1.4`
`GETDELETE /definition/archetype/adl1.4/{archetype_id}`
GET /definition/archetype/adl2, …/adl2/countList the stored ADL 2 archetypes, and count them
GET /definition/artefact/adl2, …/adl2/countList 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 archetype header, 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 a Location.
  • Ids match case-insensitively, and a malformed id on a read is simply a 404; nothing is stored under it.
  • Writes respect the read-only role. A principal carrying the configured read-only role is refused 403 before the store is touched. Uploads that do not parse or fail the phase-1 validity catalogue are 422.

The WebTemplate

The WebTemplate is a JSON description of a template that is far easier for application code to consume than raw OPT XML: it lists every field with its path, type, cardinality, allowed values, and labels, which is exactly what you need to render a form or map data. Request it with the WebTemplate media type:

curl -u ferroehr:ferroehr \
  -H 'Accept: application/openehr.wt+json' \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4/vital_signs

The WebTemplate is part of openEHR’s own Simplified Formats sub-specification of ITS-REST 1.1.0, and FerroEHR emits its metadata format version 2.3 as that specification defines it, so tooling written against the standard model works unchanged.

Accept: application/json on the same URL returns that identical WebTemplate document (it is the only JSON representation of a template) but labelled Content-Type: application/json, the type you asked for. Use Accept: application/xml for the canonical OPT instead.

You can also fetch an example composition for a template (a skeleton instance you can fill in) from GET /definition/template/adl1.4/{template_id}/example. The same endpoint exists for ADL 2 templates at GET /definition/template/adl2/{template_id}/example: the stored operational template is turned into a WebTemplate and walked into an example composition. Both serve any of canonical JSON/XML, FLAT, or STRUCTURED (via Accept) and take the same two query parameters: type (input/output) and detail_level (required/medium/complete).

The example carries every attribute and node the template makes mandatory, so it passes the server’s own validation. A template whose mandatory content only an archetype slot admits cannot be filled by any example.

A demographic template (one rooted at PERSON, ORGANISATION, GROUP, AGENT or ROLE) gets an example party of that class instead, in canonical JSON or XML. An example ROLE carries a placeholder performer, which the reference model makes mandatory. The Simplified Formats map no party class, so Accept: application/openehr.wt.flat+json or …structured+json on a party example answers 406 Not Acceptable.

A template whose root is neither a COMPOSITION nor a party class (an OBSERVATION or a SECTION, for example) has no example: the endpoint answers 400 Bad Request naming the root, because the example the specification defines is an instance of a versioned-object class such as COMPOSITION, which such a template does not describe.

Composition formats

When committing or retrieving a composition, the canonical openEHR JSON (or XML) is always available, but two flatter formats are offered for convenience, keyed to a template:

  • FLAT (simSDT): application/openehr.wt.flat+json. The whole composition as a single flat map of path|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-id request header, without which a commit is a 400. Use them for form-driven capture; use canonical JSON/XML for full-fidelity exchange and archival.

Optional RM attributes (_-prefixed keys)

Beyond the template’s own fields, FLAT and STRUCTURED carry the optional reference-model attributes as _-prefixed path segments, round-tripping in both directions. Indexed families take an :n suffix; sub-fields ride the usual |attribute pipes:

{
  "vital_signs/blood_pressure/_uid": "9fcc1c70-…",
  "vital_signs/blood_pressure/_link:0|type": "problem",
  "vital_signs/blood_pressure/_link:0|target": "ehr://…",
  "vital_signs/blood_pressure/any_event:0/systolic/_null_flavour|code": "253",
  "vital_signs/blood_pressure/any_event:0/systolic/_normal_range/lower|magnitude": 90,
  "vital_signs/blood_pressure/_other_participation:0|function": "witness",
  "vital_signs/blood_pressure/_other_participation:0|name": "Dr. Marcus Johnson"
}

Which families apply depends on the node they hang off:

HostFamilies
Any locatable node_uid, _link:n, _feeder_audit
Any entry (observation, evaluation, instruction, action, admin entry)_work_flow_id, _guideline_id, _provider, _other_participation:n
An instruction_expiry_time, _wf_definition
An action_instruction_details, and _reason:n on its state transition
An element_null_flavour, _null_reason
A data value_mapping:n, _normal_range, _other_reference_ranges:n, _accuracy, _language, _encoding, _charset, _thumbnail
Any party reference (a provider, a participation, the subject)_identifier:n
The composition’s event context_health_care_facility, _participation:n

An ACTION’s _instruction_details carries exactly three suffixes on the field itself: the instruction’s path within its composition, that composition’s uid, and the activity id:

{
  "encounter/procedure/_instruction_details|path": "/content[openEHR-EHR-INSTRUCTION.request.v1]",
  "encounter/procedure/_instruction_details|composition_uid": "4cdc3017-d8c5-4cd3-9900-f3bb7171d006",
  "encounter/procedure/_instruction_details|activity_id": "activities[at0001]"
}

An interval event additionally carries |sample_count (the number of samples the interval summarises) alongside its /width and /math_function fields.

An element that records why a value is missing carries _null_flavour (and optionally _null_reason) instead of a value (the reference model makes the two mutually exclusive), and both directions preserve it:

{
  "vital_signs/blood_pressure/any_event:0/systolic/_null_flavour|code": "253",
  "vital_signs/blood_pressure/any_event:0/systolic/_null_flavour|value": "unknown",
  "vital_signs/blood_pressure/any_event:0/systolic/_null_flavour|terminology": "openehr",
  "vital_signs/blood_pressure/any_event:0/systolic/_null_reason": "not asked"
}

Who the entry is about: subject

Every entry (observation, evaluation, instruction, action, admin entry) records a subject. It defaults to the owner of the EHR, and while it stays the default it does not appear on the wire at all. When an entry is about someone else (a relative, a donor, a fetus) spell it out with the party suffixes:

{
  "family_history/family_history/subject|name": "Susan Doe",
  "family_history/family_history/subject|id": "199",
  "family_history/family_history/subject|id_scheme": "HOSPITAL-NS",
  "family_history/family_history/subject|id_namespace": "HOSPITAL-NS",
  "family_history/family_history/subject/relationship|code": "10",
  "family_history/family_history/subject/relationship|value": "mother",
  "family_history/family_history/subject/relationship|terminology": "openehr"
}

A /relationship sub-path makes the subject a related party; additional identifiers ride the /_identifier:n family; |_type: "PARTY_SELF" marks a subject that is the EHR owner yet still carries an external reference.

Context fields: ctx/ shortcut or full path

The composition’s event context is normally set through the ctx/ shortcuts (ctx/time, ctx/setting, ctx/language, …). The equivalent full paths are accepted on input too, and take precedence over the shortcut defaults: …/context/start_time, …/context/setting, the _end_time and _location forms, and an entry’s …/language and …/encoding. A bare …/context/setting|code is resolved against the openEHR setting value set, exactly as ctx/setting is. A full path also feeds the defaults its shortcut feeds: …/context/start_time sets the default history origin of every observation and the time of every event that gives none, exactly as ctx/time does. When you give both spellings, the full path sets the stored field and the ctx/ key sets the defaults. Output always uses the ctx/ form for these, so a round trip through FLAT is stable. A path the Simplified Formats specification does not define is rejected with an error, never silently dropped.

Embedding canonical JSON with |raw

When one node needs full fidelity inside an otherwise-FLAT commit, write the node’s canonical JSON verbatim under the |raw suffix:

{
  "vital_signs/blood_pressure/any_event:0/systolic|raw": {
    "_type": "DV_QUANTITY", "magnitude": 120, "unit": "mm[Hg]"
  }
}

The embedded object must carry _type (without it, the key is treated as a normal leaf). Retrieval decomposes a value into regular FLAT keys wherever the Simplified Formats define them, and returns |raw only for a value they cannot express: a stored value whose type differs from the template’s, or a DV_STATE.

DV_STATE has no FLAT form

The Simplified Formats RM mapping chapter has a table for each data value type it maps, and none for DV_STATE (RM value: DV_CODED_TEXT, is_terminal: Boolean). No specification governs a FLAT or STRUCTURED spelling for it, and FerroEHR invents none: a write that decomposes a DV_STATE node (|code, |value, a bare value) is refused with an error that names the node, and the node is sent and returned as |raw canonical JSON instead:

{
  "my_template/my_observation:0/process_state|raw": {
    "_type": "DV_STATE",
    "value": {
      "_type": "DV_CODED_TEXT", "value": "completed",
      "defining_code": {
        "_type": "CODE_PHRASE",
        "terminology_id": {"_type": "TERMINOLOGY_ID", "value": "local"},
        "code_string": "at0012"
      }
    },
    "is_terminal": true
  }
}

The FLAT and STRUCTURED examples leave a DV_STATE node out, and an element whose value the template leaves open does not offer DV_STATE among its alternatives. The gap is reported upstream in #3936.

Coded text and open value sets

A coded field whose template value set is open accepts free text under the |other suffix. |other may not be combined with |code, |value, |terminology, or |preferred_term on the same leaf, and is rejected when the value set is closed.

Duplicate node names

When a template contains sibling nodes with the same name, the generated WebTemplate/FLAT path ids are disambiguated with underscore suffixes counted from 1 (blood_pressure, blood_pressure_1, blood_pressure_2) as the specification prescribes. There is no vendor-compatibility mode: the specification’s numbering is the only numbering, and vendor-only DV_QUANTITY suffixes such as |unit_system and |unit_display_name are not accepted. If your tooling was written against another server’s path ids, map them on the client side.

Validation on commit

Every composition is validated against its template at commit time. This is where the template earns its keep. The server checks:

  • structure: required sections and fields are present, and cardinality and occurrence constraints are respected;
  • leaf values: data types, units, value ranges, string patterns, decimal precision, and date/time constraints match the template;
  • terminology: coded values are members of the value sets the template binds, using the bundled openEHR terminology or a configured external FHIR terminology server (see Terminology servers).

A second pass runs beside it and refuses the same way: the clinical side’s data-minimisation rules read the subject reference, the party proxies and every string leaf of the body, and a finding is a 422 naming the RM path and the rule that matched, never the offending value. That pass is configured independently of the template, and the shipped default already refuses a national identifier anywhere in clinical content. See Privacy & data minimisation.

Date and time patterns

A template can constrain which parts of a date, time or date-time value must be present. The pattern takes the form yyyy-mm-ddTHH:MM:SS, and each part is written one of three ways:

In the patternMeaning
a letter field (yyyy, mm, dd, HH, MM, SS)the part is required
??the part is optional
XXthe part is not allowed

So yyyy-mm-ddTXX:XX:XX accepts 2022-02-03 and refuses 2022-02-03T04:05:06, and HH:MM:?? accepts both 10:30 and 10:30:15. A refusal names the parts in words and repeats the pattern with this key:

temporal value '2022-02-03T04:05:06' does not satisfy the pattern
'yyyy-mm-ddTXX:XX:XX': the template allows a date only; a time is not allowed
here. Pattern key: a letter field (yyyy, mm, dd, HH, MM, SS) is required, ?? is
optional, XX is not allowed.

If a composition is well-formed but breaks its template, the commit fails with 422 Unprocessable Entity, a validation-failed problem whose errors, sent when the request carries Prefer: return=representation, hold one {"path": …, "detail": …} per offending node, so a client can show the user exactly what to fix. A syntactically malformed request instead gets 400 Bad Request. The error shapes are described in Content negotiation & errors.

A version committed as 553|incomplete| relaxes what must be present while still checking everything that is; see the lifecycle notes in Resource walkthroughs.

Next

Beyond the core

The core of FerroEHR is the openEHR platform: EHRs, compositions, contributions, templates, versioning, and AQL. Around that core the server carries capabilities for fitting into the systems around it: moving whole records between systems, storing the people those records refer to, validating codes against an external terminology server, telling downstream systems that something changed, and keeping large attachments out of the database.

This chapter set describes each one the way you meet it: what it does, whether you have to turn it on, and how to consume it.

Note

FHIR R4 resource mapping is not part of FerroEHR. It is FerroBRIDGE (BUSL-1.1), a separate server that maps between FHIR and openEHR with FHIRconnect mappings and reaches FerroEHR over the ITS-REST API. On Kubernetes the FerroEHR chart deploys it beside the CDR with bridge.enabled=true (FerroBRIDGE). FerroEHR keeps two uses of FHIR: the access log rendered as FHIR AuditEvent with the ITI-81 retrieval (Audit), and the client for external FHIR terminology servers (Terminology servers).

What is on already, and what you switch on

Two different things are collected here, and they behave differently:

  • Always mounted, part of the API surface. The demographic API and the /message group (EHR Extract export and import) are ordinary routes. They have no feature switch: they are served on every deployment and gated only by the same authentication and authorization as the clinical API (Security). The bundled openEHR terminology is likewise always present in-process, with no configuration and no external dependency.
  • Off until you configure them. Everything that reaches outside the server is opt-in: external terminology servers, change events, and multimedia offload to object storage. A bare server contacts none of them, and its clinical behaviour is that of a single-tenant, integration-free openEHR CDR until you enable one.

Important

Some of these carry PHI, and each chapter says which. The one that moves clinical content off this system is multimedia offload (the blob bytes land in your bucket). Change-event envelopes carry identifiers and metadata only. Treat enabling offload as a deliberate, auditable decision about where clinical data is allowed to go.

Every configuration key these chapters name lives in the configuration reference; the integration sections are on Integrations and Audit.

Build features, and what a slim build refuses

Three of these capabilities are also cargo features of the server build (fhir, events, and multimedia) all on in the published binaries and container images, and on in any default build. Their code lives in a separate crate the platform pulls in only when the matching feature is on, so a --no-default-features build contains none of it.

A slim build does not start up quietly missing a capability: it refuses at boot when the configuration asks for one it was built without. The fhir feature covers the two uses of FHIR the server keeps: the external FHIR terminology providers, and the FHIR AuditEvent the audit store, the audit feed and the ITI-81 retrieval carry. See From source → Build features for the exact list of settings a slim binary rejects.

Note

One gap worth knowing if you build slim: terminology.api_enabled is a route switch, and in a slim build those routes are simply not compiled in, so the setting has no effect rather than failing loudly. The boot refusals cover the settings that would otherwise lose or fail to deliver data.

The capability set

  • EHR Extract & messaging: export a whole EHR, clone it into another system with its distributed version identity intact, and import an extract into an existing record.
  • Demographics: a versioned party store (persons, organisations, groups, agents, roles) with relationships, over a REST surface that mirrors the EHR APIs.
  • Terminology servers: the bundled openEHR terminology for local codes, plus any number of external FHIR terminology servers for validating coded values against external value sets; FerroTERM ships with the quickstart as the terminology overlay and runs beside the sandbox CDR.
  • Change events (AMQP): a transactional outbox that publishes a PHI-free, at-least-once event for every commit to an AMQP broker, so downstream systems can respond to changes instead of polling.
  • S3 multimedia: threshold-based, content-addressed offload of large DV_MULTIMEDIA blobs to any S3-compatible object store, with integrity verification on the way back in.

Security, instance separation and the audit trail are covered in Security; running the server in production, including the health and observability surfaces these integrations feed, is covered in Operations.

EHR Extract & messaging

Moving a patient’s record between openEHR systems (migrating to another CDR, replicating an EHR to a downstream repository, or importing an externally produced document) is what openEHR’s EHR Extract and messaging services are for. FerroEHR implements whole-EHR export, import into a new or an existing EHR (including cross-system cloning that preserves version identity).

Note

The openEHR service model defines a Message component, but the released REST API publishes no extract or message endpoint at all. FerroEHR therefore serves these operations under a /message group of its own design, beside the openEHR API at /ferroehr/rest/extensions/v1/message; the routes are ours, not the standard’s, and they gate no openEHR conformance claim. Unlike the admin extensions, they are not admin-gated: they carry the same ordinary authentication as the clinical API. The four routes, with their bodies, parameters, and status codes, are in Admin & messaging APIs.

Exporting an EHR

An export produces an openEHR EXTRACT: a self-contained package of an EHR’s versioned objects. There are two ways to ask for one.

Whole-EHR export takes every versioned object in one EHR at its latest version and assembles them into a single extract. This is the simplest way to snapshot or hand off a complete record, and it needs nothing but the EHR’s identifier.

Export by specification takes an EXTRACT_SPEC: a manifest naming which entities to include (each by EHR id or by subject id, optionally narrowed to specific version containers) plus the extract type. It produces one extract per manifest entity, in manifest order. Use it for selective or policy-controlled export.

Tip

The manifest must name at least one entity: EXTRACT_MANIFEST.entities is mandatory and non-empty in the Reference Model, so an empty manifest does not even decode: the request is refused as malformed rather than answered with an empty result. If you want “everything”, that is whole-EHR export, not an empty spec.

Not every selector the Reference Model allows is supported: search criteria and some commit-time intervals are refused explicitly rather than silently ignored, so an export never quietly returns a different set than you asked for.

Importing and cloning across systems

Import is the inverse, and it is where openEHR’s distributed version identity matters. When a record produced on one system is imported into another, the imported versions keep their original identity while being recorded as having arrived from elsewhere.

Cloning a whole EHR takes an extract and materializes it into a new EHR. There are exactly two outcomes, and you choose between them with one optional parameter:

  • Supply a target EHR id and the clone lands under that identifier; the “same patient, other EHR service” case.
  • Supply nothing and the source EHR id the extract carries is re-used, which makes it a true clone. The server does not invent a fresh identifier; if the extract names no source id and you named no target, the request is refused.

Either way, the response names the EHR that was created, so a caller that supplied no id still learns what it got. An EHR that already exists under the target id is a conflict, not a merge, and so is an imported EHR_STATUS naming a subject some other EHR already holds.

Each original version in the extract is committed wrapped in an IMPORTED_VERSION, so the record shows both the original authorship and the fact of import: version identity is preserved, not regenerated.

Importing into an existing EHR merges an extract’s versions into an EHR that is already there, following the openEHR change-control copying rules. The extract’s content items become new versions of that EHR’s versioned objects.

Together these are the mechanism behind cross-system migration: export from the source, import into the destination, and the destination’s history faithfully reflects where each version came from.

An import takes the data-minimisation pass too. Extract import stores what it receives, so it cannot rewrite a body; instead it refuses one whose content carries a national identifier, a subject reference that is not a declared pseudonym, or an identified party the deployment does not permit, with the same 422 and the same RM path the ordinary commit path reports. The rules are on Privacy & data minimisation, and they apply to the admin archive load for the same reason.

Protected national identifiers do not travel in an extract

With national-identifier protection on, a party’s versioned body carries a reference where each protected identifier stood, and the value lives in a sealed row under that deployment’s own root key. An extract carries the versions and never the sealed rows, so the identifier cannot cross from one FerroEHR to another in an extract:

  • A party with a plaintext protected value is refused. An imported version is a faithful copy that is never modified (RM common master06 §Copying), so the receiving deployment cannot seal it on the way in.
  • A party with another deployment’s sealed reference is refused. The row it names is not held here, so it could never be read back, and the refusal (422) says so.

This is a decided refusal, not a gap waiting for a key exchange. Re-wrapping the sealed rows for the receiving deployment would hand one organisation’s key material, or a wrapped copy of every value, to another, and no openEHR specification defines such an exchange. Move a record this way instead:

  1. Export the EHR and import the extract without the party. The clinical side holds pseudonyms and never a national identifier, so the clinical content moves unchanged.
  2. Register the person in the receiving deployment through its own party API (POST {base}/demographic/person), with the identifier the receiving organisation holds for the person. The receiving deployment seals it under its own key.
  3. Link the party to the EHR in the receiving deployment.

Between two deployments of one organisation that share the root key, the admin dump and load carries the sealed rows as ciphertext and restores them.

When ATNA auditing is enabled (it is on by default, see Security) each completed export and import emits a security-audit event under the ATNA Extract object class, so records moving between systems are captured in the audit trail with their direction.

Divergent copies and version branches

openEHR’s version tree branches when a version created on another system is modified locally, and FerroEHR implements that: the local write forks a branch at the imported version’s fork point, and the new version id carries a three-part tree id (…::2.1.1) rather than a plain trunk number.

Branch versions are served at the same URLs as trunk ones and appear in the revision history and in ALL_VERSIONS queries; the latest version of an object is always the latest trunk version. See Content negotiation & errors for how version ids read on the wire. So importing a copy of a record that has diverged on two systems is supported, and the divergence stays visible in the version tree instead of being flattened away.

FerroEHR as a federation node

FerroFED is the FerroHEALTH federation gateway: a client sends one AQL query to it, and it dispatches the query to the openEHR CDRs it federates. FerroEHR is one of those CDRs, a node. This page is the node contract: what a FerroEHR node exposes, what it requires, and what it refuses. The gateway’s own behaviour is FerroFED’s and is documented there.

The ground is the openEHR Federation Tier with AQL specification, release 1.0.0, approved by the openEHR SEC on 2026-10-12 (https://github.com/syntaric/openehr-federation-spec). Its requirements are numbered N1 to N44. Most of them bind the gateway. The rows below are the ones a node answers.

FerroFED 0.0.13 runs FerroEHR 5.4.1 as its node in its end-to-end harness and its quickstart.

Endpoints

A node exposes the openEHR REST API (ITS-REST 1.1.0) under its own base URL ([server] base_path; N28). The gateway uses:

UseEndpoint
The dispatched query, scoped to the resolved EHR (N7, N33)POST {base}/query/aql with ehr_id, or a WHERE e/ehr_id/value = … predicate (N29)
A follow-up read by version uid (N22)GET {base}/ehr/{ehr_id}/composition/{uid} and the other resource reads
A routed write (N23)the ITS-REST commit operations; ETag and Location come back for the gateway to pass through (N31)
The node’s system_id and releaseOPTIONS {base}/ and GET {base}/ (ITS-REST system information)
Moving a record between nodesGET /ferroehr/rest/extensions/v1/message/export/{ehr_id} and POST /ferroehr/rest/extensions/v1/message/import?ehr_id=… (EHR Extract & messaging)

The node answers an ITS-REST RESULT_SET (N17). It runs WHERE, ORDER BY, LIMIT and OFFSET locally; merging pages across nodes is the gateway’s work (N9, N39).

Identity

The node is invoked on its local ehr_id alone (N33, N34). It never needs the patient’s identifier in the query: the gateway resolves the patient to {node, local ehr_id} pairs outside AQL, through IHE PIXm or a record locator (FerroPIX). The node’s own cross-reference is the IHE identity source asked over PIXm, or the local linkage map (Demographics).

A node does not have to store a directly identifying identifier in EHR_STATUS.subject (N5). With [privacy] subject_namespaces it keeps only a pseudonym, and the identifier scanner refuses a national identifier where it is configured (Privacy & data minimisation).

Every version keeps its identity across nodes (N21, N22, N36). An OBJECT_VERSION_ID is object_id::creating_system_id::version_tree_id (RM common master06 §Version Identification). An EHR exported from one node and imported on another keeps every version uid and its creating_system_id byte for byte; the receiving node records the import as its own act beside the copied original (RM common master06 §The Copy Operation). The integration test federation_node in app/ferroehr/tests/it/ moves an EHR between two nodes and compares every version uid.

Admission (N42a)

A federation admits a node on four identifier conditions. For three of them, the operator configures a FerroEHR node as follows:

  • A federation-wide unique system_id. The default, ferroehr.local, is the same on every node that leaves it unset. Set [server] system_id to a name no other member uses before the node commits its first version: the system_id is stamped into every version uid and never changes (Server settings).
  • No adoption of a foreign ehr_id on import. POST /message/import without ehr_id re-uses the source EHR’s id (RM common master06 §Copying, Case 1). A federation member imports with ?ehr_id= set to an id it chose.
  • No reuse of an ehr_id. A node never issues an id twice; creating an EHR under an id it holds is refused (409 Conflict, ITS-REST EHR API PUT /ehr/{ehr_id}).
  • ehr_id generation. FerroEHR mints every ehr_id as a UUIDv7 (RFC 9562 §5.7).

Authentication

There is no central FerroHEALTH authorization service. Each node validates the bearer tokens of the deployment’s own authorization server itself, as a SMART App Launch resource server (SMART App Launch), and records the caller in its access log (N24, N25). The gateway propagates the client’s identity; the node decides on it.

What the node refuses

Access decisions are the node’s (N26, N27). Whatever the gateway pre-filtered, the node applies its own controls before it releases data:

  • role and attribute rules, and the per-EHR EHR_ACCESS decision;
  • the restriction of access by health professionals (EHDS Art. 8);
  • a restriction of processing (GDPR Art. 18): a restricted object leaves every query result and export, and a point read of it answers 403;
  • a research objection (GDPR Art. 21(6)): the EHR leaves every population query and export.

A refusal is the node’s answer, which the gateway reports for that node. The run that sets a restriction at a node and shows a federated query never returns the restricted object belongs in FerroFED’s harness (FerroHEALTH/FerroFED#1389).

What this page does not claim

FerroEHR implements no gateway: no patient discovery (IHE XCPD), no cross-node merge, no registry of nodes. Those are FerroFED’s and FerroPIX’s. The node answers ehr_get_by_subject only for an identifier its own cross-reference already holds.

Demographics

Alongside clinical records, a CDR often needs to store the people and organisations those records refer to: patients, clinicians, care teams, institutions. FerroEHR provides a versioned demographic store for the openEHR party types and the relationships between them, over a REST surface that mirrors the EHR APIs.

The demographic endpoints are always mounted (there is no feature switch) and are subject to the same authentication and authorization as the rest of the API. See Security.

Note

The party wire API is defined by the openEHR REST specification’s Demographic API, which carries DEVELOPMENT lifecycle status inside the released specification, and the conformance schedule places demographics in the Options profile tier rather than Core or Standard. Party relationships are the one part with no specification at all: those routes are a FerroEHR extension realizing the service model’s relationship interface, and they are excluded from conformance-profile claims.

What is stored

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.

MethodPathPurpose
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_historyrevision history
GET/demographic/versioned_party/{versioned_object_uid}/versionversion at a time (query parameter)
GET/demographic/versioned_party/{versioned_object_uid}/version/{version_uid}a specific version

Party changes can also be committed and read as contributions (POST /demographic/contribution, GET /demographic/contribution/{contribution_uid}), and parties support item tags (GET /demographic/tags, plus /demographic/{kind}/{uid_based_id}/tags and DELETE …/tags/{key} per party).

A demographic contribution follows the payload rules of the EHR one (see Resources → CONTRIBUTION) and is refused the same way. A contribution without an audit, an audit without change_type or committer, a commit_audit without its own committer, and a version without lifecycle_state are each a 400. A version whose object is not a party or a PARTY_RELATIONSHIP, a lifecycle state outside the openEHR version lifecycle state group, and content that fails validation are each a 422. The body is canonical JSON only: an XML or simplified-format Content-Type is a 415.

Relationships

Party relationships are managed through a parallel set of routes (the FerroEHR extension the note above describes) with the same versioned shape as parties. ITS-REST defines no relationship routes, so they sit under the extensions root, /ferroehr/rest/extensions/v1, beside the openEHR API, and every path below is relative to it:

MethodPathPurpose
POST/demographic/party_relationshipcreate 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_historyrevision 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_relationships is a derived attribute the server leaves unpopulated: it is the computed inverse of relationships, 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 carry PARTY.reverse_relationships: the released generation defines it, so refusing a valid instance of the generation the server advertises would invent a prohibition. The value is accepted and then dropped, because the server derives that attribute itself.
  • Under development, the attribute is not part of the model and a body carrying it is refused as an undeclared field, like any other unknown key.

XML bodies need no such split: undeclared elements are skipped in either profile.

This is an acceptance boundary, not a conversion: nothing is silently translated between generations. If you feed a mixed estate, pick the profile that matches what your clients actually send.

Terminology servers

openEHR records carry coded values: a diagnosis, a route of administration, a laboratory unit. Some codes come from openEHR’s own terminology; others must be checked against an external code system such as SNOMED CT or LOINC. FerroEHR serves the bundled openEHR terminology in-process, and asks one FHIR terminology server, FerroTERM, about every other code system.

The bundled openEHR terminology

The server ships the openEHR terminology bundle (Terminology 3.1.0) and uses it by default, with no configuration and no external dependency. It answers the questions the platform needs while validating and querying: which terminologies exist, whether a code belongs to one, what a term’s rubric is, whether one code subsumes another, and whether a code is a member of a value set.

Enumeration is always the bundle’s job. A lookup or a membership test goes to the bundle when the bundle knows that terminology, and only otherwise to the terminology server.

Exposing the lookups over REST

You can also expose these lookups over a small read-only REST surface. It is a FerroEHR extension (no openEHR REST contract defines a terminology API) and it is off by default; while disabled, every route answers 404 as if it were not mounted. Turn it on with FERROEHR__TERMINOLOGY__API_ENABLED=true and it serves:

MethodPathPurpose
GET/terminologylist terminologies
GET/terminology/{terminology_id}describe one terminology
GET/terminology/{terminology_id}/term/{code}look up a term
GET/terminology/{terminology_id}/subsumes?ref_code=&candidate=subsumption test
GET/terminology/{terminology_id}/value_set/{value_set_id}get a value set
GET/terminology/{terminology_id}/value_set/{value_set_id}/validate?candidate_code=&at_date=test membership

All paths are relative to the extensions root, /ferroehr/rest/extensions/v1, where FerroEHR serves its own extensions beside the openEHR API. An unknown terminology or value set is a 404; a missing required query parameter is a 400.

The terminology server

Note

The CDR is only ever a client of the terminology server. FerroEHR does not implement one: you run a FHIR terminology server and point the CDR at it by URL. The one that ships with the product is FerroTERM, started by the quickstart’s terminology overlay; any other server speaking the FHIR terminology operations (Ontoserver, Snowstorm, HAPI FHIR) works the same way.

The terminology server is off by default, and while it is off nothing is requested: validation uses the in-process bundle alone. The keys live under [terminology.server]; the full table, including the timeout and OAuth2, is on Integrations. The minimum is the switch and the server’s FHIR R4B base URL:

[terminology.server]
enabled = true
url = "http://ferroterm:8080/r4b"

Enabling the server with no URL is a boot error, not a silent fall back to the bundle. So is an incomplete OAuth2 grant: a control you configured either works or the server refuses to start.

One server answers every terminology the bundle does not carry. openEHR puts the resolver of an archetype constraint binding in a “terminology query server” outside the CDR, and FerroTERM serves SNOMED CT, LOINC, ICD and any FHIR CodeSystem or ValueSet from one process, so the CDR holds one client to one server.

What it changes at commit time

The mechanism is openEHR’s own archetype constraint binding. Where a template binds an archetype constraint code (an ac code) to a query against an external terminology, the specification puts the resolver outside the CDR: the archetype holds an identifier for a query, and the query itself is defined in the terminology server.

With the terminology server enabled, committing a composition resolves those bindings for every bound coded value the instance actually carries:

  • The code is in the value set → the commit proceeds.
  • The code is not in the value set → 422, naming the path, the code, and the bound query. That is a real constraint violation, so fail_on_error does not change it.
  • The value set could not be resolved → fail_on_error decides, 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_id travels verbatim as the FHIR system parameter, and no openEHR specification defines a mapping between terminology_id values (SNOMED-CT) and FHIR system URIs (http://snomed.info/sct). If your archetypes and your terminology server disagree on that spelling, align them in the terminology-server configuration. The CDR does not rewrite the value.

What it changes when an ADL 2 archetype is uploaded

An ADL 2 archetype’s term_bindings are checked too (AOM2 validity rule VETDF). A binding target is a URI in the IHTSDO model, …/id/<code>, and the CDR takes it apart before asking: http://snomed.info/id/50121007 (or the snomedct.info host the ADL 2 specification’s own examples use) is asked as system=http://snomed.info/sct, code=50121007, per the SNOMED CT URI Standard; http://loinc.org/id/LA6742-6 as system=http://loinc.org; any other …/id/… URI as the URI before /id/. The outer binding key (SNOMED-CT, Snomed, LOINC, however the author spelled it) is not sent. The server’s answer then decides:

  • the code exists → accepted;
  • the code system is served and the code is not in it → 422, naming the rule, the binding and the code;
  • the code system is not served by the terminology server (or the server cannot say) → accepted with a warning in the log. AOM2 puts it this way: codes for inaccessible terminologies “should be flagged with a warning indicating that no verification was possible”, so a CDR wired at a server that carries only LOINC still stores archetypes with SNOMED CT bindings.

The same rule holds with no terminology server configured at all: nothing is asked and nothing is refused.

Which FHIR operation is used

Membership is tested with ValueSet/$validate-code, one direct yes/no with the least payload; $expand serves a value set’s contents, $subsumes a subsumption test and $lookup a term. Every call is a round trip to the server, which keeps no answer the CDR would have to invalidate.

The CDR speaks to the server through ferrobridge-term, FerroBRIDGE’s FHIR terminology client, over the FHIR R4B operation definitions. Each operation is a POST of a Parameters resource to the operation’s path under the configured base URL, so a code never travels in a URL. The at_date of a term lookup or a membership test is sent as the operation’s date input. Each call is made once, without a retry, so request_timeout_ms bounds the whole call on the commit path.

A response that does not fit the operation’s R4B definition is treated as an upstream fault rather than partially read, and takes the same path as an unreachable server: a $lookup answer without its name or display, an $expand answer without an expansion, a $subsumes outcome outside its four codes, or a body of another resource type. The server’s refusals are read through the tx-issue-type coding of their OperationOutcome: not-found means the server does not serve the code system or value set, invalid-code that the code is not in it, and a 404 or 410 without either coding that the thing asked about is unknown.

Authenticating to the server

A server behind an authorization server takes an OAuth2 client-credentials grant under [terminology.server.oauth]; the token is cached and renewed shortly before it expires. The client secret should come from a mounted file rather than an inline value, client_secret_file. The keys are on Integrations.

There is no option to disable certificate verification. Server-certificate and hostname verification are always on against the platform’s trust store.

When the terminology server cannot answer

fail_on_error decides what happens when a bound value set cannot be resolved at all, whether the server is unreachable, answers an error, or does not know the value set:

  • false (the default, fail-open): the composition is accepted and a warning is logged. The availability of an external service does not block clinical writes.
  • true (fail-closed): the composition is rejected with a validation error naming the unresolved binding.

A code that is resolved and turns out not to be a member is a different matter: that is a constraint violation and is rejected under either setting.

Pick deliberately. Fail-closed means commits stop while your terminology server is down; fail-open means they are accepted unvalidated. There is no third option that gives you both.

FerroTERM beside the CDR

FerroTERM is the terminology server FerroEHR ships with: the docker-compose.terminology.yml overlay of the quickstart starts it beside the CDR and points [terminology.server] at http://ferroterm:8080/r4b, the compose page has the commands. The hosted sandbox at https://sandbox.ferroehr.eu runs the same pair on one machine, so the coded-text binding you see resolving there is a real round trip to a real terminology server.

What the sandbox shows, and how it is arranged:

  • The server is not on the public surface. FerroTERM answers on the sandbox’s private compose network alone; there is no route to it at sandbox.ferroehr.eu, its own browser UI is off, and the CDR is its only caller. You exercise it through the CDR: commit a composition against the cnf.tpl.dv_coded_text_binding_sct template with a code inside its bound value set and it is accepted, with a code outside it and the answer is 422; the /terminology/* routes look codes up; AQL TERMINOLOGY() expands a value set into a MATCHES operand.
  • 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.test domain, with no licensed terminology in them.

SNOMED CT on a public sandbox

Serving SNOMED CT to the public is a licensed activity, and the arrangement above is what the SNOMED CT Affiliate Licence Agreement (April 2023, snomed.org/get-snomed) asks for. Clause 2.2.4 permits systems “made available to the general public for accessing and/or retrieving any part of the International Release and/or data encoded using the foregoing”, provided users “are not able to extract any substantial portion of SNOMED CT” and no fee is charged for access. Clause 2.7 requires “reasonable measures to ensure that the International Release (and any part of it) cannot be accessed or downloaded from the Licensee’s systems except by authorised users”. A raw FHIR terminology endpoint open to anonymous callers would let anyone walk a code system or expand large value sets; per-code operations through the CDR, behind its rate limit, do not.

The sandbox serves the SNOMED CT International Edition, release 20260901 (http://snomed.info/sct/900000000000207008/version/20260901), loaded by the operator under their Affiliate Licence from an index built off the machine. The International Edition rather than a national one: it needs the Affiliate Licence alone (a Member’s national release also needs an agreement with that Member), its English displays fit an international audience, and it is the lighter of the two. Every surface showing that content carries the notice the licence prescribes:

This material includes SNOMED Clinical Terms® (SNOMED CT®) which is used by permission of the International Health Terminology Standards Development Organisation (IHTSDO). All rights reserved. SNOMED CT®, was originally created by The College of American Pathologists. “SNOMED” and “SNOMED CT” are registered trademarks of the IHTSDO.

No SNOMED CT content is in this repository, in any image, or in CI; the index exists only on the sandbox machine. This is a description of the arrangement, not legal advice: the licence text is the authority and the Member’s conditions apply in each territory.

LOINC on the sandbox

The sandbox also serves LOINC version 2.83 (http://loinc.org), from an index built the same way. The LOINC licence (loinc.org/license) grants use and distribution “for any commercial or non-commercial purpose” without fees and names “online terminology services” among the permitted products, on three conditions this deployment meets: the notice below is available where the service’s terms are stated, every LOINC code is shown with one of its LOINC display names (the FHIR operations return the long common name), and the version is stated. Where a LOINC record carries a third-party copyright notice of its own, that notice travels with the record.

This material contains content from LOINC (http://loinc.org). LOINC is copyright © Regenstrief Institute, Inc. and the Logical Observation Identifiers Names and Codes (LOINC) Committee and is available at no cost under the license at http://loinc.org/license. LOINC® is a registered United States trademark of Regenstrief Institute, Inc.

Neither index is in the repository, an image or CI; both exist only on the sandbox machine, beside the licence-free shaped seed the compose profile ships.

Running one locally (development and CI)

The quickest local terminology server is the quickstart’s terminology overlay: FerroTERM beside the CDR, the shaped seed served, the CDR wired, one command and no checkout. The conformance lane runs the same server from a checkout of the repository, with the shaped seed mounted read-only out of docker/terminology/seed/:

docker compose -p ferroehr-cnf --project-directory . --profile terminology \
  -f docker/sut-ferroehr.yml -f docker/sut-terminology.yml up -d --wait ferroehr

The profile starts FerroTERM (host port 8090 by default, FERROEHR_TERMINOLOGY_PORT), which reports healthy only once the mounted code systems and value sets are served, and the CDR waits on that. The overlay file is what points the CDR at it, by switching on the [terminology.server] the development configuration already carries in the disabled state.

None of this touches the downloadable quickstart Compose file on its own, which uses the in-process openEHR terminology only; the terminology overlay is the opt-in that adds FerroTERM to it.

Warning

The seeded content is synthetic and lives under the reserved example.test domain: one hierarchical code system shaped like SNOMED CT and one shaped like LOINC, each with an enumerated value set. It carries no licensed terminology content. Point the CDR at a real server, and for SNOMED CT hold the appropriate licence, for anything beyond experimentation.

Tip

Serving your own content is the same shape: FerroTERM loads every FHIR CodeSystem and ValueSet in the directory FERROTERM_CODESYSTEMS names, and serves them read-only. A value set it does not hold is an unresolved binding, which your fail_on_error setting then decides the fate of.

On Kubernetes

terminology.enabled=true runs FerroTERM in the cluster and wires the CDR at it (FerroTERM on Kubernetes). A server outside the cluster goes through the config passthrough, which renders the keys verbatim into the server’s configuration file (Any server setting is reachable):

# values.yaml
config:
  terminology:
    api_enabled: true
    server:
      enabled: true
      url: https://tx.example.org/fhir
      fail_on_error: true      # fail-closed: an unresolvable binding rejects the commit
secrets:
  # only when the server sits behind an authorization server; the grant itself
  # goes under config.terminology.server.oauth
  terminologyClientSecret: ""

Before you enable it: a reachable terminology server, a decision on fail_on_error, and (if the chart’s default-deny egress policy is on) an egress rule that admits the server, or every call fails as a timeout. To turn it off, set config.terminology.server.enabled: false; validation falls back to the in-process bundle and no external call is made.

Terminology in AQL

Query authors can constrain a match to a value set with the AQL TERMINOLOGY() function: TERMINOLOGY('expand', …) resolves a value set and merges its codes into a matches list at query-analysis time, so the planner sees an ordinary value list. A terminology://… operand in a matches list is expanded the same way. See Querying with AQL for the query surface.

Operations with no defined comparison semantics in AQL are typed rejections rather than silent wrong answers.

Change events (AMQP)

When something is committed to the CDR, downstream systems often need to know: an analytics pipeline, a care-coordination service, a cache invalidator. Rather than have them poll, FerroEHR can publish a small event for every commit to an AMQP 0.9.1 broker (RabbitMQ). The events are built so you can fan them out broadly without leaking clinical data: they carry identifiers and metadata, never the record content.

Delivery guarantees

The publisher is built on a transactional outbox, which is what gives it properties you can design a consumer against:

  • At-least-once delivery. Every commit writes its event row to an outbox table in the same database transaction as the change itself, so no commit without its event, no event without its commit. A background task drains the outbox and marks a row published only after the broker confirms. A crash or a retry may deliver a message more than once, so consumers deduplicate.
  • Ordered draining. Rows are read in global sequence order, and the drainer stops the batch at the first publish failure rather than skipping ahead, so an earlier event for an EHR is not overtaken by a later one from the same drainer. Messages are published persistently to a durable exchange.
  • PHI-free envelopes. The message body carries ids, version numbers, and metadata. To read the actual clinical content a consumer calls back through the authenticated REST API.
  • Commits never wait on the broker. If the broker is down, events accumulate in the outbox and drain when it returns. Published rows are pruned after a retention window, and never past the cursor of an active reader registered in event_outbox_reader: the prune’s floor is the lowest active reader cursor, read in the same statement.
flowchart LR
    commit["commit<br/>(composition / status / folder / party)"]
    tx[("same DB transaction")]
    node["clinical data"]
    outbox["event_outbox row<br/>(published_at = NULL)"]
    drain["outbox drainer<br/>(background task)"]
    broker["AMQP topic exchange<br/>ferroehr.events"]
    consumer["your consumer<br/>(bound queue)"]

    commit --> tx
    tx --> node
    tx --> outbox
    drain -->|"poll pending, publish, await confirm"| broker
    outbox -.->|"drained in seq order"| drain
    broker --> consumer
    consumer -.->|"fetch bodies via authenticated API"| commit

Note

Several server replicas can drain the same outbox safely (each row is claimed exclusively and the others skip it) but that also means two replicas may have different rows in flight at once. Order your consumer on the seq field 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:

FieldMeaning
contribution_idthe contribution this change belongs to
ehr_idthe EHR; null for a demographic contribution, which has no EHR scope
committed_atthe commit instant
versions[]one entry per changed versioned object
seqthe delivery sequence number (monotonic)
version_indexwhich entry in versions this message is for

Each versions[] entry carries:

FieldMeaning
vo_idthe versioned object’s identifier
kindthe full RM type name: COMPOSITION, EHR_STATUS, EHR_ACCESS, FOLDER, or one of the demographic kinds (PERSON, ORGANISATION, GROUP, AGENT, ROLE, PARTY_RELATIONSHIP)
sys_versionthe version ordinal
version_tree_idthe version-tree id, so a branch version is distinguishable from a trunk one
change_typethe 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_idthe 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 in seq order. That handles at-least-once redelivery and keeps ordering at the consumer regardless of how the server side is scaled.

Routing keys and subscriptions

Messages are published to a topic exchange (default name ferroehr.events), with a three-field routing key:

<kind>.<change_type>.<template_id>

For example, COMPOSITION.249.openEHR-EHR-COMPOSITION_encounter_v1. AMQP topic keys use . as the word separator, so a template id containing dots is sanitised (every character outside [A-Za-z0-9_-] collapses to _) and the key always has exactly three fields. When there is no template, the last field is -.

Bind a queue with the usual AMQP topic wildcards to select what you care about: COMPOSITION.*.* for all composition changes, *.523.* for all deletions, # for everything.

The server can also manage subscriptions for you. With the event-subscription admin API enabled (FERROEHR__EVENTS__ADMIN_API), the CRUD routes under /ferroehr/rest/extensions/v1/admin/event_subscription let you store subscription rows, and each enabled row makes the server declare and bind a durable queue named <exchange>.<name> (for the default exchange, ferroehr.events.<name>). Its binding key is built from the row’s kind / change_type / template_id predicates, with a wildcard for any predicate left unset. Topology is (re)declared when the broker connection is established or the enabled set changes, not on every poll, and re-declaring is idempotent, so a broker replaced underneath the server gets its queues back.

Enabling it

Publishing is off by default. The keys live under [events]; the full table with every default is on Integrations. The essentials:

Environment variableDefaultMeaning
FERROEHR__EVENTS__ENABLEDfalsemaster switch
FERROEHR__EVENTS__URLa local development brokerbroker connection URL (credentials are redacted from every rendering)
FERROEHR__EVENTS__URL_FILEunsetread the broker URL from a mounted file instead
FERROEHR__EVENTS__EXCHANGEferroehr.eventstopic exchange name, and the queue-name prefix
FERROEHR__EVENTS__TLSfalseupgrade an amqp:// URL to amqps://
FERROEHR__EVENTS__ADMIN_APIfalsemount the /ferroehr/rest/extensions/v1/admin/event_subscription routes

Batch size, poll interval, publish retries, and the retention window for published rows are tunable too, and their defaults are sensible for a normal deployment. The retention window is a floor, not a schedule: a published row that an active cursor reader has not reached survives the window until that reader passes it, and a reader the configuration switches off is marked inactive at boot so it holds nothing.

Warning

The broker URL carries credentials, so keep it in a secret (url_file reads it from a mounted file) not in a plain environment file. For anything beyond a local broker use TLS (FERROEHR__EVENTS__TLS=true, or an amqps:// URL). Under deployment_profile = "production" the server refuses to start with the outbox on a plain amqp:// broker unless plaintext_broker is accepted by name.

Note

Eventing is also a cargo feature (events), on in the published images and any default build. A slim --no-default-features build contains none of the transport’s code and refuses to boot with events.enabled = true rather than starting quietly without a publisher; see From source → Build features.

What a broker outage looks like

A broker the server cannot reach is a degraded, not a failed, deployment. The events health indicator reports degraded with “event broker unavailable; outbox buffering”, and because it is not a required indicator, readiness still passes: the CDR keeps accepting clinical writes and the outbox keeps growing. Watch the indicator on the health surface rather than the broker alone: see Operations.

On Kubernetes

The chart renders its config tree verbatim into the server’s configuration file, so every key above is reachable as config.events.* (Any server setting is reachable). The broker URL carries credentials, so it goes through secrets.eventsUrl, which the chart mounts as a file and passes by path:

# values.yaml
config:
  events:
    enabled: true
    exchange: ferroehr.events
    tls: true
secrets:
  eventsUrl: "amqps://user:pass@broker.example:5671/%2f"

Before you enable it: a reachable broker, a broker certificate the pod trusts if you set tls: true, and (if the chart’s default-deny egress policy is on) an egress rule that admits the broker. To turn it off, set config.events.enabled: false and upgrade; the outbox stops draining and nothing else changes.

Consuming events

A consumer binds a queue to the exchange and reads. In shell form with the RabbitMQ tooling:

# bind a queue to every composition creation, then consume
rabbitmqadmin declare queue name=my-consumer durable=true
rabbitmqadmin declare binding source=ferroehr.events destination=my-consumer \
  routing_key='COMPOSITION.249.*'

The server declares the exchange itself (durable, topic) the first time it publishes. If you bind before the server has ever published, declare the exchange yourself with the same name and type, or the binding has nothing to attach to.

Each delivery is a JSON envelope as described above. Your consumer records the (contribution_id, version_index) pairs it has seen, and for anything whose content it needs, it calls the CDR’s REST API (for example GET /ehr/{ehr_id}/composition/{vo_id}) with its own credentials. The event tells it what changed; the authenticated API is where it reads the data.

S3 multimedia

Clinical records sometimes carry large binary attachments (scanned documents, images, waveforms) as DV_MULTIMEDIA values. Keeping big blobs inline in the database bloats storage and slows queries. FerroEHR can offload large multimedia blobs to any S3-compatible object store, keeping a small content-addressed reference in the composition, and re-materialize them on demand when a record is read back.

How offload works

Offload is a commit-path transformation applied to DV_MULTIMEDIA nodes anywhere in the tree, including a node’s nested thumbnail, which is itself a multimedia value:

  1. A node qualifies only when it is purely inline (it has data and no uri) 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.
  2. 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.
  3. The node is rewritten in place: inline data is removed and replaced with a uri of the form s3://<bucket>/<hash>, plus an integrity_check (the same SHA-256 digest), an integrity_check_algorithm code phrase (SHA-256), and size set 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_MULTIMEDIA that points at the blob by content hash. Everything stays canonical openEHR JSON: the s3:// 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_check is a byte array, so canonical JSON renders it base64, while the uri spells 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_MULTIMEDIA inline, 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=true still fetches, verifies and re-inlines them, as long as an endpoint is still configured. Content this server externalized does not become unreachable because a switch was flipped.

Warning

Removing the endpoint as 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 answer 200 with 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 flip enabled.

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 variableDefaultMeaning
FERROEHR__MULTIMEDIA__ENABLEDfalsemaster switch
FERROEHR__MULTIMEDIA__THRESHOLD_BYTES262144 (256 KiB)offload blobs larger than this; smaller stay inline
FERROEHR__MULTIMEDIA__ENDPOINTunsetS3 endpoint URL; unset uses default AWS endpoint resolution
FERROEHR__MULTIMEDIA__BUCKETopenehr-multimediatarget bucket
FERROEHR__MULTIMEDIA__REGIONus-east-1S3 region, required even for non-AWS endpoints
FERROEHR__MULTIMEDIA__ACCESS_KEY_IDunsetaccess key id
FERROEHR__MULTIMEDIA__SECRET_ACCESS_KEYunsetsecret key (or …__SECRET_ACCESS_KEY_FILE for a mounted secret)
FERROEHR__MULTIMEDIA__ALLOW_HTTPfalsepermit 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 PUT into a bucket that does not exist with 403 AccessDenied, not 404 NoSuchBucket. So every multimedia commit fails 500 and the log says Access 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-features build contains none of the object-store code and refuses to boot with multimedia.enabled = true rather than silently storing every blob inline; see From source → Build features.

On Kubernetes

Every key above is reachable as config.multimedia.* (Any server setting is reachable); the S3 credentials go through secrets.*, which the chart mounts as files:

# values.yaml
config:
  multimedia:
    enabled: true
    endpoint: https://s3.example.com
    bucket: openehr-multimedia
    threshold_bytes: 262144
secrets:
  multimediaAccessKeyId: "AKIA…"
  multimediaSecretAccessKey: "…"

Before you enable it: the bucket must exist (nothing in the chart creates it) and, if the chart’s default-deny egress policy is on, an egress rule must admit the endpoint. To turn it off, set config.multimedia.enabled: false and leave the endpoint in place so already-externalized content stays readable (see Turning it back off).

Quick setup with SeaweedFS

Any S3-compatible store works: AWS S3, MinIO, SeaweedFS. SeaweedFS is a light option for development and testing: its S3 gateway needs no credentials.

Step 1, create the bucket. The gateway starts with no buckets at all, and nothing in it creates one. Against an unauthenticated development gateway a bare PUT on the bucket path is enough:

curl -X PUT http://127.0.0.1:8333/openehr-multimedia
curl -s http://127.0.0.1:8333/            # the bucket now appears in ListAllMyBuckets

The Compose stack does this for you: the s3 profile brings up the gateway plus a seaweedfs-init service that performs exactly that PUT once the gateway is healthy, reading the same bucket variable the server does so the two cannot disagree. This step is only for a gateway you run yourself.

Step 2, point the server at the gateway and allow plain HTTP for local use:

export FERROEHR__MULTIMEDIA__ENABLED=true
export FERROEHR__MULTIMEDIA__ENDPOINT=http://127.0.0.1:8333
export FERROEHR__MULTIMEDIA__BUCKET=openehr-multimedia
export FERROEHR__MULTIMEDIA__ALLOW_HTTP=true

The same exports drive the Compose stack, which passes the whole FERROEHR__MULTIMEDIA__* set through from your shell; only the endpoint changes, to the in-network hostname http://seaweedfs:8333.

Step 3, check what the server actually took. /management/env reports the effective configuration, which is the quickest way to catch a variable that never arrived:

curl -s -u ferroehr:ferroehr http://localhost:8080/management/env | jq .multimedia

The secret access key is masked there. access_key_id is not masked, and deliberately so: it is an identifier rather than a credential, and seeing it is how you confirm which key the server is actually using.

With the switch on and the bucket present, large DV_MULTIMEDIA values committed through the ordinary composition APIs (see Using the API) are offloaded automatically. Nothing about the request or the stored record changes except the size of what lives in the database.

Note

Base64 inflates a blob by about a third on the wire, and the whole request body is still subject to the request-body limit (rest.body_limit_bytes on the clinical surface, server.body_limit_bytes everywhere); a composition that exceeds it is refused 413 before offload is ever considered. See [server].

Security

A clinical data repository holds PHI, so its access controls and audit trail are part of the product. This chapter covers the four security surfaces you configure when you deploy FerroEHR: authentication (who is calling), authorization (what they may do), the pseudonymisation boundary (which role reads which domain), and the ATNA audit trail (recording what happened). Each is independently configurable, and each is described here in terms of the environment variables you actually set.

This chapter tells you how to configure each control. Its companions tell you the rest: the Threat model states what remains true after each control has done its job: the trust boundaries, the residual risk at each, and what this software explicitly does not defend against; and Verifying releases covers the artifacts themselves: how to establish that the binary, image, or chart you downloaded came from this project’s own build. Read the threat model before you decide a control is sufficient for your deployment.

Configuration follows the same pattern throughout: the server reads defaults, then the single ferroehr.toml file, then environment variables, with __ separating nested keys. The security configuration groups live in distinct sections of ferroehr.toml: [auth] (authentication), [authz] (authorization), [privacy] (the pseudonymisation boundary), and [audit] (the ATNA audit trail). Any key can be overridden with the matching FERROEHR_* environment variable shown below.

Authentication

Authentication is on by default (FERROEHR__AUTH__ENABLED=true). Setting it to false lets all requests through unauthenticated, a development-only mode.

There is no single “mode” switch. The server offers two mechanisms and enables each by the presence of its configuration. Both are checked by ferrohealth-smart, the resource-server crate every FerroHEALTH product shares; FerroEHR checks tokens and never issues them.

  • HTTP Basic is active when [[auth.basic.users]] lists a user. Each user has a user-id, an Argon2id password hash (a PHC string beginning $argon2id$) or a password_hash_file pointing at one, and the SMART on openEHR scopes it holds, and its RBAC roles (a user with none holds no role). Because it is a list of users, the Basic store is normally supplied through the TOML configuration file rather than environment variables.
  • OAuth 2.0 bearer tokens are active when [auth] names an issuer. The server reads the issuer’s metadata and key set at boot and validates every token’s signature (asymmetric algorithms only), issuer, audience, lifetime and type under RFC 9068.

Both mechanisms are validated at startup, and a configuration the server cannot honour refuses to boot rather than degrading at the first request:

ConfigurationBoot outcome
auth.enabled = true with no mechanismerror: a 401 challenge must name a scheme the server implements (RFC 9110 §11.6.1)
an issuer with no audienceerror: a server with no declared audience cannot reject another server’s token (RFC 9068 §4, RFC 8725 §3.9)
an issuer not https (plain http is accepted on a loopback host only), carrying a query or fragment, or a user name or passworderror: RFC 8414 §2, §6.2; OpenID Connect Core 1.0 §1.2
an issuer whose metadata or key set cannot be readerror: the start is refused rather than the first request
clock_skew_seconds above 300error: leeway may be “no more than a few minutes” (RFC 7519 §4.1.4)
algorithms naming an HS* algorithm, or emptyerror: the server accepts asymmetric algorithms only (RFC 8725 §3.1, §3.2)
a jwks_file holding a symmetric key or a private-key membererror: a verifier holds public keys only
a product_scope that is neither a full URI nor starts with __error: SMART App Launch 2.2.0, Scopes and Launch Context, §Extensibility
a password_hash below m=19456,t=2,p=1 argon2iderror: the OWASP Argon2id floor
[authz.assurance] minimum with an empty levels map, or with no mapped value at or above iterror: every patient-data request would be refused
an [authz.assurance] levels value that is blank or carries whitespace, a quote or a backslasherror: the step-up challenge names the values in one quoted acr_values parameter (RFC 9470 §3)
[authz.professional] with a blank claim or acting_for_claimerror: nothing to read the person from

Successfully verified Basic credentials are cached for FERROEHR__AUTH__BASIC__CACHE_TTL_SECONDS (default 0, which checks every request) so a busy client pays the deliberately expensive Argon2 verification once per TTL instead of on every request. The cache names each entry by a keyed hash of the credential, never the password, and an entry exists only after a successful verification; the TTL bounds how long a removed user can still authenticate, exactly like a session lifetime.

The bearer settings:

Environment variableDefaultMeaning
FERROEHR__AUTH__ISSUERunset (required for bearer tokens)expected iss, and where the metadata is read: RFC 8414 §3 first, OpenID Connect Discovery 1.0 §4 after a 404
FERROEHR__AUTH__AUDIENCEunset (required with an issuer)accepted aud values
FERROEHR__AUTH__ALGORITHMSRS256,RS384,PS256,PS384,ES256,ES384accepted signing algorithms, asymmetric only
FERROEHR__AUTH__TOKEN_PROFILErfc9068rfc9068 requires typ at+jwt and the client_id, iat and jti claims; jwt admits a plain JWT for an issuer that mints no RFC 9068 tokens
FERROEHR__AUTH__CLOCK_SKEW_SECONDS60leeway on exp, nbf and iat; capped at 300
FERROEHR__AUTH__JWKS_FILEunseta static JWK Set of public keys, read instead of the issuer’s metadata
FERROEHR__AUTH__JWKS_MAX_AGE_SECONDS600a key set older than this is read again
FERROEHR__AUTH__JWKS_REFRESH_INTERVAL_SECONDS60an unknown kid reads the key set again at most once per interval
FERROEHR__AUTH__TIMEOUT_MS10000one call to the authorization server
FERROEHR__AUTH__REALMferroehrthe realm every challenge names
FERROEHR__AUTH__PRODUCT_SCOPE__ferroehr.platformthe scope that admits the ADMIN and DEMOGRAPHIC APIs

There is no separate key-set URL to set: the server reads the jwks_uri from the issuer’s metadata unless you supply a JWKS_FILE. A rotated key is picked up on the first token that names it, bounded by the refresh interval, and a withdrawn key stops verifying within the max age.

Every route also enforces its SMART on openEHR requirement: a valid token or Basic user whose scopes do not grant the operation is refused with 403. See SMART App Launch.

Tip

Keycloak example. Point the issuer at your realm and let discovery do the rest:

export FERROEHR__AUTH__ISSUER=https://keycloak.example/realms/ferroehr
export FERROEHR__AUTH__AUDIENCE=ferroehr-api
export FERROEHR__AUTH__TOKEN_PROFILE=jwt

Keycloak’s access tokens carry typ: JWT, outside the RFC 9068 profile the server expects by default, hence token_profile = "jwt". The client’s default client scopes put the SMART on openEHR scopes the routes need into each token’s scope claim.

The same pattern works for Active Directory or any standards-compliant identity provider; walkthroughs for Entra ID and AD FS (and the answer for plain-LDAP directories) are in Enterprise identity providers. User accounts, roles, and lifecycle are administered in the IdP; the CDR has no user API.

An unauthenticated request to a protected route is refused with 401; an authenticated request that lacks the required role or scope is refused with 403. Two outcomes are neither: a malformed Authorization header, or two of them, is a 400 (the server never read a credential), and an unreadable key set is a 503 (no token can be validated, so the server cannot decide; it is not a statement about the caller’s credential). The per-status table for client authors is in Using the API.

The 401 body deliberately says nothing about why, and neither does the challenge: every token that does not hold gets the one invalid_token answer (RFC 9068 §4), with no error_description. Rendering the rejection would tell an unauthenticated caller whether a token was expired or forged, which is exactly the distinction an attacker probes for; the reason stays in the debug log, where the operator can read it and the caller cannot. A 403 names no scope either, in its challenge or its body. A request that carried no credential at all, or one for a scheme the server does not admit, gets no error code: it has not made a mistake yet (RFC 6750 §3.1).

Assurance level and the natural person on patient data

Two optional blocks under [authz] judge how a professional authenticated and who stands behind a token. Both are off by default; with neither configured nothing below changes the wire.

[authz.assurance] reads the authentication assurance level from a token claim (claim, default acr) and maps each value your identity provider emits to an eIDAS level (levels: low, substantial or high). With minimum set, a patient-data request whose token maps below it, carries a value the map does not name, or carries no such claim is refused with 401 and the OAuth 2.0 step-up challenge of RFC 9470 §3:

WWW-Authenticate: Bearer realm="ferroehr", error="insufficient_user_authentication",
  error_description="patient data requires authentication at assurance level substantial or higher",
  acr_values="http://eidas.europa.eu/LoA/high http://eidas.europa.eu/LoA/substantial"

acr_values lists every mapped value at or above the minimum, so a client can send the user back to the identity provider with that request. EHDS Annex II 3.1 asks an EHR system used by health professionals for reliable identification and authentication of them; Implementing Regulation (EU) 2026/2099 Art. 6(3) sets substantial for cross-border exchange, and high from 26 March 2032.

[authz.professional] names the claim that identifies the natural person (claim, default sub). A token is a client token when it lacks that claim, or, under the default client_tokens = "sub_is_client", when its sub equals its client_id or azp (RFC 9068 §2.2 has sub name the client when no resource owner is involved). With the block present, a client token is refused on patient data with 403, unless acting_for_claim names a claim the token carries the professional’s identifier in (a dotted path reaches into a JSON object, such as act.sub). The access record then names that professional; see Audit trail.

What counts as patient data: every API route except the definition family (/definition/...: templates, archetypes, stored-query texts), terminology, the aggregate counts under /admin/report, and the admin routes that manage configuration, event subscriptions, the retention policy, templates and stored queries. The list of exceptions is closed, so a route added later is judged patient data until it is listed. The health family, /rest/status, discovery and /management are outside the API and never judged.

Basic credentials carry no assurance level and no issuer-asserted person. With minimum set a Basic request for patient data gets the same 401 step-up challenge, and with [authz.professional] set it gets the 403; definition routes stay reachable with Basic.

The assurance level is judged before RBAC, so a caller below the level learns it must step up rather than that it lacks a role. A client token acting for a professional is held to the same level as any other token: the acr it carries is the one judged, so a client-credentials token needs your authorization server to put the professional’s assurance level on it.

Two limits worth planning around

Plain-HTTP issuers are accepted on a loopback host only. The server reads the issuer’s metadata and keys over TLS, because over plain HTTP an attacker on the network could serve their own signing keys (RFC 8414 §6.2); there is no switch that admits a plain-HTTP issuer elsewhere.

Revocation latency equals the access-token lifetime. Tokens are validated offline against the issuer’s published keys; the CDR calls no introspection endpoint (RFC 7662 defines that mechanism but does not require a resource server to use it), so a token revoked at the identity provider stays acceptable here until its exp passes. That is deliberate: introspecting per request would put the identity provider’s availability in the request path, and caching the results only shortens the lag. The control is therefore the token lifetime, which your authorization server owns: keep access-token lifetimes short (minutes, not hours) if prompt revocation matters, and use refresh tokens for session length. clock_skew_leeway_seconds adds at most its own value on top of exp.

Authorization

Authorization has three composable layers. The per-EHR EHR_ACCESS gate is the openEHR-specified base and is always on; the coarse role layer is active when authentication is enabled; the fine-grained attribute layer is opt-in. A request must clear every active layer. Deployments serving SMART apps can enable a fourth, token-scope layer on top; see SMART App Launch.

Per-EHR access control (EHR_ACCESS)

Every EHR carries a versioned EHR_ACCESS object, the openEHR access-decision authority for that record. A new EHR has no settings, and what that admits is a server-wide choice:

authz.rbac.ehr_access_defaultAn EHR with no settings
open (default)reachable by any caller the coarse layers already admitted
restrictedreachable 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> or role:<role> (matched against the caller’s roles). Everyone else gets 403.
  • 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. full access has no ceiling; restricted_below uses the entry’s max_level; a caller with no access-list entry gets default_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_ACCESS version (via a CONTRIBUTION; there is no dedicated EHR_ACCESS endpoint in the openEHR REST API). Changes are versioned and audited like all record content.

The scheme is a FerroEHR extension: openEHR mandates the EHR_ACCESS object and its change control but publishes no concrete access-control scheme. The per-EHR decision also holds for AQL, ad-hoc and stored queries alike: an EHR the caller may not read contributes no rows, whether the query names it in its WHERE clause, in an EHR predicate, through the ehr_id parameter, or not at all, as in a population query. Query results are not filtered by privacy level.

Restriction of access by health professionals (EHDS Art. 8)

Regulation (EU) 2025/327 Art. 8 gives a person the right to restrict the access of health professionals and healthcare providers to all or parts of their data, and has the fact of the restriction stay invisible to healthcare providers. Art. 11(5) lets the restricted data be reached where necessary to protect the person’s vital interests, logged so the person can see it. No openEHR specification governs this; it is our own design.

  • Recording it. The controller records a restriction on the person’s instruction with POST /ferroehr/rest/extensions/v1/admin/access-restriction, for a whole EHR or for one versioned object, and lifts it with POST /ferroehr/rest/extensions/v1/admin/access-restriction/lift (the routes). A whole-EHR restriction covers data recorded after it too.
  • What a professional sees. Restricted data reads as absent: a restricted composition or EHR answers 404 exactly as an unknown one does, a query leaves it out at every scope (and a query scoped to a restricted EHR answers as for an unknown EHR), an export skips it, a CONTRIBUTION lists none of its versions, the event stream withholds it, and a write to it is refused as a write to an unknown target. No status, header or count says that a restriction exists.
  • What it does not change. Nothing is deleted, and storage and the controller’s own processing go on: the admin routes (dump, archive, erase, the registers) see everything. It is not the GDPR Art. 18 restriction of processing, which answers 403 and stops processing itself.
  • The emergency override. A caller holding the role authz.rbac.emergency_access_role names that declares one of [audit] emergency_purpose_codes reads the restricted data, and its access record carries the emergency mark. Either alone lifts nothing. Unset, nobody may override.
[authz.rbac]
emergency_access_role = "EMERGENCY"

[audit]
purpose_codes = ["TREAT", "ETREAT"]
emergency_purpose_codes = ["ETREAT"]

The grain is the versioned object, the whole EHR, or both. A finer grain (one entry inside a composition) is not built: an object is the unit the version reads, the queries and the exports already decide on, and splitting a composition would change what the stored, signed version holds.

RBAC (role-based, coarse)

Every openEHR operation is classified as Public, Clinical, or Admin, and a role model gates each class. Roles are plain, case-insensitive strings; the defaults are USER and ADMIN.

Environment variableDefaultMeaning
FERROEHR__AUTHZ__RBAC__ENABLEDtruethe coarse role gate (active only when auth is enabled)
FERROEHR__AUTHZ__RBAC__ADMIN_ROLEADMINrole required for admin operations
FERROEHR__AUTHZ__RBAC__READONLY_ROLEREADONLYrole marking a principal read-only: refused on every write
FERROEHR__AUTH__ROLE_CLAIMS["roles","groups","entitlements"]JWT claim paths mined for roles; a Keycloak issuer adds realm_access.roles
FERROEHR__AUTHZ__RBAC__SUBJECT_AUDIT_ROLEunsetrole that reads the access log for ONE subject at a time (GET /fhir/r4/AuditEvent with patient required). Unset, that log stays admin-only. See Audit trail

Roles come from the JWT claims listed in ROLE_CLAIMS, or from a Basic user’s configured roles. The defaults are the carriers RFC 9068 §2.2.3.1 names for conveying authorization state (roles, groups, entitlements, of which roles and entitlements are SCIM attributes), the default every FerroHEALTH product shares. A claim path may be dotted, so an issuer that nests them differently, such as Keycloak’s realm_access.roles, is configuration rather than a code change; a claim carrying a single string and one carrying an array are both accepted.

A Clinical operation needs at least one role of any name; an Admin operation needs the admin role. A Basic user holds the roles its roles list names and nothing else, so a user with none is refused every Clinical operation; roles = ["USER"] satisfies the Clinical class and no Admin operation. A bearer token whose SMART on openEHR resource scopes are all system/ scopes, such as system/template-*.r, is a backend client and needs no role for a Clinical operation: its scopes decide what it reaches. A user/ or patient/ token belongs to a person and still needs a role. The former user_role key is refused at boot. Disabling RBAC restores authentication-only behaviour.

Important

The management surface is not configured here. /management/* is governed entirely by [management.endpoints], one level per endpoint, and nothing under [authz.rbac] changes it. There is no global default beside it: an endpoint you do not name is off and is not mounted at all.

Each level means: off, not mounted, answers 404; private, any authenticated principal; admin_only, authenticated and holding authz.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 a public endpoint 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 scope claim 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, since openid alone 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 (or FERROEHR__AUTHZ__RBAC__ENABLED=true) and give each principal an explicit roles list.

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 variableDefaultMeaning
FERROEHR__AUTHZ__ABAC__ENABLEDfalsemaster ABAC switch
FERROEHR__AUTHZ__ABAC__ENGINEcedarcedar (embedded) or remote (external PDP)
FERROEHR__AUTHZ__ABAC__ORGANIZATION_CLAIMorganization_idJWT claim for the organisation attribute
FERROEHR__AUTHZ__ABAC__PATIENT_CLAIMpatient_idJWT claim for the patient attribute (enables the subject gate)
FERROEHR__AUTHZ__ABAC__CHECK_DIRECTORYfalsealso 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_SECSoffoptional policy hot-reload interval
FERROEHR__AUTHZ__ABAC__REMOTE__SERVER— (required for remote)PDP base URL (must end with /)
FERROEHR__AUTHZ__ABAC__REMOTE__CONNECT_TIMEOUT_MS2000PDP connect timeout
FERROEHR__AUTHZ__ABAC__REMOTE__REQUEST_TIMEOUT_MS5000PDP request timeout

Two engines sit behind one interface. Cedar is the embedded default: policies live in *.cedar files, are schema-validated at boot against a shipped schema built from the resource-kind and access-mode sets themselves (an invalid policy set stops the server rather than silently denying), and need no external service. The remote PDP option consults an external policy server over HTTP for deployments that already run one; it additionally requires an [authz.abac.policy.<kind>] binding for every resource kind it will be asked about, and a missing one is a boot error rather than a first-request surprise.

A policy sees the caller, not just the request: the authenticated subject, its roles (as the role layer above resolved them), its scopes, the resolved organization and patient, the resource’s patient, template, category and origin, and the operation id. So a rule can be written about one caller, a role, a scope, or a single operation; the shipped example policy shows a role-keyed break-glass permit and a scope-keyed write restriction.

Category and origin (EHDS Annex II 3.4 asks for access rights that take the origins and categories of the data into account). On a composition read, write or delete, a contribution and a query, resource.category holds each EHDS category of the data, spelt as the access record spells it: the <system>|<code> token of a priority or national category, [audit.categories] classifying the template, then the root archetype; none; or unclassified. resource.origin holds each origin the data came from, as the version recorded it at commit: a FEEDER_AUDIT originating-system id, the registered origin of the committing client, or this server’s system_id (keying on an origin declares the register). A write is decided on what its body names: its template’s category and the origin the commit will stamp. A FLAT or STRUCTURED write is converted only after the check, so it carries the category its template header implies and no origin attribute. A remote PDP receives both as the category and origin parameters of [authz.abac.policy.<kind>].

// Laboratory reports fed by the lab are readable by the lab's own staff only.
forbid (principal, action == Action::"composition.read", resource)
when {
  resource has category && resource has origin &&
  resource.category == "http://hl7.eu/fhir/health-data-api/CodeSystem/eehrxf-document-priority-category-cs|Laboratory-Reports" &&
  resource.origin == "lab.example" &&
  !(principal has organization && principal.organization == "lab")
};

A request whose patient, template, category or origin resolves to several values is evaluated over the full cartesian product of them, and every combination must permit; the first deny short-circuits. A request that resolves to no combination at all permits vacuously, because there is nothing to decide about.

Warning

Authorization is fail-closed in two distinct senses, and the difference shows up in the status code.

Nothing permits by omission: a gate reached without an authenticated caller refuses, an unconfigured resource kind on the remote PDP denies (and the missing rule is a boot error), and Cedar is deny-by-default with forbid overriding permit. A denied decision is a 403.

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 a 500: never a silent permit, and never a 403, which would claim a decision was made. (Cedar skips a policy that errors and reports it in its diagnostics; ignoring those would let an erroring forbid quietly stop forbidding.) A 4xx from 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):

HeaderValueWhy
Cache-Controlno-storeresponses 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-Optionsnosniffstops a proxy or browser re-guessing application/json as something executable
Referrer-Policystrict-origin-when-cross-originrequest paths carry ehr_id and version identifiers; this keeps them out of cross-origin Referer headers
Cross-Origin-Resource-Policysame-siterefuses cross-site embedding of API responses
X-Frame-OptionsDENYfor the HTML this origin can serve (Swagger UI)
Content-Security-Policydefault-src 'none'; frame-ancestors 'none'the defensive minimum, applied wherever a response does not set its own; Swagger UI needs a real policy and brings one. The cheat sheet is explicit that CSP “might be meaningless in the response of a REST API that returns content that is not going to be rendered”, so this is not a policy pretending to govern scripts: it says nothing loads and nothing frames

X-XSS-Protection and X-Powered-By are absent because the cheat sheet says to remove them, and no Server header is sent at all.

Strict-Transport-Security is deliberately not sent by the API server. It is a property of the TLS edge, and RFC 6797 §7.2 requires a browser to ignore it over plain HTTP, which is how this server is commonly reached behind a terminating proxy. Set it at the proxy or ingress that owns TLS; sending it from here would be inert at best and misleading at worst.

The viewer carries the FerroHEALTH console set instead, because it serves HTML: a CSP that admits scripts and styles from its own origin alone (script-src 'self'; style-src 'self', no nonce, no inline script or style), Referrer-Policy: no-referrer and Cross-Origin-Opener-Policy: same-origin (the viewer chapter lists them). Its Cache-Control has one scoped exception to no-store: FerroUI’s stylesheet, theme and scripts under /ferrohealth-ui/<version>/ are served no-cache, so the browser keeps them and revalidates them, and they hold nothing clinical (the viewer reaches the CDR from its own server, never from the browser). Every document carries no-store, because documents carry patient data. An asset response that is not a served body (a 404, a redirect) is never kept either.

The published documentation site cannot carry response headers at all: it is static files on GitHub Pages, so its policy travels as a <meta http-equiv> element, which is weaker by specification (frame-ancestors, report-uri and sandbox are ignored in meta form). Anyone re-hosting these docs behind a real web server should send the headers properly instead.

Request limits and rate limiting

Four different protections, four different statuses, and an operator should be able to tell them apart from the status alone.

Connection bounds: [server] header_read_timeout_ms. The limit that applies before a request exists, because a client that opens a socket and trickles headers, or sends nothing at all, reaches none of the others: a client that has not sent its request head in time, counted from accept, is disconnected, on the plain and the TLS listener alike. On a kept-alive connection the bound counts from the first byte of each later request head, and [server] idle_timeout_ms (120 s by default) closes a connection that waits that long for its next request. Keep it longer than the idle timeout of your clients (90 s for a Go client) and of any load balancer in front, so they close an idle connection first.

Body size: [server] body_limit_bytes and [rest] body_limit_bytes. Two tiers: the routes that accept bulk by design (template upload and /message/import) take the listener’s limit, and the clinical surface the narrower [rest] one when it is set. Over-limit is 413. The listener’s default is the family’s 1 MiB; a deployment that uploads operational templates raises it (the chart and the Compose files set 64 MiB), and one whose compositions embed large DV_MULTIMEDIA data raises the clinical tier deliberately.

Request rate: [server.caller_rate], unset by default and set by the chart and the Compose quickstart. It sits inside authentication, keyed on the authenticated subject, because a hospital behind one NAT is a single address and address-keying a clinical API would throttle a whole site for one busy client. Refusal is 429 with Retry-After.

Concurrency: [server] max_concurrent_requests, the admission bound. Refusal is 503 with Retry-After; the health routes are never refused.

So: 503 means the server is full right now, 429 means you are asking too fast, 413 means your payload is too big. Full key tables are on the configuration page.

If you benchmark this server, turn the rate limiter off first, or you will measure the limiter. Our own measurement lanes compose an overlay that disables it, and both instruments refuse to write a record if the server answered any 429: a performance number that is really a configuration key is worse than no number.

Operational surfaces: what is reachable, and by whom

Everything reachable without a credential is listed here, with the reason it is public. Every other route answers 401 to a request without one, and a test drives every mounted route to keep this list closed. A public answer says only what a client or probe needs; the detail behind it is on the authenticated /management surface.

SurfaceDefaultWhy it is public, or how it is gated
/health, /health/livenessalways on, unauthenticatedOrchestrator probes carry no credential. Liveness answers {"state":"up"} with no I/O behind it; /health answers the readiness status with the state alone.
/health/readinessalways on, unauthenticatedAn orchestrator probe: 200 while ready, 503 while the process boots or drains or an indicator is down. The body is the state, the phase and each indicator’s name and state, nothing more. The detail is /management/health.
{rest root}/statusalways on, unauthenticatedA documented client contract: status, server version, ITS-REST version, timestamp and the deployment profile. The licence, the deployment gaps and the support period are /management/status.
{rest root}/.well-known/smart-configurationmounted only with SMART onSMART App Launch service discovery, read before the client holds any token (ITS-REST SMART App Launch, Service Discovery).
OPTIONS {base path}always on, unauthenticatedThe ITS-REST System API’s options manifest, which its released OpenAPI document declares without security.
{rest root}/swagger-ui, {rest root}/api-docs/*private (rest.swagger_ui)Public only if you set public; admin_only and off are the other levels.
/management/*not mounted at all (management.enabled = false)With the master switch off, every route is 404.
/management/{info,metrics,prometheus,env,loggers,flamegraph,health,status}each off individuallyEven 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.portunset (shares the API listener)Set it to serve ops introspection from its own listener on its own port. It binds all interfaces and always stays plain HTTP even with [server.tls] on, so treat it as an internal surface and keep it off any publicly routed port; the interface half of the separation is your network’s, not this key’s. Under deployment_profile = "production" setting it opens the plaintext_listener gap, so the server refuses to start unless the gap is accepted by name.

env and flamegraph deserve particular caution: env renders the effective configuration (redacted, but still configuration), and flamegraph starts a CPU profiler on request, which is both a disclosure and a denial-of-service lever. Both are off until you name a level, and the profiler additionally caps the window and sampling frequency, refusing an out-of-range request rather than clamping it silently.

Secrets: mount files, never bake values

Every secret this server reads has a *_file sibling, and the loader reads and trims the file at startup. Exactly one of a pair may be set; both is a boot error naming the pair:

SecretFile sibling
the database DSN, per storage domain, and the credential that prepares the schemadb.url_file, storage.<domain>.url_file, db.migrate_url_file
a Basic user’s Argon2 hashauth.basic.users[].password_hash_file
the PGP signing-key passphrasesigning.key_passphrase_file
a terminology OAuth2 client secretterminology.server.oauth.client_secret_file
the object-store secret keymultimedia.secret_access_key_file
the AMQP URL of the change eventsevents.url_file
the national-identifier root keydemographic.identifier_protection.key_file

(The TLS certificate_file / client_ca_file settings and a static key set’s auth.jwks_file are paths by nature and have no inline form at all, which is the same property arrived at from the other direction.)

That is deliberately the shape Docker Secrets and Kubernetes Secrets deliver: a file mounted into the container. So the recommended posture needs no extra machinery.

services:
  ferroehr:
    environment:
      # Point the key at the mount path; the VALUE never appears anywhere.
      FERROEHR__SIGNING__KEY_PASSPHRASE_FILE: /run/secrets/signing_passphrase
    secrets:
      - signing_passphrase

secrets:
  signing_passphrase:
    file: ./secrets/signing_passphrase.txt   # or `external: true` in swarm

Why files rather than environment variables: an environment variable is readable from /proc/<pid>/environ by anything in the container’s namespace, appears in docker inspect output, is inherited by every child process, and is routinely captured whole by crash reporters and process listings. A mounted file is none of those, and it can be rotated without recreating the container.

Two properties worth knowing because they are not obvious:

  • Redaction is a property of the type, not a list. Secret-bearing fields are a Secret/SecretUrl newtype whose Debug and serialization render ***, so a new secret key cannot be forgotten by a per-endpoint redactor: /management/env and ferroehr config check show *** because the type does, not because something remembered to hide it. A Basic password hash in [auth] is a secrecy secret of the shared crate’s section, rendered *** the same way.
  • A Kubernetes Secret is 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 ferroehr user, because a self-contained demo file cannot reference a secret you do not have. That is the one place a credential appears in our own artifacts, and it is a development credential by construction; replace it before any real use.

Verifying what you pulled

Release binaries, container images and the Helm chart carry signed build provenance, SBOMs and checksums, so you can establish that the bytes you are about to run came from this repository’s own pipeline and see what went into them. The commands, the SBOM formats, the SLSA levels claimed per artifact, and the published VEX justifications for scanner findings are all in Verifying releases. Enforcing image provenance at admission time inside a Kubernetes cluster is covered separately, in Images: build, provenance, scanning.

The pseudonymisation boundary

The pseudonymisation boundary separates a record from the person it is about, and it is enforced by PostgreSQL grants rather than by the server’s own routing: code that reaches for the wrong schema is a bug that can be fixed, while a database role able to read two domains defeats the separation however correct the code is.

GDPR Art. 4(5) defines pseudonymisation as processing where attributing data to a person requires additional information “kept separately and subject to technical and organisational measures”, and Art. 32(1)(a) names it a security measure for health data. EDPB Guidelines 01/2025 require that separation to hold against internal actors, operators with database access included.

Three domains hold the three parts, each behind its own role:

flowchart LR
    server["FerroEHR server"]
    server -->|ferroehr_clinical| ehr[("clinical<br/>versions and nodes,<br/>keyed by an opaque subject pseudonym")]
    server -->|ferroehr_party| demo[("party<br/>parties, and national identifiers<br/>sealed under a per-domain key")]
    server -->|ferroehr_linkage| link[("linkage<br/>which party is the subject<br/>of which EHR")]
    server -->|audit writer| audit[("audit<br/>ATNA record repository")]
    ehr -. barred .- demo
    ehr -. barred .- link
    demo -. barred .- link

Read one domain and you hold a clinical record whose subject is an opaque identifier, or a set of people with no records attached, or a table of two identifier columns naming neither. Only the three together re-identify anything, and no credential the server uses holds more than one of them. The migrations create the roles, grant each its own schema, and revoke every other domain explicitly, in both directions; each role is NOINHERIT and a member of no other, so a grant cannot arrive through a membership.

The boundary is checked at startup, not assumed: the server reads the catalogue for every table, view, sequence and function each role can reach in a domain it does not own, and refuses to serve if it finds one, naming the role and the object. A misconfigured grant is a failed boot, not a silent weakening.

Two limits are worth stating plainly. The clinical schema holds a pseudonym only when [privacy] subject_namespaces declares the namespaces a subject reference may draw from — left unset, whatever a client sends is what gets stored, and no schema split saves you from a national identifier written into the clinical side; see Privacy and identifiers. And the split is a schema split by default: giving the demographic domain its own [storage.party] url is what turns it into a credential split, which is the deployment step described in Operations. The linkage domain takes its own credential from [storage.linkage] url the same way.

Resolving across the boundary

One question needs all three domains at once: which record belongs to this person? The server answers it in exactly one place — the linkage service — and answers it in the application, over two connections, never in the database.

An external identity (a national identifier, say) is matched against the sealed party.national_identifier map by keyed digest, which returns a party without decrypting anything. That party is then looked up in linkage.subject_ehr on a second connection, with a second search path and, when [storage.linkage] url is set, a second database role. No statement performs the join, because no credential could: the linkage role holds no grant in demographic and the demographic role holds none in linkage, both revocations are explicit and in both directions, and the boot check refuses to serve a database where either has been given one. The crossing exists as an application step that can be audited, rather than as a query anyone holding one password could write.

Every resolution — and every write that opens, merges or splits a mapping — records an access event in the linkage domain, naming who asked, the purpose of use they declared, and whether anything matched. A miss is recorded like a hit: it says someone asked whether this deployment holds a record for that person. The event deliberately does not name the EHR that came back. The audit trail lives in its own schema, outside the linkage role, so a record pairing a party with its EHR would be a second copy of the map the split exists to hold apart.

Merges and splits are period-closing writes. A mapping that stops being true gets an end date rather than a deletion, and the linkage role holds no DELETE privilege to delete it with. “Which party was the subject of this EHR when that composition was written” therefore still has an answer after two person records have been merged. The database enforces one mapping in force per party with a temporal primary key, so the rule holds against any code path, not only the intended one.

Erasure is the one exception, and the role still cannot perform it: physically deleting an EHR calls a SECURITY DEFINER function that removes the rows naming that one EHR id, in force or historical. A row that survived would go on asserting whose record an erased EHR was.

Looking an EHR up by subject (GET /ehr?subject_id=…&subject_namespace=…) does not go through any of this. That operation matches the EHR’s own EHR_STATUS.subject.external_ref, which is the opaque pseudonym the privacy layer already constrains, so it resolves a pseudonym rather than an identity and consults no map. It records a linkage-domain access event all the same, because who resolved a subject to a record is worth knowing wherever it happened.

The lookup puts the subject identifier in the URL, because ITS-REST passes it as a query parameter. FerroEHR keeps it out of everything it writes: the request log and every trace span record the path and the matched route without the query string, and the 404 for an unknown subject names only the namespace. A reverse proxy or load balancer in front of FerroEHR sees the whole URL, so configure it not to log query strings on this route, or not at all. FerroPIX as the EHR index provider (IHE PIXm, planned in #3380) resolves a patient identifier to an EHR without putting it in a FerroEHR URL.

Declaring the posture

The separations above are each a configuration key, and a deployment that has made none of them is indistinguishable from one that has made all of them until something goes wrong. The top-level deployment_profile key (configuration) makes the posture explicit: production refuses to start while a separation is missing and not accepted by name, and reads the cluster each pool reached from pg_control_system() rather than trusting the DSN text; sandbox, the default, names every missing separation on the banner, in the boot log and on GET /management/status, reports the profile on GET /rest/status, and must not hold real patient data. The database keeps its own line of defence for the subject pseudonym too: once privacy.subject_namespaces is declared, a trigger on ehr refuses a subject reference that is not a UUID, whichever code path or session writes it.

One instance, one organisation

FerroEHR is single-tenant, and that is where openEHR puts the boundary. BASE architecture_overview/master06-design_of_the_ehr.adoc §The EHR System defines a system as “a distinct logical repository corresponding to an organisational entity that is legally responsible for the management and governance of the healthcare data contained within”, and says it is “distinct from any underlying virtualisation infrastructure or cloud computing facility, which may house multiple logical EHR systems in a multi-tenant fashion”. Multi-tenancy belongs to the layer that hosts several systems, not inside one of them.

So several organisations are served by several instances: one instance, one database, one set of domain roles each. That is a stronger boundary than a row predicate — a defect in a query cannot cross it, because there is nothing to cross — and it is what the §System Identity rule needs, since system_id “becomes embedded in the version identifiers of committed – and possibly signed – content” and “cannot easily be changed afterwards”. One system_id shared across organisations mints version identifiers that cannot tell the responsible parties apart.

There is therefore no tenant column on any relation, no row policy, no session variable to set and no [tenancy] configuration. What separates the clinical record from the identity of its subject is the pseudonymisation boundary above, which is a different question and stays.

Version signing

Every version the server commits can carry a VERSION.signature, computed inside the write transaction over the canonical form of the version itself. Signing is on by default in digest mode, and read-time verification of the server’s own signatures defaults to strict, so a served version that no longer matches its stored signature is a 500 rather than a silently served record.

The chapter Version signing covers the mechanism in two pages: Digest signing (what is signed, when, what the stored value proves, and how to reproduce it yourself) and PGP signing (key configuration and rotation, client-supplied signatures, and the signature an import wrapper carries). The [signing] keys are in the configuration reference.

ATNA audit trail

Separately from openEHR’s own provenance, FerroEHR keeps an IHE ATNA security audit trail of API access: on by default, persisted in the local Audit Record Repository (the dedicated audit PostgreSQL schema), rendered in both official formats (FHIR R4 AuditEvent per IHE BALP, and the DICOM PS3.15 audit message for the classic syslog feed), retrievable via the RESTful-ATNA ITI-81 FHIR search, and optionally forwarded to an external ARR over syslog and/or the ITI-20 FHIR feed. Node authentication (ITI-19) is available as native mutual TLS on the listener.

Stored records are tamper-evident: each is linked into a SHA-256 hash chain maintained by the database, the table refuses every rewrite path except the forwarding stamp, and SELECT * FROM audit.verify_audit_chain() names any record that was modified or removed. That is detection, not prevention: the controls that make it hard to forge wholesale are the least-privilege database role and the off-box sinks.

The full chapter (record content, sinks, tamper evidence, the ITI-81 search, fail-mode semantics, and mTLS) is Audit trail (IHE ATNA); every [audit] key is in the configuration reference.

Note

The ATNA trail is orthogonal to openEHR’s own CONTRIBUTION and AUDIT_DETAILS, which the server always writes in the same transaction as every change. openEHR audit records what a version says about its own authorship; ATNA records security surveillance of API access. Both coexist. Identified data never enters telemetry (metrics, traces, logs), so the audit trail is the single place where access to identified data is recorded; see Operations.

Verifying releases

Before you run a FerroEHR binary, image, or chart in a clinical environment, you should be able to answer two questions about the bytes you downloaded: did they arrive intact, and were they built by this project’s own release pipeline. This page is the operator’s procedure for both: one command per artifact kind, plus the deliberately-failing runs that prove your verification is actually checking something.

Every release publishes signed build provenance, a dependency SBOM, and a plain checksum alongside each artifact. Provenance is signed through Sigstore, so you can verify it yourself, and the signer identity is one you can pin to a single hardened workflow.

The repository moved to the FerroHEALTH organization on 2026-10-01, and v4.3.2 is the first release built there. Releases up to and including v4.3.1 were built and signed while it was rubentalstra/FerroEHR, so their attestations name that repository: verify them with -R rubentalstra/FerroEHR and --signer-workflow rubentalstra/FerroEHR/…. Their images and charts were published under ghcr.io/rubentalstra. The copies of v4.3.1 under ghcr.io/ferrohealth carry the same digests, so gh attestation verify accepts them with the old repository name. Their cosign signatures exist only beside the originals, so run cosign verify against the ghcr.io/rubentalstra path.

What a release publishes

Substitute the release tag you downloaded for <tag> (for example v5.6.0-rc3) and the architecture for <arch> (x86_64 or aarch64) throughout this page. Linux is the only published target.

AssetWhat it is
ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gzthe stripped server binary
…tar.gz.sha256suma plain checksum of that tarball
…tar.gz.sigstore.jsonthe Sigstore bundle for its build-provenance attestation
…tar.gz.sbom.sigstore.jsonthe Sigstore bundle for its SBOM attestation
…tar.gz.intoto.jsonlthe same provenance as one DSSE-wrapped in-toto statement per line
ferroehr-<tag>-<arch>-unknown-linux-gnu.cdx.jsonthe CycloneDX dependency SBOM for that binary
ferroehr-<tag>.spdx.jsonthe SPDX SBOM of the source tree at the release commit
ferroehr-<tag>-vulnerability-record.jsonevery finding of a full scan of each image and binary, each with the OpenVEX statement that judges it (the record)
ferroehr-<tag>-trivy-reports.tar.gzthe raw Trivy reports the record was joined from
<crate>-<crate-version>.cdx.jsonthe CycloneDX SBOM of each published openehr-* crate
<crate>-<crate-version>.crate.sbom.sigstore.jsonthe Sigstore bundle binding that SBOM to the .crate archive crates.io serves
docker-compose.yml + the Keycloak and observability overlaysthe quickstart stack, so a downloader never has to clone the repository

Container images and the Helm chart are published to GHCR by their own lanes and carry their own attestations; see images and the chart below.

Note

The release lane refuses to publish a release whose asset set is incomplete. It creates the release as a draft, attaches every asset, checks the full expected set is present, and only then publishes, because a published release is immutable on this repository, so a missing asset could never be added afterwards. The remedy for a bad cut is a new patch version, never a retag.

A release binary

The floor: the checksum. Every tarball ships a .sha256sum beside it, and this is the only verification available with neither gh nor cosign installed , which is a realistic constraint in a locked-down clinical environment:

sha256sum -c ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sha256sum

The file records the bare filename, so this works in whatever directory you put the two files in. On macOS, shasum -a 256 -c is the same check.

Warning

Be clear about what a checksum buys: it detects a corrupt or truncated download, not a substituted release, because anyone who could replace the tarball could replace the checksum beside it. Only the Sigstore bundle answers “who built this”. The checksum is a floor, not a substitute.

The real check: the provenance attestation.

gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  -R FerroHEALTH/FerroEHR

Without reaching GitHub. Each release also carries its Sigstore bundles as assets, so verification needs nothing but the artifact and the bundle, useful on an air-gapped host, and the only form in which the signature travels with the download:

gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  --bundle ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sigstore.json \
  --repo FerroHEALTH/FerroEHR

The *.sbom.sigstore.json asset beside it is the same thing for the SBOM attestation, so “which dependency graph was this binary built from” is verifiable offline too. Verifying an SBOM attestation needs one extra flag: gh attestation verify enforces the SLSA provenance predicate by default, so a non-provenance attestation must name its predicate type explicitly:

gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  -R FerroHEALTH/FerroEHR \
  --predicate-type https://cyclonedx.org/bom

Fully offline, with no call to GitHub at all: fetch the trusted key material once on a connected machine and carry it across with the bundle:

gh attestation trusted-root > trusted_root.jsonl        # on a connected host
gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  --bundle ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sigstore.json \
  --custom-trusted-root trusted_root.jsonl \
  --repo FerroHEALTH/FerroEHR

Require the hardened signer. Without a signer constraint you are trusting that some workflow in this repository signed the artifact. Pin the one that actually did:

gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  -R FerroHEALTH/FerroEHR \
  --signer-workflow FerroHEALTH/FerroEHR/.github/workflows/release-build.yml

That workflow is the reusable release-build lane described under SLSA levels below.

The binary also describes itself. From v4.0.1 on, the shipped binary embeds its own compressed dependency list in a .dep-v0 linker section (built with cargo auditable; the section is allocated, so it survives the release lane’s strip). A scanner that reads binaries — syft, trivy, grype, osv-scanner — recovers the crate graph from the artifact itself, even when the release page’s SBOM never travelled with it:

syft ferroehr -o cyclonedx-json   # the extracted binary, not the tarball

From v4.3.5 on, the binaries inside the ferroehr and ferroehr-viewer images are built with cargo auditable as well, so a scan of either image lists the Rust dependencies of the binary it carries, and the exploitability record judges them per image. Images of earlier releases carry no such list: a scan of them sees the operating-system layer only, and the server’s Rust dependencies are visible through the tarball scan and its SBOM.

# <version> is v4.3.5 or later, without the v
trivy image --list-all-pkgs --format json ghcr.io/ferrohealth/ferroehr-viewer:<version> \
  | jq -r '.Results[] | select(.Type == "rustbinary") | .Packages[] | "\(.Name) \(.Version)"'

Tip

gh attestation verify reports success by exiting zero and printing nothing in current gh versions. Check the exit status in scripts rather than grepping for a success message.

Prove your verification can fail

A check that cannot fail proves nothing, so do both of these once, by hand, before you trust a passing run:

# 1. Tamper with the artifact — verification must refuse it.
printf 'x' >> ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz
gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  --bundle ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sigstore.json \
  --repo FerroHEALTH/FerroEHR
# → Error: verifying with issuer "sigstore.dev"; exit status 1

# 2. Name a repository that did not build it — also non-zero.
gh attestation verify … --repo someone-else/something

Re-download the tarball afterwards. Only once you have seen both refusals does a passing run mean something.

Cross-check the digest. The same digest is produced independently in three places, so they are worth comparing against each other rather than trusting any one of them:

# the published checksum file
cat ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sha256sum

# the digest the provenance statement was signed over
jq -r '.payload' ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.intoto.jsonl \
  | base64 -d | jq -r '.subject[].digest.sha256'

# the bytes on your disk
sha256sum ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz

A container image

The three images (ferroehr, ferroehr-viewer, ferroehr-postgres) each carry a Sigstore-signed SLSA provenance attestation, plus the SPDX SBOM and provenance the builder writes onto the image index itself.

gh attestation verify oci://ghcr.io/ferrohealth/ferroehr:5.6.0-rc3 \
  -R FerroHEALTH/FerroEHR

Important

Image tags carry no v prefix. A release publishes 4.0.0, 3.20 and latest (plus a sha-… tag per commit), while the release assets are named after the v4.0.1 git tag. Using v4.0.1 as an image reference will simply not resolve.

The development tags (ghcr.io/ferrohealth/ferroehr:main and its two siblings) are signed the same way, so you can rehearse the command against them before a release.

Add --signer-workflow FerroHEALTH/FerroEHR/.github/workflows/build-image.yml to require the hardened image-build lane specifically rather than any workflow in this repository.

Verify by digest where it matters. A tag is mutable, so anything that GATES on verification (admission control, a deploy script) should resolve the tag once and verify the digest it resolved — otherwise the bytes verified and the bytes pulled can differ:

digest=$(docker buildx imagetools inspect ghcr.io/ferrohealth/ferroehr:5.6.0-rc3 \
  | awk '/^Digest:/{print $2}')
gh attestation verify "oci://ghcr.io/ferrohealth/ferroehr@${digest}" \
  -R FerroHEALTH/FerroEHR

The attestation is also pushed to the registry itself, so a host with registry access but no GitHub API path can verify from the registry alone with --bundle-from-oci. And the SBOM the builder wrote onto the image index is readable without any verifier at all:

docker buildx imagetools inspect "ghcr.io/ferrohealth/ferroehr@${digest}" \
  --format '{{ json .SBOM }}'

Enforcing this at admission time in a Kubernetes cluster is a separate job with its own machinery; see Images: build, provenance, scanning.

The Helm chart

The chart carries a keyless cosign signature in addition to its provenance attestation. The two are not redundant: the attestation says what the chart was built from, the signature says who signed the artifact you pulled, and the signature is what Helm-ecosystem tooling looks for.

cosign verify ghcr.io/ferrohealth/charts/ferroehr:<chart-version> \
  --certificate-identity-regexp '^https://github\.com/FerroHEALTH/FerroEHR/\.github/workflows/build-chart\.yml@' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Both flags are the point: without an identity and an issuer, cosign verify accepts a signature from anyone in the transparency log.

gh attestation verify oci://ghcr.io/ferrohealth/charts/ferroehr:<chart-version> \
  -R FerroHEALTH/FerroEHR \
  --signer-workflow FerroHEALTH/FerroEHR/.github/workflows/build-chart.yml

Note

The chart version is not the product version. The chart runs its own SemVer line, so a chart version and the appVersion it deploys move independently. Read the chart’s appVersion to learn which server release it defaults to.

The chart ships no PGP .prov file, so helm install --verify does not apply. That is deliberate: a .prov needs a long-lived private key in CI, and keyless Sigstore signing gives a consumer a stronger identity to pin without one.

The publishing lane refuses to overwrite a chart version that already exists, reads both the signature and the attestation back from the registry before reporting success, and checks that the appVersion image accepts the chart’s rendered defaults. It also attests the chart’s SBOM, which names every image the chart deploys by digest (the SBOMs).

The SBOMs, one per published artefact

Every artefact this project publishes carries a software bill of materials, and each one answers a different question about a different download:

ArtefactSBOMWhereFormatAnswers
the server binaryferroehr-<tag>-<arch>-unknown-linux-gnu.cdx.jsona release asset per architecture, attested against the tarball (…tar.gz.sbom.sigstore.json)CycloneDX 1.5what is inside the binary I am about to run? Every cargo component with a pkg:cargo/… purl and licence, most with checksums, and the dependency edges, so “is this crate direct or four levels down” is answerable. This is what a vulnerability scanner consumes.
each container imagethe image SBOMwritten onto each image index by the builderSPDXwhat is in the image’s OS layer? Which is what matters for ferroehr-postgres, built on the upstream postgres image.
each openehr-* crate<crate>-<crate-version>.cdx.jsona release asset per crate, attested against the .crate archive crates.io serves (<crate>-<crate-version>.crate.sbom.sigstore.json)CycloneDX 1.5what does this library pull into my build? The crate’s normal and build dependencies; dev-dependencies are left out, as cargo package strips them.
the Helm chartthe chart SBOMan attestation on the chart’s OCI digest in GitHub’s attestation storeCycloneDX 1.5which images does this chart deploy? Each image the chart can deploy, named by repository, tag and the digest the tag resolved to when the chart was published, with its platforms.
the source treeferroehr-<tag>.spdx.jsona release asset per releaseSPDX 2.3what am I redistributing, and under what terms? The attribution and licence-obligation view of the source tree at the release commit, the document a legal or procurement reviewer of a multi-licence redistribution asks for.

The crate SBOMs ride the server release that ships them, so <crate-version> is the crate’s own version (the crates run their own SemVer line). A crate version that did not change in a release is described again, against the archive crates.io already serves. Verify a crate SBOM against the archive you downloaded:

curl -sSLO https://static.crates.io/crates/openehr-rm/openehr-rm-<crate-version>.crate
gh attestation verify openehr-rm-<crate-version>.crate \
  -R FerroHEALTH/FerroEHR \
  --predicate-type https://cyclonedx.org/bom \
  --signer-workflow FerroHEALTH/FerroEHR/.github/workflows/release.yml

Read the chart SBOM, and verify it in the same step:

gh attestation verify oci://ghcr.io/ferrohealth/charts/ferroehr:<chart-version> \
  -R FerroHEALTH/FerroEHR \
  --predicate-type https://cyclonedx.org/bom \
  --signer-workflow FerroHEALTH/FerroEHR/.github/workflows/build-chart.yml \
  --format json --jq '.[].verificationResult.statement.predicate'

CycloneDX 1.5 is the highest version cargo-cyclonedx emits (it accepts 1.3, 1.4 or 1.5 and defaults to 1.3, so the release lane sets it explicitly); it carries everything 1.6 consumers read. The release lane refuses an SBOM that is not the format it claims or that names another artefact: the repository SPDX document must name itself ferroehr and list at least one package, a crate SBOM must describe that crate at that version, and every image in the chart SBOM must resolve to a sha256 digest before the chart is pushed.

What SLSA level each artifact reaches

The levels are the SLSA v1.2 Build track (the current spec; v1.0 is marked Retired, and the Build-track requirements are unchanged across those versions — GitHub’s own documentation still phrases the same construction against “SLSA v1.0”). They differ per artifact, so a table is the honest form:

ArtifactLevelWhy
release binaries + their SBOMBuild L3built and attested inside a reusable workflow (release-build.yml), so the signing material is out of reach of any caller-defined step
container imagesBuild L3built, pushed and attested inside a reusable workflow (build-image.yml); the calling jobs pass names and label text, never steps
the Helm chartBuild L3packaged, pushed, signed and attested inside a reusable workflow (build-chart.yml); the caller carries only triggers

Images and the chart reach L3 from the first publish after v4.0.1; earlier artifacts were attested at Build L2 (in the job that built them), and their attestations verify with the repository-scoped command, not the pinned signer.

Build L3’s distinguishing requirement is that secret material used for authenticating the provenance “MUST NOT be accessible to the environment running the user-defined build steps”. Every step of a GitHub Actions job shares one runner VM, so attesting inside the building job cannot satisfy it. The release lane therefore builds and signs inside a reusable workflow: it runs on its own VM, and a caller passes declared inputs; it cannot add steps. The calling job has no steps at all, which is what makes the property hard to lose by accident, and it is why --signer-workflow is worth passing.

What is still not claimed, in either lane: the isolation is GitHub’s rather than this project’s, and nothing here asserts a reproducible or hermetic build; those are separate SLSA tracks this project does not address. Provenance proves where an artifact was built, not that the source was good. Naming the boundary is worth more than rounding a level up.

The openehr-* crates

The nine specification crates publish to crates.io through Trusted Publishing: the workflow authenticates with a short-lived OIDC token, and no long-lived crates.io token exists anywhere. Be precise about what that does and does not give you: it is authentication, not provenance. crates.io records and displays nothing about how a crate was built, and the Rust RFC that introduced Trusted Publishing lists provenance verification as explicitly out of scope — there is no registry-side attestation to verify, from this project or any other. cargo checks the crate’s checksum against the registry index, and that is the whole registry-side story. The crates’ source of truth is this repository: the publishing lane, the tags, and the public history.

What this repository does attest is each crate’s SBOM, against the very .crate archive crates.io serves; the crates.io leg of the release reads that attestation back from crates.io’s own copy after publishing. See the SBOMs for the command.

Findings a scanner will report

Run a scanner over a FerroEHR artifact and it will report findings. Every one this project has assessed and accepted is published as an OpenVEX document under security/vex/, carrying a controlled-vocabulary justification and an impact statement you can check, rather than an ignore entry that records only the verdict. Point your tooling at them (trivy --vex, and most SCA platforms take an OpenVEX feed).

DocumentCovers
rust-advisories.openvex.jsonthe Rust dependency advisories: the ones the advisory gate accepts, plus one that only a Cargo.lock-reading scanner reports
ferroehr-os.openvex.jsonthe Debian packages the ferroehr and ferroehr-viewer images inherit from their distroless base (glibc, the GCC runtime libraries, zlib)
postgres-os.openvex.jsonthe Debian packages of the ferroehr-postgres image, one statement per finding

A statement with status affected names a finding that applies to you, with an action statement saying what to do until the fix ships. trivy --vex leaves those findings in its output on purpose; only not_affected and fixed statements remove one.

The ferroehr-postgres image is the upstream postgres:18.6 image with Debian’s security updates applied at build time, minus two things: GnuPG and the libraries only it uses (upstream needs it only to import a package signing key while building the image), and upstream’s gosu, a Go binary, which a short shell wrapper around util-linux setpriv replaces under the same name. Every finding that remains is a Debian package with no fix in Debian yet. Six are affected: three libxml2 findings and two glibc character-converter findings, which any database login reaches through PostgreSQL’s XML functions, and the resolver finding CVE-2026-8674. FerroEHR sends no XML to the database, so the action statements come down to giving database logins only to the FerroEHR roles and your administrators, and keeping the resolver configuration the container receives under your control.

The Rust document is generated from deny.toml (the gate that actually decides whether a build passes) joined with the published reasoning, and a CI job fails if the two disagree in either direction. So an advisory cannot be accepted without a justification reaching you, and a justification cannot claim something the gate does not do.

The ferroehr-os.openvex.json statements argue from what the one program in each image loads and imports: libgcc_s, libm, libc and the loader, no RPATH or RUNPATH, no dlopen, no C++, and a named list of glibc functions it never calls. Each statement carries those facts as data in a ferroehr:linkage block, and the release pipeline reads them back off every ferroehr and ferroehr-viewer binary it builds, on amd64 and arm64, with readelf before the image is assembled. A binary that needs another library or imports a listed function stops the release, so the statement you read describes the binary in the image you pull. You can repeat the check on a binary you extracted from an image: bash scripts/checks/vex-linkage.sh <path>/ferroehr (it needs GNU readelf and jq).

One asymmetry worth knowing, because it produces findings that are real reports of nothing. Cargo.lock records the union of every dependency any feature combination could pull, so a scanner reading the lock file alone reports crates this project’s feature set never compiles. cargo deny resolves features and does not. Where the two disagree in that direction the feature-resolving tool is the more precise instrument, and the VEX document carries that argument for the specific crate it applies to, so you do not have to take our word for it in prose.

The published images are additionally re-scanned on a weekly schedule at the tag an operator actually pulls. A fixable high-or-critical finding files or updates one tracker issue, and the issue is the alert: the scheduled run itself stays green, because a red scheduled run nobody looks at is not a control.

The exploitability record

The scan that gates an image blocks only high and critical findings that have a fix. Every release therefore also scans each image (ferroehr, ferroehr-viewer, ferroehr-postgres, both platforms, at the digest the release pushed) and each server binary (x86_64 and aarch64) with no severity floor and unfixed findings included, and publishes the result as two release assets:

  • ferroehr-<tag>-trivy-reports.tar.gz: the raw Trivy JSON reports, one per image and platform and one per binary.
  • ferroehr-<tag>-vulnerability-record.json: every finding from those reports, joined with the OpenVEX statement under security/vex/ that judges it. Each entry names the product (pkg:oci/<image> or pkg:cargo/ferroehr-server), the vulnerability id, the package and its installed version, the fixed version where one exists, the severity, and the statement: its status (not_affected, affected or fixed), its justification, impact statement or action statement, and the document it comes from.

The release is refused while any finding has no such statement: the record is checked before the images are tagged, so a refused release leaves its images untagged and its GitHub release a draft. The rule for a finding with a fix is to fix it by rebuilding, not to judge it. A statement counts only when it is a judgement: under_investigation does not, and neither does not_affected without a justification or impact statement, or affected without an action statement.

Read what a release shipped and why:

gh release download <tag> -R FerroHEALTH/FerroEHR -p 'ferroehr-*-vulnerability-record.json'
jq -r '.records[] | [.product, .id, .severity, .statement.status, .statement.justification] | @tsv' \
  ferroehr-<tag>-vulnerability-record.json | sort -u

If verification fails

A failing verification is a security report, not a support question. Do not run the artifact, and follow SECURITY.md: report privately, never as a public issue. Note that only the newest release receives fixes; there is no maintenance branch to backport to.

Threat model

This chapter says what FerroEHR defends against, how, and (more usefully) what remains true after the control has done its job. It exists because the parts of this system that actually protect patient data are the parts no external specification governs: openEHR’s REST specification makes authentication a SHOULD and mandates no scheme, and no openEHR specification governs authorization, so only the 401-versus-403 split is specification-grounded. Everything finer is this project’s own design, and a design nobody has written down asks every deployer to reconstruct it privately.

What this is, and what it is not

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:

AssetWhere it livesWhy it matters
Clinical payload: compositions, EHR status, foldersthe clinical schema’s node and version tables, archival partitions includedthis is PHI; disclosure is the primary harm and it is not undoable
Demographic parties and their identifiersthe party schema, archival partitions included, with protected national identifiers sealed in national_identifierwho the people are, held apart from what is recorded about them
The EHR id / subject cross-referencelinkage.subject_ehrthe additional information that re-attributes a pseudonymised record to a person; on its own it names neither
The audit trailthe audit schema, plus any configured forwarding sinkit is the evidence that everything else happened; an attacker who can edit it can make an access disappear
Version history and its integrityversion, vo_head, contribution, attestationsan openEHR record’s value is that it is append-only and attributable; a silently rewritten prior version is worse than a deleted one
Signing keysthe configured signing key material for commit attestationforging an attestation forges provenance of clinical content
Bearer credentials and password hashestokens in flight; Argon2id PHC hashes at resta stolen token is an authenticated clinical caller until it expires
Instance separationone instance, one database, one set of domain roles per organisationtwo organisations sharing one instance is a breach of both at once
Availabilitythe whole stacka CDR that is down is a clinical system that is down; denial of service is a patient-safety issue here, not an inconvenience

Actors

ActorReachesAssumed capability
Unauthenticated callerthe listening portcan send arbitrary bytes, replay anything observed, and probe every route
Authenticated clinical callerthe openEHR API within their grantsholds a valid token; may be a legitimate user acting outside their remit, or a compromised client
Admin / management callerthe admin, management and messaging surfacescan delete physically, archive, dump and load; the most dangerous authorized actor
Databaseeverything storedtrusted for confidentiality, but assumed to be a place where a mistake is permanent
Terminology server (FHIR R4B)called out to at validation and commit timeoperator-configured; semi-trusted: it answers our questions and can lie
Object store (S3)called out to for multimediaoperator-configured; semi-trusted, and its responses are parsed
Message broker (AMQP)receives change eventsoperator-configured; a sink for data, so a confidentiality boundary
Identity providerissues the tokens everything else believesfully trusted by construction; if it is compromised, no control below it holds
Cluster / host operatorthe process, its memory, its filesystem, its secretsfully trusted; outside the software’s reach

Trust boundaries

flowchart LR
  subgraph Untrusted
    U[Unauthenticated caller]
    C[Clinical client]
    A[Admin client]
  end
  subgraph Deployment["Deployment boundary (operator-controlled)"]
    subgraph Server["FerroEHR process"]
      B1["B1 network + protocol"]
      B2["B2 authentication"]
      B3["B3 authorization<br/>EHR_ACCESS - RBAC - ABAC - SMART"]
      B4["B4 content validation"]
      B5["B5 instance separation"]
    end
    DB[("B6 PostgreSQL")]
    AUD[("B7 audit store")]
  end
  subgraph External["Semi-trusted peers"]
    IDP[Identity provider]
    TS[Terminology server]
    S3[Object store]
    MQ[Message broker]
  end
  U --> B1
  C --> B1
  A --> B1
  B1 --> B2
  B2 --> B3
  B3 --> B4
  B4 --> B5
  B5 --> DB
  B5 --> AUD
  B2 -.verifies tokens from.-> IDP
  B4 -.asks.-> TS
  B4 -.stores blobs.-> S3
  B5 -.publishes.-> MQ

Each boundary below names the control that holds and the risk that remains.

B1 — Network and protocol

Control. TLS terminates either at the server (rustls, TLS 1.3 by default) or in front of it. Connection-level bounds apply before a request even exists (an HTTP/1 header-read timeout, an HTTP/2 stream cap with keep-alive PINGs); request bodies are size-limited, requests are timed out, concurrency is capped by an admission limit, and a two-tier tower_governor rate limit is on by default. Panics are caught and become a clean 500 rather than a dropped connection, and release builds run with overflow checks on so an arithmetic mistake is a loud panic rather than a silently wrong number. Response security headers are set. Every parser that reads attacker-controlled bytes (canonical JSON, canonical XML, AQL, ADL, OPT 1.4 templates, the simplified formats, and the identifier types) has a libFuzzer harness, built on the pull-request path and fuzzed on a nightly campaign, and a crash is fixed in the crate, never in the harness.

Residual risk.

  • Fuzzing finds crashes; it does not prove their absence. The harnesses cover the parsers we identified as reachable. A parser reached by a path we did not enumerate is not covered by anything.
  • Rate limiting is per-process. A multi-replica deployment behind a load balancer divides the effective limit by the replica count; it is not a distributed limiter. The shipped defaults are also deliberately generous: they sit above this implementation’s own measured whole-server ceiling, so the limiter refuses abuse rather than shaping normal load.
  • Algorithmic complexity in query execution is not bounded by the limiter. A syntactically small AQL query can be an expensive one. Statement timeouts at the database are the backstop, and they are the operator’s to set.
  • TLS termination in front of the server is the operator’s; nothing in the software can tell whether the hop behind the proxy is encrypted.

B2 — Authentication

Control. Two mechanisms: HTTP Basic against Argon2id PHC hashes with a verified-credential cache, and OAuth 2.0 bearer tokens validated against the issuer’s key set by ferrohealth-smart. Configuration is validated at boot, not on the first request: a mandatory audience list, an https issuer whose metadata and key set are read at start, asymmetric algorithms only (with none and every HS* algorithm refused), and the OWASP Argon2id parameter floor. A malformed Authorization header is a 400, not a 401: the server never read a credential. An unreachable issuer is a 503, never a silent pass. A 401 body names no reason, so it is not an oracle for expired-versus-forged.

Residual risk.

  • A valid token is a valid caller. FerroEHR verifies signature, audience, issuer and expiry; it does not and cannot know whether the human behind the token is who the token says. Token theft is defended against by your identity provider’s lifetimes and binding, not by us.
  • There is no token binding, no proof-of-possession and no replay window. A bearer token replayed from another network location is accepted.
  • The identity provider is unconditionally trusted. A compromised issuer, or an attacker who can mint tokens with the right audience, defeats every authorization stage below, all of which read their inputs from claims.
  • Basic authentication has no session, no lockout and no second factor. It is intended for machine callers and small deployments; the Argon2id cost is the only brake on offline cracking if a hash leaks.

B3 — Authorization

Control. Five stages, each of which can only narrow what the previous one allowed, which is what makes an optional stage safe to leave off:

  1. Authentication produces a principal or a typed refusal.
  2. EHR_ACCESS is always on and unconditional, from the openEHR Reference Model’s own gateway clause.
  3. 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.)
  4. 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.
  5. 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 that EHR_ACCESS does 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 organization claim produces a correct decision on a false premise.
  • The external PDP is a network dependency in the request path. Fail-closed means an outage is an outage: correct, and an availability risk you should size.
  • Authorization is enforced at the API. Anything with a direct database connection is past it entirely; see B6.
  • A legitimate caller acting outside their remit is not prevented, only recorded. That is what the audit trail is for, and detection is not prevention.

B4 — Content validation and outbound calls

Control. Committed content is validated against the operational template (the WebTemplate walker), the Reference Model’s own invariants (machine-derived from the specification’s meta-model rather than hand-written) and terminology bindings. Canonical JSON is read by a strict reader that refuses undeclared and duplicate keys. Outbound terminology, object-store and broker calls go only to operator-configured endpoints: terminology goes to the one configured FHIR terminology server ([terminology.server], FerroTERM in every shipped deployment), authenticated with an OAuth 2.0 client-credentials token when it sits behind an authorization server, and verified against the platform trust store. The one outbound call that is on by default is the usage report to report.ferropulse.eu, which [usage_report] enabled = false switches off; it sends a fixed set of fields and reads nothing from the answer beyond its status code.

Residual risk.

  • The terminology server is trusted to answer honestly. A compromised or misconfigured one can permit a code that should have been refused. It is a correctness boundary, and a clinical-safety one.
  • A response parsed from a semi-trusted peer is still parsed. A terminology answer, an object-store response and a broker message are all parsed by the server, so a compromised endpoint reaches a parser rather than only a validator. The object-store path once carried an XML parser version with denial-of-service advisories the client-facing canonical-XML codec did not share; both are now on the patched version, and the only pre-fix copy left in the build reaches it through the flamegraph renderer, which writes SVG and parses nothing. That argument is published as a machine-readable VEX document rather than left in a comment. Read the current documents for the current position: they name the versions, and they are regenerated from the gate.
  • Multimedia blobs are stored, not inspected. FerroEHR is not an antivirus and does not pretend to be; a malicious blob is delivered faithfully to whatever opens it.
  • Validation does not make content true. A well-formed composition asserting a clinically wrong fact is stored, correctly.

B5 — Instance separation

Control. One instance serves one organisation: one database, one set of domain roles. There is no tenant column, no row policy and no session setting to get wrong, because there is nothing inside the instance to separate. That is also where openEHR puts the boundary — BASE architecture_overview master06-design_of_the_ehr.adoc §The EHR System calls a system “a distinct logical repository corresponding to an organisational entity that is legally responsible” for the data and “distinct from any underlying virtualisation infrastructure or cloud computing facility, which may house multiple logical EHR systems in a multi-tenant fashion”.

Residual risk.

  • Separation is now a deployment property, so a deployment mistake is the whole risk. Two organisations pointed at one instance share everything; nothing in the software will catch it, because there is no longer a tenant for it to compare against. The compensating control is that the mistake is visible in the deployment, not buried in a session variable.
  • Instance separation does not separate resources by itself. Two instances on one node still compete for CPU, connections and disk unless the platform bounds them.
  • system_id must 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 UPDATE on the tables. The audit chain (B7) is the detection layer.
  • Backups carry PHI with none of these controls. A restored dump is a complete copy of the schemas it covers. Which schemas that is, and what two dumps are worth together, is Backup artefacts below.

B7 — The audit trail

Control. Every access is recorded in both official renderings (DICOM PS3.15 and FHIR R4 AuditEvent/BALP) into a local Audit Record Repository, on by default, with optional syslog and ATX:FHIR-Feed forwarding. Refusals (401, 403, and the 400 a malformed credential header earns) are always recorded, and an unattributable denial is recorded as unattributed rather than under a fabricated subject. Records are hash-chained, so SELECT * FROM audit.verify_audit_chain() names any record that was altered or removed, and the server runs that verification itself on a schedule (tamper evidence). ITI-81 is the retrieval side; ITI-19 mutual TLS is available for node authentication.

Residual risk.

  • Tamper evidence, not tamper proof. The chain proves alteration happened; it does not prevent it. An attacker with write access to the audit schema can rewrite the chain from the point of compromise forward, which is precisely why forwarding to an off-box sink matters, and why the sink should not be writable by the same identity.
  • A local repository shares the blast radius of the thing it audits. If the database is lost, so is the local audit trail.
  • The trail records what the server saw. A read performed directly against the database appears nowhere in it.
  • Audit records are themselves sensitive. They name patients, subjects and actions; ITI-81 retrieval is a PHI-disclosure surface and is authorized like any other.

B8 — The supply chain

Control. Releases are built by a reusable workflow that satisfies SLSA v1.0 Build L3, signed through Sigstore with a signer identity a consumer can pin, carrying a CycloneDX dependency SBOM, a SPDX repository SBOM, in-toto provenance and a plain sha256sum; the verification procedure is Verifying releases. The release is created as a draft and only published once its full asset set is present, because a published release is immutable. Container base images are digest-pinned, and the published images are re-scanned weekly at the tag an operator actually pulls, with a fixable high-or-critical finding both failing the run and filing an issue. Every action is pinned to a commit SHA, workflows are audited by zizmor on every pull request, and accepted advisories carry published VEX justifications. Commits and tags are signed and the tags are protected by a ruleset.

Residual risk.

  • Provenance proves where an artifact was built, not that the source was good. Build L3 says these bytes came from this workflow at this commit. It says nothing about what that commit contained.
  • The build is not hermetic and not reproducible. Those are separate SLSA tracks this project does not address. A dependency resolved at build time is trusted to be what the lock file names.
  • Review capacity is one person. No second human is structurally required between an idea and a released binary; the mitigation is machine enforcement and it is honestly recorded in GOVERNANCE.md.
  • Fixes ship in the newest release only. Only the newest release is supported, and a fix arrives as a newer release; there is no maintenance branch to backport to. Cadasto B.V. commits to keep publishing security fixes for at least five years after each release (CRA Art 13(8) and (10)). See SECURITY.md.

What each database credential can reach

Every control from B1 to B5 is enforced at the API, so a database credential is past all of them. What one credential holds is therefore the boundary that survives a leaked connection string, and it is a property of the grants rather than of the server’s routing. The migrations under app/ferroehr/migrations/ apply those grants, revoke each domain from the roles that do not own it, and make every domain role NOINHERIT and a member of no other, so a privilege cannot arrive through a membership.

CredentialReachesCannot reach
ferroehr_clinicalclinical, 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 chainparty and linkage, revoked explicitly and in both directions
ferroehr_clinical_readerSELECT on clinical; USAGE on ext; read the audit repository and verify the chainthe same two schemas
ferroehr_partyparty, archival partitions included, with SELECT, INSERT, UPDATE and DELETE, the sealed national_identifier rows included; EXECUTE on party.resolve_national_identifierclinical and linkage, revoked explicitly and in both directions
ferroehr_party_readerSELECT on party. On national_identifier the table-level grant is revoked and re-granted column by column, so it reads id, party_id, scheme, created_at and retired_at and never lookup_digest, nonce or ciphertextclinical and linkage
ferroehr_linkagelinkage.subject_ehr with SELECT, INSERT and UPDATE; EXECUTE on linkage.erase_ehr; USAGE on extclinical, 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 creatednothing. The server opens it for that one boot step and closes it again, so no pool is held on it and no request is served through it

A schema’s _sqlx_migrations bookkeeping, the record of which migrations ran that the boot check compares, is read-only to every runtime role, the writer roles included; the server refuses to start while a runtime role can write any bookkeeping table, in every database a domain pool reaches.

The audit schema is granted to the clinical pair (ferroehr_clinical records an event, stamps it forwarded, runs the retention reaper and verifies the chain; ferroehr_clinical_reader reads it). The local Audit Record Repository is written on the clinical pool, so a login role that is a member of ferroehr_clinical alone writes its own access log. The audit trail is not a pseudonymisation domain: the demographic and linkage roles hold no privilege there, and the trail carries record identifiers, never a subject’s data.

The five domain roles are checked at every boot. The server reads the catalogue for every table, partitioned table, view, materialized view, foreign table, sequence and function each of them can reach in a domain it does not own, and refuses to serve when it finds one, naming the role, the object kind and the object. A role that does not exist is skipped rather than failed, because role provisioning is a deployment step and the migrations create the roles only where the migrator holds CREATEROLE. ferroehr db verify runs the same check from outside the deployment.

Residual risk.

  • The grants are only a boundary once the credentials are separated. With every [storage.<domain>] url unset, all four pools authenticate as [db] url and the split is a schema split. The shared_credential gap of deployment_profile is 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] key lives somewhere the dump does not.
  • Two dumps together are worth more than twice one. demographic beside linkage says 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_cluster gap, 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_namespaces is empty by default, and with it empty the subject rule and the database trigger are both out of force. The open_subject_namespace gap of deployment_profile is what names that.
  • A finding names the RM path and the rule, never the value. That is deliberate, and it means a refusal tells an operator where to look rather than what was there.

Content that identifies without carrying an identifier

Control. None in the query engine. Clinical content is keyed by an opaque subject pseudonym, and AQL returns the rows it was asked for.

Residual risk. A rare diagnosis with an admission date and a place of treatment identifies a person with no identifier field involved. Small-cell suppression applies to the cohort query only: a cohort result serving fewer distinct EHRs than cohort.small_cell_threshold has its rows withheld and is marked suppressed. An ordinary AQL execution takes no threshold at all, so nothing counts how small a population a plain POST /query/aql returned. For that surface, who may run AQL, for what purpose, and over which EHRs are the controls, and they are configuration rather than arithmetic.

The map that rejoins the two domains

Control. linkage.subject_ehr holds a party id, an EHR id, the subject identifier the clinical side carries, the association metadata and a validity period. No name, no address and no plaintext attribute, because a row here is already the additional information that re-attributes a record. Its role is barred from both domains it joins and both of them from it. It holds anything only when a FerroEHR run on its own opts in to it with [ehr_index] provider = "local"; by default the cross-reference lives in an IHE patient identity source that FerroEHR asks over PIXm (ITI-83), and the map stays empty. FerroEHR matches, links and merges no patients in either mode. Each EHR Index operation writes a linkage-domain access event naming the actor, the declared purpose of use and whether anything matched, and that event deliberately does not name the subject identifier. Erasure is the one deletion, and the role still cannot delete: linkage.erase_ehr is a SECURITY DEFINER function that removes the rows naming one erased EHR, which the admin delete calls once the clinical delete has committed.

Residual risk.

  • The identity source holds the re-identifying link. Its controls, who may resolve a pseudonym back to a person, are the source’s, and FerroEHR never asks for that resolution.
  • Under the local map, nothing populates it. What it holds is what an I_EHR_INDEX caller wrote, so a deployment that never writes it has no map, and one that writes it from a batch job has whatever that job decided.
  • A miss is as informative as a hit. Asking whether this deployment holds a record for a person is itself a disclosure, which is why the miss is recorded too.

The sealed identifier and its lookup digest

Control. A protected national identifier leaves the versioned body and lives in party.national_identifier under AES-256-GCM with a fresh 96-bit nonce per record, with the scheme code bound in as associated data so a ciphertext moved between either fails to open. Beside it is an HMAC-SHA-256 digest under a separate subkey, which is what lets equality lookup work without the plaintext ever reaching the database. Both subkeys are derived per domain from one configured root key. The resolve function is SECURITY DEFINER, PUBLIC is revoked from it, and only ferroehr_party may execute it. Every resolution records an access event naming the scheme and whether it matched, never the value. The scheme registry is a closed set: an identifier whose scheme nobody registered cannot be stored as a protected one at all.

Each party version seals rows of its own. When a later version or the logical deletion supersedes a version, its rows get a retired_at stamp and stay, so an older version still expands to the value it committed. Resolution matches live rows only, and a value is live for at most one party at a time.

Three paths that move a party version keep the value sealed:

  • EHR-Extract import: a received version is stored verbatim, so the server cannot seal it on the way in. It refuses with a 422 a version that carries a protected scheme’s value in plaintext, and one that references a sealed row this instance does not hold, such as a reference from another instance.
  • Admin dump and load: the archive carries the party’s sealed rows, live and retired, as base64 ciphertext, nonce and digest, never the value. They open again only under the same root key.
  • Physical erasure of a party: every sealed row of the party, live and retired, is deleted in the same transaction as its versions.

Residual risk.

  • The root key is the whole control. Held beside a dump, the ciphertext opens and the digest can be recomputed over a nine-digit space in seconds. It belongs in a secret store, not next to the backup.
  • Protection is off by default, and an identifier whose type the deployment did not list is left in the versioned body exactly as written.
  • Rotating the key is a re-encryption, not an edit, because every record is sealed under a subkey derived from it.

The subject pseudonym

Control. mint_subject_pseudonym derives the value from the party id under the linkage key domain, in the first declared pseudonym namespace. No caller input enters it, so the same party yields the same pseudonym and a national identifier cannot become a subject reference through that path.

Residual risk. A stable per-subject identifier across the whole clinical store is what makes it an EHR, and it is also a linkage key. Anyone holding the clinical domain can gather every record of one subject, without knowing who the subject is. Across instances the derivation differs with the root key, so the same person in two instances is two pseudonyms.

The audit trail as a re-identification surface

Control. Access records live in audit, outside all three domains, and retrieval is the admin-gated ITI-81 route. A linkage event names the party and the purpose, not the EHR that was returned, so the trail is not a second copy of the map.

Residual risk. The records name subjects, actors, organisations, roles and what they read, which is sensitive in its own right and is a disclosure surface like any other. Retention makes it durable on purpose: where one of the deployment’s active identifier rules names a jurisdiction with a registered retention floor, a shorter audit.store.retention_days is a boot error rather than a silent trim. The Netherlands is the one registered floor today, at 1830 days.

What is explicitly NOT defended against

Silence is never coverage, so these are stated rather than left to inference.

  • A compromised identity provider. Everything downstream reads claims it asserted.
  • A compromised host, container runtime, or cluster operator. Process memory holds decrypted PHI and key material by necessity.
  • Anyone with direct database credentials.
  • A malicious maintainer, or a compromise of the maintainer’s account or signing key. The bus factor is one; see MAINTAINERS.md.
  • Physical access, side channels, and speculative-execution attacks.
  • Traffic analysis. Request sizes, timings and error-code patterns can leak the existence of records; nothing pads or delays.
  • A legitimate user’s authorized misuse, beyond recording it.
  • Denial of service by a resource-holding authorized caller: an expensive query, a large commit, a slow client. The rate limiter is on but coarse and per-process; database statement timeouts and container limits are the operator’s.
  • Data remanence. Physical deletion removes rows; it does not scrub filesystem blocks, WAL segments, replicas or backups.
  • Clinical correctness. FerroEHR validates structure, invariants and terminology bindings. It does not know whether a recorded fact is true, and no control here is a substitute for clinical governance.

Reporting a finding

If you believe a control is weaker than stated here, or a residual risk is missing, that is a valid security report, including when the defect is in this document rather than in the code. Follow SECURITY.md: report privately, never as a public issue.

Data protection impact assessment

GDPR Art. 35 makes a data protection impact assessment mandatory before large-scale processing of health data. That assessment belongs to the controller, and it is about a deployment: its purposes, its users, its network, its organisation. What this page supplies is the half a supplier can supply, which is the technical description the assessment builds on.

FerroEHR is software. It is not a controller, not a processor and not a certified organisation, so nothing here says a deployment is compliant with anything. It says what the software does with personal data, which controls it ships, and what those controls do not reach.

How to use this page

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:

  1. Storage of clinical content as versioned objects.
  2. Versioning and change control: every write commits a contribution and an audit entry in the same transaction, and no version is overwritten.
  3. Query over stored content, in AQL.
  4. Identity and linkage: parties, protected national identifiers, and the map from a party to the EHR whose subject it is.
  5. Access logging: one record per access, in DICOM PS3.15 and FHIR AuditEvent renderings.
  6. Change-event publication: a PHI-free envelope per commit, drained to a broker when [events] is enabled.

Everything else the server does (templates, terminology lookup, conformance reporting) operates on definitions rather than on people.

2. Data categories, by schema

The database is split so that no single runtime credential holds two of the three parts. Each schema is a separate category of data with its own role.

SchemaPersonal data it holdsGDPR category
clinicalClinical 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 declaredArt. 9 special-category health data
partyParties: 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 themArt. 4(1) personal data; a national identifier is Art. 87 national identification number
linkagesubject_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 kindThe additional information of Art. 4(5): identifying only when joined to one of the other two
auditOne record per access: who, from which organisation, under which roles, what they read or wrote, the declared purpose, the outcome and the timeArt. 4(1) personal data about both the subject and the accessing person
extNo 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.

DataWhat the software doesKey
Clinical content and its versionsKept until an administrator deletes it. Archiving moves a record to the cold tier without expiring itthe admin API
Demographic partiesThe samethe admin API
Linkage mappingsKept 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 recordsKept forever by default; reaped hourly when a retention is set[audit.store] retention_days
Change-event envelopesPublished 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.

#RiskShipped controlWhat remains
R1A clinical record and the identity of its subject are held together, so one credential re-identifies everyoneThree schemas, five NOINHERIT roles, explicit revokes in both directions, and a boot self-check that refuses to serve when a role can read acrossWith 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
R2A national identifier is written into clinical contentThe 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 declaredA 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
R3A national identifier is readable in the party store, in a backup or on a replicaAES-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 executeProtection 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
R4Someone re-identifies a record and nobody can tell afterwardsEvery resolve, link, merge and split writes a linkage-domain access record naming the actor, purpose and outcome, including a missRecording is not prevention. An actor entitled to the crossing re-identifies by design
R5Content identifies a person without carrying an identifierSmall-cell suppression on the cohort query: a result serving fewer distinct EHRs than cohort.small_cell_threshold (default 5) withholds its rows and says soA 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
R6One organisation reads another’s rowsOne 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
R7Access records are altered or lostRecords 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 boxThe 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
R8A backup carries what the grants withheldOne 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 claimEach dump is plaintext of its own domain. Two of them together rebuild the join. Nothing prunes old dumps
R9A deployment has made none of the separations and looks like one that hasdeployment_profile = "production" refuses to start while a separation is open and not accepted by name; sandbox names every open one on the banner, in the log and on GET /management/statusThe default is sandbox, so an operator who sets nothing gets the loud version rather than the enforced one
R10Anyone with a database connection is past every API controlLeast-privilege roles revoked from each other’s domains, and a version table that is appended to rather than overwrittenThis is inherent. Authorization is enforced at the API, so protecting the credentials and the network path is yours

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.

ControlIssueGDPREDPB 01/2025EHDSNEN
Demographic parties in their own schema under a non-overlapping runtime role#3153Art. 4(5), Art. 32(1)(a)pseudonymisation domain——
The clinical side refuses identifying data; the subject reference is constrained to a pseudonym namespace#3154Art. 4(5), Art. 25(2)———
National identifiers sealed, looked up by keyed digest, resolved under audit#3155Art. 32(1)(a)——NEN 7510-2 cryptographic controls
Per-domain access logging for reads and queries#3156——Art. 9NEN 7513 event content
Separate encryption keys and per-schema backup handling#3157Art. 4(5), Art. 32(1)(c)———
The linkage map as its own schema and role#3158Art. 4(5)pseudonymisation domain——
The declared deployment profile: production refuses what it cannot prove#3226Art. 32(1)———
The server mints the subject pseudonym; no caller value becomes one#3232Art. 4(5)———
A jurisdictional floor under access-log retention#3242Art. 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

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

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:

FieldValue
Controller (name, contact)your organisation
Joint controllers, if anyyours
Representative in the Union, if applicableyours
Data protection officeryours
Processors engagedyours: the PostgreSQL operator, and any object store, message broker, terminology server or identity provider the deployment is configured against

Note

One transmission is on by default and is not a processor relationship: the usage report to FerroPULSE. It carries no patient data and no staff identities. Cadasto B.V. receives it as controller for its own purposes, and its collector stores the IP address each report comes from. Where that makes the report personal data (an instance run by one person from their own connection, for example), list Cadasto B.V. as a recipient in your record, or switch the report off.

1. Storing the clinical record

Art. 30 fieldWhat the software does
Purposeyours: the care or research purpose the repository serves
Categories of data subjectPatients and other record subjects; the health professionals recorded as composers, performers and participants
Categories of personal dataClinical content as the operational templates define it, in the clinical schema, its cold archival partitions included. Special-category health data under Art. 9. The subject appears as an opaque pseudonym once [privacy] subject_namespaces is declared
Recipientsyours: whoever the API is exposed to. The software discloses nothing on its own
Third-country transfersyours: wherever the deployment and its backups run
RetentionNo automatic expiry. Content leaves only by an administrator’s deletion; archiving moves it to the cold tier without expiring it
Security measuresLeast-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 fieldWhat the software does
PurposeKeeping the clinical record attributable and reconstructible, which openEHR’s change-control model requires
Categories of data subjectAs activity 1, plus the committing user
Categories of personal dataOne contribution and one audit entry per commit, naming the committer, the change type and the commit instant; every prior version of every object
Recipientsyours
Third-country transfersyours
RetentionIndefinite by design. A version is superseded, never overwritten
Security measuresContribution 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 fieldWhat the software does
Purposeyours: the reporting, cohort or operational purpose queries serve
Categories of data subjectAs activity 1
Categories of personal dataWhatever the query projects out of stored clinical content
Recipientsyours
Third-country transfersyours
RetentionResults are not stored. Stored query definitions are, as AQL text
Security measuresQueries 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_threshold has its rows withheld and is marked suppressed; an ordinary POST /query/aql takes no threshold, so a query returning one row about one rare condition is served like any other.

4. Identity and linkage

Art. 30 fieldWhat the software does
PurposeHolding who the record subjects are, and resolving a person to their record where a caller is entitled to
Categories of data subjectPatients, and the parties a record refers to
Categories of personal dataParties in the party schema with names, addresses and contacts as the template defines them; protected national identifiers sealed in national_identifier; the party-to-EHR map in linkage, which holds identifiers and a validity period and nothing else
Recipientsyours
Third-country transfersyours
RetentionParties 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 measuresThree 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 fieldWhat the software does
PurposeRecording 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 subjectThe record subject, and the person who accessed the record
Categories of personal dataPer 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
Recipientsyours: the local repository, plus any syslog or FHIR-feed Audit Record Repository configured
Third-country transfersyours
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 measuresRecords 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 fieldWhat the software does
PurposeTelling downstream systems that a commit happened, when [events] is enabled
Categories of data subjectRecord subjects, indirectly
Categories of personal dataA 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
RecipientsThe configured AMQP broker, and whoever consumes from it
Third-country transfersyours: wherever the broker runs
RetentionPublished rows pruned after [events] retention_days, seven days by default
Security measuresWritten in the same transaction as the commit it announces, so there is no event without its commit; optional TLS to the broker; the envelope carries identifiers, so a consumer still has to authenticate to the API to read anything

Fields no software can fill

The record is not complete until these are answered, and they are answered by the organisation:

  • The purpose of each activity and the lawful basis for it, which for health data means an Art. 9(2) condition beside the Art. 6 one.
  • The recipients, including every category of person inside the organisation who can reach the API.
  • Transfers to third countries and the safeguard relied on, which follows from where the database, the backups and every configured peer run.
  • Time limits for erasure per category, which the software will not apply for you: it deletes when an administrator asks.
  • The general description of security measures in Art. 30(1)(g), for which the technical half is the DPIA page and the threat model.

Next

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

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 verify reports as intact: the check measures the group roles. Give each login role one membership. The clinical login role’s membership in ferroehr_clinical also covers the local Audit Record Repository, which that role writes; it holds no grant in the other two domains.

  • Five domain roles exist, each NOINHERIT and a member of no other.
  • Each login role is a member of exactly one domain role.
  • ferroehr db verify exits 0.

2. The deployment profile is production, and the server boots

deployment_profile = "production"

Under production the server refuses to start while a separation is open and not accepted by name. The refusal lists every open gap with its finding and its remedy, so one boot tells you the whole list:

deployment_profile = "production" refuses to start: this deployment has not
made the separations production asserts. Make them, or accept each one by name
in deployment_accepts (which is then stated on every boot and on /management/status):
  - shared_credential: ...

The gaps are shared_credential, shared_cluster, open_subject_namespace, audit_off, audit_fails_open, no_category_map, open_ehr_access_default, migrate_on_runtime_credential, auth_off, rbac_off, plaintext_listener, plaintext_broker, plaintext_audit_feed and plaintext_object_store. shared_cluster is read from pg_control_system().system_identifier on each pool, the audit pool included, rather than from the DSN text, so two names for one cluster do not pass it.

Two of them are the shipped compatibility defaults, kept as they are because the conformance instrument and the development stacks need them: the EHR_ACCESS default is open, and the audit trail fails open. A production deployment either changes both, or names each in deployment_accepts and carries the decision.

plaintext_listener opens whenever the main listener speaks plain HTTP on an address reachable off the host. Behind a TLS-terminating ingress, accept it by name: the entry records that the ingress carries the encryption. It also opens when a separate management listener (management.port) is set, because that listener binds every interface and never speaks TLS: unset the port so the surface shares the main listener, or accept the gap by name and keep that port off any network clinical clients or the internet reach.

The syslog feed has no gap of its own: it sends over RFC 5425 TLS only.

plaintext_broker opens when the change-event outbox ([events]) is on with an amqp:// URL and tls = false, which publishes change events to the broker unencrypted. Use an amqps:// URL or set tls = true, or accept the gap by name where the path to the broker stays on a trusted segment.

plaintext_audit_feed opens when the audit FHIR feed ([audit.fhir_feed]) is on with an http:// URL, and plaintext_object_store when the multimedia store is on with allow_http = true. Use https:// for both, or accept the gap by name where the repository or the store is reached on a trusted segment.

A gap you have decided to run with goes in deployment_accepts by name. It is then stated on every boot and on GET /management/status, so it is run rather than hidden.

  • deployment_profile = "production" and the server starts.
  • [authz.rbac] ehr_access_default and [audit] fail_mode are the values this deployment intends, not the ones it inherited.
  • Every entry in deployment_accepts is a decision someone recorded, not a leftover.
  • GET /management/status shows 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 rules list covers every jurisdiction whose identifiers your content can carry.
  • Local identifier kinds have a patterns entry.

4. The pseudonym namespaces are declared

[privacy]
subject_namespaces = ["your-pseudonym-service"]

Empty is the default, and empty means the subject rule is out of force: whatever a client sends becomes the clinical subject reference. Declaring a namespace turns the rule on. From then on EHR_STATUS.subject.external_ref must name a listed namespace and carry a UUID, the server stamps ehr.posture, and a database trigger refuses a non-UUID subject whichever session writes it.

The name is a deployment fact: it identifies the service that mints your pseudonyms, and no server can invent it for you. FerroEHR mints none; under the IHE patient identity source it is the clinical domain’s assigning authority, ehr_index.assigning_authority.

  • subject_namespaces names at least one namespace.
  • The open_subject_namespace gap no longer appears on GET /management/status.

5. Backups, one per domain

Backups leave the database with none of the grants attached, so one dump per cluster undoes the split. Configure one per domain, each with its own credential and its own volume. On Kubernetes that is the backup values:

backup:
  enabled: true
  clinical:
    existingSecret: ferroehr-backup-clinical
    persistentVolumeClaim: ferroehr-backup-clinical
  demographic:
    existingSecret: ferroehr-backup-party
    persistentVolumeClaim: ferroehr-backup-party
  linkage:
    existingSecret: ferroehr-backup-linkage
    persistentVolumeClaim: ferroehr-backup-linkage

Each credential is a read-only role on its own domain, and it cannot be the pool’s: each domain’s runtime role is revoked from the other domains, so a dump taken through one would be silently partial. The chart refuses to render when two domains name the same claim, which is the mistake that quietly puts the join back together.

Three things the chart does not do: it provisions no storage, it prunes no old dumps, and no network policy of ours selects the dump pods.

  • Three dumps, three credentials, three volumes.
  • The demographic and linkage volumes have the narrower audience.
  • A restore has been rehearsed, and the rehearsal is recorded.
  • Retention and pruning of the dumps is somebody’s job, and they know it.

6. The audit trail is on, durable, and kept long enough

[audit]
enabled = true
purpose_header = "x-purpose-of-use"

[audit.store]
enabled = true
retention_days = 0    # or a value at or above your jurisdiction's floor

Auditing is on by default with the local store as the sink. What a production deployment adds is a copy off the box, because a local repository shares the blast radius of the database it audits:

[audit.syslog]
enabled = true

# or, for a RESTful ARR
[audit.fhir_feed]
enabled = true

Decide fail_mode deliberately. open logs a dropped record and serves the request; closed refuses an auditable operation with 503 when the record cannot be taken. closed is the posture that makes “no unaudited access” true.

Retention has a jurisdictional floor. Where one of your active identifier rules names a jurisdiction with a registered floor, a shorter horizon is a boot error naming the floor. Every EU Member State carries three years from each date of access (EHDS Art. 9(2)); the Netherlands five years, from the Besluit vaststelling bewaartermijn logging; Switzerland one year (DSV Art. 4 Abs. 5). The floors are in calendar years, so state the horizon as [audit.store] retention_years, which is compared exactly; a retention_days is held to the most days those years can span (1096, 1830 and 366). The audit trail page lists the floors with their sources. A deployment in a jurisdiction with no floor registered sets its own horizon and records why.

Verify the chain works before you need it:

SELECT * FROM audit.verify_audit_chain();
  • [audit] enabled = true with a durable sink.
  • A copy is forwarded off the box, to a sink the server’s own identity cannot rewrite.
  • fail_mode is a decision, not a default.
  • retention_years (or retention_days) is 0 or 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 (or migrate_url_file) is set.
  • migrate = "verify" if the schema is applied out of band, with ferroehr db migrate run under the migrator DSN first.

8. The paperwork exists

  • A data protection impact assessment has been carried out and signed off. The technical half is the DPIA page; the purposes, the lawful basis and the organisational measures are yours.
  • The Art. 30 record of processing is written. The template is Records of processing.
  • The processor agreements are in place for the database operator and every configured peer.
  • Somebody has read what the software cannot do for a data subject.

What this checklist does not cover

  • Your identity provider. Every authorization stage reads claims it asserted. Its token lifetimes, binding and revocation are outside the software.
  • The platform. Host hardening, network policy, image admission and secret management are Cluster hardening.
  • Encryption at rest. FerroEHR stores queryable JSONB and does not encrypt clinical payload before it reaches PostgreSQL. That is the database operator’s layer.
  • Clinical governance. Validation checks structure, invariants and terminology bindings. It does not know whether a recorded fact is true.

Enterprise identity providers

FerroEHR does not manage users. There is no user table, no user API, and no plan to add one: identity administration is delegated to your identity provider (IdP), and the CDR consumes standard OAuth 2.0 bearer tokens. This page records that posture and walks through connecting the two enterprise IdPs we are asked about most, Microsoft Entra ID (Azure AD) and AD FS, plus the answer for plain-LDAP directories.

The posture: users live in the IdP

A clinical data repository is the wrong place to store credentials. A user store would make the CDR an authentication product (password lifecycle, lockout policy, MFA, recovery flows, and the largest new attack surface the product could grow) duplicating what a dedicated IdP already does under your existing governance. So the split is deliberate and permanent:

  • The IdP owns identities: accounts, passwords, MFA, group/role membership, lifecycle (joiners/movers/leavers), and session policy.
  • The CDR owns authorization: it validates the token, mines roles from its claims, and enforces RBAC/ABAC and per-EHR access control on every request.

The HTTP Basic user list in ferroehr.toml is for machines and bootstrap, not a user store; people authenticate with bearer tokens from the IdP.

Note

The viewer follows the same rule: it authenticates against the same credentials the CDR accepts (the same OIDC issuer, or Basic) and has no user-management screens. To create, disable, or re-role a user, use your IdP’s own administration surface.

How the CDR consumes an IdP

Three things do all the work:

  1. Token validation ([auth]): the server reads the issuer’s metadata and key set at boot and validates each bearer token’s signature (asymmetric algorithms only), iss, exp/nbf, aud and typ; the audience list is mandatory, so a server with none refuses to boot rather than accepting another service’s token. See the bearer settings table.
  2. Role mining ([auth]): FERROEHR__AUTH__ROLE_CLAIMS (default ["roles","groups","entitlements"], the RFC 9068 §2.2.3.1 carriers; a Keycloak realm adds realm_access.roles) names the JWT claim paths whose values become the caller’s roles for the role layer. A path may be dotted to walk nested claims, and a claim holding a single string is accepted as readily as an array.
  3. SMART on openEHR scopes: every route requires a scope from the token’s scope claim (user/composition-*.cruds, user/aql-*.s and so on; see SMART App Launch). Your IdP has to mint them, for example as Keycloak client scopes assigned to the client. A token with roles and no SMART on openEHR scope authenticates and is then refused with 403 on every route.

Everything below is those three pointed at a different issuer.

Microsoft Entra ID (Azure AD)

Entra ID exposes a standards-compliant OIDC issuer per tenant.

  1. Register an application (Entra admin center → App registrations). Note the Directory (tenant) ID and the Application (client) ID.

  2. 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’s roles claim.

  3. Expose an audience: either use the client ID as the audience or add an Application ID URI (for example api://ferroehr).

  4. Point the CDR at the tenant issuer and mine the roles claim:

    export FERROEHR__AUTH__ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
    export FERROEHR__AUTH__AUDIENCE=api://ferroehr
    # Entra ID mints no RFC 9068 tokens (`typ` JWT, the scopes in `scp`).
    export FERROEHR__AUTH__TOKEN_PROFILE=jwt
    export FERROEHR__AUTH__ROLE_CLAIMS='["roles"]'
    
  5. 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:

    StatusWhat it tells you
    200-familyroles arrived and the operation was permitted
    401no credential, or one the server rejected (the body deliberately never says which)
    403the token is valid but carries no role or SMART on openEHR scope the operation needs: a clinical call needs a matching scope and, unless every SMART on openEHR scope it holds is a system/ scope, at least one role, an admin call needs the admin role and the product scope
    400the Authorization header itself is malformed; no credential was ever read
    503the CDR could not read your issuer’s key set again, so the token was never judged: check network egress, not the token

Important

A role must arrive in a role claim, not in scope. The OAuth2 scope claim 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 in scope, map them into roles (or another entry of ROLE_CLAIMS) instead.

Tip

Group-based deployments can emit the groups claim instead and list it in ROLE_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 https URL with no query or fragment, and an empty audience list. Both are deliberate: the second is what stops this server accepting a token minted for a different service. An issuer the server cannot reach at boot refuses the start too.

AD FS (on-premises Active Directory)

AD FS 2016+ speaks OIDC. This is also the supported path for on-premises Active Directory in general: front AD with AD FS (or another OIDC-capable broker) rather than pointing anything at LDAP.

  1. 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.

  2. Issue role claims: on the Web API’s Issuance Transform Rules, add a rule mapping AD group membership to the role claim (template: Send Group Membership as a Claim), one rule per CDR role.

  3. Point the CDR at the AD FS issuer and mine the role claim:

    export FERROEHR__AUTH__ISSUER=https://adfs.example.com/adfs
    export FERROEHR__AUTH__AUDIENCE=ferroehr-api
    # AD FS mints no RFC 9068 tokens.
    export FERROEHR__AUTH__TOKEN_PROFILE=jwt
    export FERROEHR__AUTH__ROLE_CLAIMS='["role"]'
    

    Discovery works out of the box (https://adfs.example.com/adfs/.well-known/openid-configuration).

  4. Verify with the same status table as above: 401 without a token, 403 with a token that lacks the required role.

Note

AD FS emits a single string for one role and an array for several; the role-mining layer accepts both shapes on any configured claim path.

“We only have LDAP”

The CDR does not speak LDAP, by design: LDAP bind would put password handling back inside the CDR. Front the directory with an OIDC-capable broker and connect that instead:

  • Active Directory → AD FS (above) or Entra ID (if synced).
  • Generic LDAP → Keycloak with LDAP user federation (the Keycloak example then applies verbatim), or any other OIDC provider that can federate LDAP.

The broker owns the LDAP bind; the CDR sees only signed tokens.

Serving SMART apps

If your IdP is also the authorization server for SMART App Launch apps, the same [auth] issuer does double duty: the CDR validates the tokens those apps come back with, and the discovery document it serves is built from that issuer’s metadata, so the server it advertises to apps is the one it accepts tokens from. See SMART App Launch.

SMART App Launch

FerroEHR is the resource server of a SMART on openEHR platform: a clinical app is launched with an OAuth 2.0 access token from your authorization server (Keycloak or any standards-compliant identity provider), and the CDR checks the token, enforces the SMART on openEHR scopes it carries, and binds a patient/ grant to the EHR of the launch context. FerroEHR never issues tokens, registers clients, or serves the OAuth 2.0 endpoints itself; those remain your authorization server’s job. Token checking and scope enforcement run on ferrohealth-smart, the crate every FerroHEALTH product shares.

Scopes are enforced on every route

Whenever [auth] is on, every route carries a SMART on openEHR requirement, and a credential whose scopes do not meet it is refused with 403 and the insufficient_scope challenge (RFC 6750 §3.1). There is no switch: a route the table does not know is refused too, so a route can never answer to a token that carries no grant for it. A bearer token’s scopes come from its scope claim; a Basic user’s from its scopes in [[auth.basic.users]]. With authentication off nothing is checked.

The scopes and the RBAC and ABAC layers are all checked: SMART only narrows, and never lets through what a role or a policy refuses (see Security).

The scope grammar

SMART on openEHR resource scopes have the form <compartment>/<resource>.<permissions>:

  • Compartment: patient (the launch context’s EHR only), user (what the user may see), or system (a backend service, no user).
  • Resource: one of three families, composition-<template-id>, template-<template-id>, or aql-<stored-query-name>.
  • Permissions: any combination of c create, r read, u update, d delete, s search/execute, in any order (.crud, .rs, .sr).

Resource ids accept wildcards: * matches within one ::-delimited namespace segment, ** matches across namespaces, and a bare * or ** matches every id. Every other character, :: and . included, is literal. The permission tail is split at the last dot, so template ids and query names keep their internal dots and versions.

ScopeGrants
user/composition-*.crudCreate, read, update, and delete any composition, and the EHR’s other data.
user/composition-MyHospital::*.rRead compositions of any template in the MyHospital namespace (not sub-namespaces).
user/template-*.crudsFull access to template definitions.
user/aql-*.sRun any query.
patient/composition-*.rRead the compositions of the launch context’s EHR only.
system/aql-org.openehr::bloodpressure.v1.sA backend service may run that one stored query.

The FHIR v1 suffixes (.read, .write) are not part of this grammar. A token may carry SMART on FHIR scopes beside these; they grant nothing on an openEHR route.

What each route requires

RoutesRequirement
COMPOSITION and versioned-composition operationscomposition-<template> with the operation’s permission, the template read from the body or the stored version
EHR, EHR_STATUS, DIRECTORY, CONTRIBUTION, the item tags, the EHR extract (/message)composition- with the operation’s permission over every template: they are the EHR’s data, and the draft names no family of their own
operational templates, archetypes and artefactstemplate-<template> with the operation’s permission, the template from the {template_id} path parameter
query executionaql-<query> with s, the query from the {qualified_query_name} path parameter
stored-query definitionsaql-<query> with the operation’s permission
the DEMOGRAPHIC APIa client listed in [smart] demographic_clients, or the [auth] product_scope: no SMART on openEHR scope covers demographic data
the ADMIN APIthe [auth] product_scope
the terminology API, the ITI-81 audit retrievalan authenticated caller

Note

An unresolved resource id matches only a bare wildcard. A template upload or list, an ad-hoc query and an EHR-level operation name no template or query, so only a scope on every resource (* or **) covers them. Grant those scopes deliberately.

A system/aql-* grant “would grant access to all registered and ad-hoc AQL queries system-wide” (master08 §Resource Scopes), so it counts only for a backend client listed in [smart] backend_clients.

Launch context: binding to one EHR

When an app is launched for a patient, your authorization server puts the openEHR EHR id of the launch context in the token’s ehrId claim (master07 §Context Selection). A patient/ grant then reaches that one EHR:

  • it counts only for an issuer listed in [smart] patient_issuers, because an ehrId names an EHR of the platform that issued it;
  • an EHR route whose {ehr_id} names another EHR is refused, and so is an operation that names no EHR, such as creating an EHR;
  • a query runs only within the launch context’s EHR, named by its single-EHR ehr_id parameter or openehr-ehr-id header; a query naming no EHR, or another one, is refused;
  • it reaches EHR data alone, never a template definition.

user/ and system/ grants carry no such binding, and when a user/ grant covers the same operation the wider grant wins.

The discovery document

With [smart] enabled = true, the server serves the SMART configuration document, unauthenticated, at:

GET /ferroehr/rest/.well-known/smart-configuration

(relative to the REST root, or to [smart] platform_base_url when set). The document is built from the first [auth] issuer’s metadata, read at boot: the authorization, token, registration, introspection and revocation endpoints, the grant types and client authentication methods it supports, and its jwks_uri. It therefore needs an issuer whose metadata names a token_endpoint, which the document requires (SMART App Launch 2.2.0, Conformance, §Metadata). An issuer read from a jwks_file has no metadata of its own: [smart] issuer_metadata_file names a JSON file with its RFC 8414 §2 metadata, which must name that issuer and hold every endpoint to https (a loopback host excepted). Without it, enabled = true refuses the start.

export FERROEHR__AUTH__ISSUER=https://as.example/realms/ferroehr
export FERROEHR__AUTH__AUDIENCE=ferroehr-api
export FERROEHR__SMART__ENABLED=true
# The external origin absolute service URLs are built from; required.
export FERROEHR__SMART__PUBLIC_BASE_URL=https://cdr.example.com

A document looks like:

{
  "jwks_uri": "https://as.example/realms/ferroehr/certs",
  "authorization_endpoint": "https://as.example/authorize",
  "token_endpoint": "https://as.example/token",
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "private_key_jwt"],
  "grant_types_supported": ["authorization_code", "client_credentials"],
  "scopes_supported": [
    "openid", "profile", "launch",
    "user/composition-*.cruds", "user/template-*.cruds", "user/aql-*.cruds",
    "system/composition-*.cruds", "system/template-*.cruds", "system/aql-*.cruds"
  ],
  "services": {
    "org.openehr.rest": {
      "baseUrl": "https://cdr.example.com/ferroehr/rest/openehr/v1",
      "description": "The openEHR REST API baseUrl"
    }
  },
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"],
  "capabilities": [
    "permission-user", "client-confidential-symmetric",
    "client-confidential-asymmetric", "launch-standalone",
    "context-openehr-ehr", "openehr-permission-v1"
  ]
}
  • services names the openEHR REST service; org.fhir.rest is never advertised, because FerroEHR serves no FHIR API for clinical data. Each baseUrl is absolute, built by prefixing the CDR’s own base path with public_base_url, which is why that key is required.
  • capabilities carries what the server enforces: context-openehr-ehr and openehr-permission-v1 always, permission-user, and permission-patient only when patient_issuers binds an issuer; the client types and launch modes the issuer supports; sso-openid-connect, with the issuer member it requires, when the issuer’s metadata lists the openid scope; context-openehr-episode and launch-base64-json when their switches are on; and your [auth] capabilities.
  • grant_types_supported names only authorization_code and client_credentials, whatever else the issuer advertises: the implicit and password grants are deprecated (master06 §Deprecated Flows).
  • code_challenge_methods_supported always carries S256 and never plain.
  • scopes_supported lists [auth] scopes_supported when set, otherwise the scopes the route table honours above, with the patient/ ones only when an issuer is bound. It never copies the issuer’s own list: “the server SHALL support all scopes listed here”.

With enabled = false the path is not mounted at all (404).

Configuration keys

The keys live in [auth] and [smart]; see the configuration reference.

KeyDefaultMeaning
FERROEHR__SMART__ENABLEDfalseServe the discovery document.
FERROEHR__SMART__PUBLIC_BASE_URLunset (required when enabled)The external origin absolute services.*.baseUrl values are built from, e.g. https://cdr.example.com.
FERROEHR__SMART__PLATFORM_BASE_URLunsetBase the discovery document hangs off. Unset = the REST root (/ferroehr/rest). A leading path is honoured (/gateway/v1 → /gateway/v1/.well-known/smart-configuration).
FERROEHR__SMART__BACKEND_CLIENTS[]The client_ids a system/aql-* grant counts for.
FERROEHR__SMART__PATIENT_ISSUERS[]The issuers whose patient/ grants count.
FERROEHR__SMART__DEMOGRAPHIC_CLIENTS[]The client_ids that reach the DEMOGRAPHIC API.
FERROEHR__SMART__LAUNCH_BASE64_JSONfalseAdvertise the base64 JSON launch-parameter capability (experimental; consumed by the app, not the CDR).
FERROEHR__SMART__EPISODE__ENABLEDfalseAdvertise and accept episode launch context (experimental; advisory only, no episode filtering).
FERROEHR__AUTH__PRODUCT_SCOPE__ferroehr.platformThe scope that admits the ADMIN API and the DEMOGRAPHIC API.
FERROEHR__AUTH__CAPABILITIES[]Capabilities the discovery document adds.
FERROEHR__AUTH__SCOPES_SUPPORTED[]The scopes the discovery document lists.

Note

Episode context is experimental: enabling it advertises the capability and accepts launch/episode, but the server applies no episode-scoped filtering: openEHR has no first-class episode resource yet.

Audit trail (IHE ATNA)

FerroEHR keeps a full security audit trail of API access (who did what to which resource, with what outcome, from where, and when) following the IHE ATNA (Audit Trail and Node Authentication) profile, the standard the openEHR reference text points at (its platform service description names a system log “IHE ATNA-compliant”). It is on by default: every deployment records a queryable audit trail with zero external dependencies.

Warning

Two postures are legitimate to run and wrong to run silently, so the server says them at warn on boot (structured field posture = "audit"), in the audit_sender readiness indicator and on /management/info: auditing off ([audit] enabled = false) writes no access log and leaves the EHDS logging component off, and the shipped default fail_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 sets fail_mode = "closed", which answers 503 and Retry-After instead. A deployment declaring deployment_profile = "production" refuses to start with auditing off.

The trail is orthogonal to openEHR’s own CONTRIBUTION/AUDIT_DETAILS change-control audit (which the server always writes in the same transaction as every version change): openEHR audit records what a version says about its own authorship; ATNA records security surveillance of access, including reads and rejected attempts.

The record, in both official formats

Every audited operation produces one record, rendered in the two formats the IHE standards define. Both are written by the shared audit crates of the FerroHEALTH products (FerroAUDIT): ehds-logging writes the EHDS access record, and ihe-atna writes the BALP AuditEvent and the DICOM audit message and sends them. FerroEHR decides which accesses are logged, what each record says and where it is stored.

  • FHIR R4 AuditEvent following the IHE BALP 1.1.4 (Basic Audit Log Patterns) content profiles: the modern RESTful-ATNA form and the canonical stored form. An access to clinical content is the EHDS access record: ehds-logging writes it on the RESTful pattern of its action, with the patient behind the EHR as the patient entity (PatientRead, PatientCreate and the other Patient* claims), the categories, the retention, the security labels and the origins. Every other record (an authentication, a template or party operation, a refused access) is a BALP exchange FerroEHR fills directly. The person is the agent:user slice, named by the token’s issuer and subject, with the token’s jti as its policy, the caller’s roles as its role and the client application as an agent of its own (BALP 1.1.4 §3:5.7.5.4); a Basic user is named by the account alone, with no issuer and no client. The ITS-REST operation id is a second subtype (urn:openehr:its-rest:operation). The object an access record reads, creates, updates or deletes is named by its openEHR object id as entity.what.identifier in the system urn:openehr:object-id, never as a what.reference, since it is no FHIR resource (FHIR R4 references.html §2.3.0.3). The outcome is stated in outcomeDesc, and source.type names an application server. Query executions carry the search expression.
  • DICOM Audit Message (DICOM PS3.15 §A.5 XML): the classic ATNA form, shipped as RFC 5424 syslog over RFC 5425 TLS per IHE ITI-20 when the syslog sink is enabled, with the optional EventOutcomeDescription, AuditSourceTypeCode (4, an application server) and each participant object’s ParticipantObjectDataLifeCycle. 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 EventTypeCode 110122).

Both formats give the client and the server the same data-flow role: on a read the server is Source (DCM 110153) and the client Destination (110152), because the data moves from the server to the client (DICOM PS3.15 §A.5.2.7, the BALP Read profiles); on a create, an update and a query the client is Source (PS3.15 §A.5.3.20 for a query, the BALP Create, Update and Query profiles).

Every server operation is audited (an unrecognised extension operation fails closed to a generic audited class, so nothing is silently unaudited) and access refusals are always recorded: a 401, a 403, and the 400 a malformed Authorization header earns. A refusal is attributed to the caller only when one actually authenticated; an unattributable denial is recorded as unattributed rather than under a fabricated subject, so no audit record ever names an identity that did not authenticate.

Note

Successful-login records are suppressed by default ([audit] suppress_login_events = true). Set it to false to 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:

FieldWhat it holds
domainThe pseudonymisation domain read: ehr, demographic, linkage, or system for an operation that touches no domain.
organisationThe 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.
purposeThe purpose of use the caller declared in the x-purpose-of-use header, when the deployment accepts it.
emergency_accesstrue when the declared purpose is one of [audit] emergency_purpose_codes: an emergency access (EHDS Art. 11(5)).
legal_basisThe basis the deployment processes under, from [audit] legal_basis.
result_countHow many records the operation served.
request_idThe 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 forFerroEHR records it as
The identity of the person who accessed the recordprincipal — the authenticated Basic username or OAuth sub; an unattributable denial is recorded as unattributed, never under a fabricated identity. With [authz.professional] configured, professional_id names the natural person when the token names a client application
The identity of the patient whose record was accessedpatient_id — the resolved EHR subject, which under the pseudonymisation boundary is an opaque pseudonym
Which record, or which part of itresource_class + resource_id: the version uid, party uid or EHR id the operation touched
The date and timerecorded_at, the event time; stored_at is when the row was persisted
The kind of actionaction (DICOM C/R/U/D/E) plus operation, the released ITS-REST operation id. It equals the served OpenAPI operationId except for the demographic CONTRIBUTION pair, served as demographic_contribution_create and demographic_contribution_get because the released contribution_create and contribution_get also name the EHR operations and one document needs unique ids
Whether it succeededoutcome, the DICOM outcome indicator (0 success, 4 minor, 8 serious, 12 major)
Under which role or authorityroles — the roles the caller held at access time (the RFC 9068 §2.2.3.1 claim carriers for a bearer, the user definition for Basic), rendered as FHIR agent.role on the requestor and as a RoleIDCode per role on the DICOM requesting participant
On whose authority, and whypurpose and legal_basis, and permit, the identifier of the data permit a secondary-use caller declares it acts under (Secondary use)
Which system, and from whereAuditSourceID and the source participant’s network address (client_ip): the connection’s peer, or the client a peer in [server] trusted_proxies names in its forwarded_header (Server)

Two of these are the deployment’s to supply. purpose is only as meaningful as the vocabulary you agree with your callers: set [audit] purpose_codes to that vocabulary and a code outside it is recorded as absent rather than as free text that reads at review time like an established purpose. legal_basis is a deployment-level fact and is recorded on every access once you set it.

Mapping the recorded fields onto your own retention schedule and review process stays yours: the software records the events and serves them back, and no supplier document discharges the obligation to review them.

The EHDS logging elements, mapped

Annex II 3.2 of Regulation (EU) 2025/327 lists five things the European logging software component must record “on every access event or group of events”. This is where each one lands, or does not. The elements were read from the published text on 2026-09-10; the regulation is the authority and the wording below is a paraphrase.

Annex II 3.2Access-event fieldDICOM PS3.15FHIR AuditEventState
(a) the healthcare provider or other individuals having accessedorganisation— (no such attribute)an agent whose who is a logical reference to an OrganizationRecorded
(b) the specific natural person or persons having accessedprincipal (+ token_id, client_ip), and with [authz.professional] configured professional_id + acting_modeActiveParticipant/@UserID (the professional; a client application acting for one is a second, non-requesting participant; the token subject is @AlternativeUserID when it differs)the agent:user slice, who the token’s issuer and subject, the professional’s identifier as its ihe-otherId, and the client application as an agent of its own (BALP 1.1.4 §3:5.7.5.4)Recorded
(c) the categories of data accessedcategories + category_basis + category_evidence + category_map_digest, classified by ehds-logging through the [audit.categories] map (beside resource_class, domain and result_count)none (no such element)the entity named ehds-categories, with one ehds-category detail per category and its basis, each id the classification read and the map digest; each category is also a meta.security labelRecorded
(d) the time and date of accessrecorded_atEventIdentification/@EventDateTimerecordedRecorded
(e) the origin or origins of dataorigins + origin_count (the FEEDER_AUDIT originating systems of the served version bodies, or the server that created them, derived at commit onto version.origins)— (no such element)one entity named origin per origin, its system-id detail the originating systemRecorded

Read (a) beside (b): they are both about the accessing side, and the pair distinguishes the organisation on whose behalf the access happened from the natural person who made it. The principal answers (b); the organisation column answers (a), resolved from the same access-token claim the ABAC layer reads ([authz.abac] organization_claim) rather than from a second setting that could name a different claim. Sharing the setting is not sharing the switch: the organisation is recorded whether or not the ABAC gate is enabled, because the trail describes a caller rather than deciding anything about them. The FHIR rendering carries it as an agent of its own, a logical reference to an Organization, with no participation code, because AuditEvent.agent.type is optional and inventing a code would be worse than omitting one. The DICOM rendering carries nothing: PS3.15 §A.5 defines no organisation attribute on ActiveParticipant, and AuditEnterpriseSiteID is the reporting node’s own site rather than the caller’s organisation. Where no claim is configured or the caller authenticated with Basic, the field stays empty rather than being guessed.

Element (b) asks for a natural person, and a bearer token can name a client application instead. With [authz.professional] configured, every record says which it was and who stood behind it:

FieldMeaningDICOM PS3.15FHIR AuditEvent
acting_modeperson when the token names a natural person, client when it names a client applicationthe participant split belowthe agent split below
professional_idthe natural person: the person’s own claim, or the professional a client token acts forActiveParticipant/@UserID of the requesting participantthe ihe-otherId extension of the agent:user, typed NPI
principalthe token subject, as before: the client application in client modea second ActiveParticipant with UserIsRequestor="false" and the network address; @AlternativeUserID in person mode when it differs from the professionalagent:user who.identifier, with the issuer as its system; the acting mode is its first role
assurance_levelthe eIDAS level (low, substantial, high) the token’s assurance claim maps to under [authz.assurance]none (no such attribute)the ihe-assuranceLevel extension of the agent:user
assurance_valuethe raw claim value the level was read from, usually acrnonenone

PS3.15 §A.5 gives ActiveParticipant no assurance attribute, so the DICOM message and the syslog feed carry no level. The FHIR export carries the level as the BALP ihe-assuranceLevel extension (BALP 1.1.4 §3:5.7.5.4) and never the raw claim value, which lives in the local store’s column only. The tamper-evidence digest covers both columns, like every other column of the record (see Tamper evidence for records written before the digest was versioned).

Without [authz.professional] the mode and the professional stay empty and (b) is answered by principal, which names a client application when the token names no person. Without [authz.assurance] levels the level stays empty; the raw value is still recorded when the token carries the claim. A Basic credential records none of the four.

Element (c) asks for the priority category of the data, in the sense of Art. 14(1): patient summaries, electronic prescriptions, electronic dispensations, medical imaging, medical test results and discharge reports, plus any category a Member State adds in national law. AQL selects archetypes and templates, never categories, and no openEHR specification maps the one onto the other, so the deployment declares the mapping in [audit.categories]: a template id, or failing that the root archetype id, maps to a set of categories (a priority category by its code in HL7 Europe’s EEHRxFDocumentPriorityCategoryCS, such as Patient-Summaries or Laboratory-Reports, a national category as its <system>|<code>), or to none for content that holds no data of any category. While you declare no template and no archetype, the map FerroEHR ships applies: the published CKM templates and COMPOSITION archetypes whose data sit in one priority category (the shipped map). Your own map replaces it, and ehds-logging classifies each access through the map in force.

Each record is classified when the access happens, from what it served or wrote, and never at commit, because the map is your reading of your templates and can change:

  • a composition read or write is classified by the version’s template id, then by its root archetype id;
  • an AQL execution is classified by the template and root archetype ids of the versions its result page came from; where the answer carries no version (a DISTINCT or aggregate projection, or zero rows) by the query’s positive archetype and template constraints, so an operand under NOT CONTAINS or a predicate under NOT never counts. Every id the query constrains to must be mapped, and every content operand bound to an id, or the record is unclassified beside what did map. Each per-EHR record of a query carries the categories that EHR disclosed;
  • a logical delete writes no content, so it is classified by the version it deleted; a VERSIONED_COMPOSITION read by the composition it holds;
  • a CONTRIBUTION read, with or without Prefer: resolve_refs, is classified by the versions the contribution committed, a deleted version again by the version it deleted;
  • an EHR Extract export is classified by every version it carried, and an Extract import by the newest content of every versioned object it landed;
  • EHR, EHR_STATUS, EHR_ACCESS, the directory, item tags and revision histories are recorded as none, by resource kind.

An access the map cannot classify is recorded unclassified, with the template and archetype ids as evidence, and is never refused; the FHIR record says why (unmapped, unbound, named-nothing). A stored category is its <system>|<code> token. category_basis says what the classification was read from (returned, written, queried or resource-kind; records written before 5.3.2 carry template, archetype or query and the earlier spellings), and category_map_digest names the map that produced it, so a later map never rereads an old record. The map’s digest is also on the boot line and on GET /management/info under audit.category_map. The classification stays in the access log: it is never written to the request log, a span or a metric label. The DICOM rendering has no element for it (PS3.15 §A.5), so it travels in the local store and the FHIR export only.

Records in the demographic domain carry no classification, because no priority category lives there.

Element (e) asks where the DATA came from, which is not where the request came from. openEHR models that provenance as FEEDER_AUDIT on the content itself, so every committed version records the distinct set of FEEDER_AUDIT.originating_system_audit.system_id values found in its body, or this server’s own system id when it carries none. A composition, status or folder read then records the origins of the version it served, and an AQL execution the union over the version rows its result page came from. The record carries the true distinct count beside a set capped at 32, so a page drawing on more origins than that says how many rather than looking complete. A version committed before the column existed is assessed when it is read.

One further element, adjacent to the five: the declared purpose of use reaches one export of the two, and only one of them could carry it. It is stored on the record, served by ITI-81, and rendered as agent.purposeOfUse on the requesting person’s agent in the FHIR export. The coding carries the code alone, with no system: the vocabulary is the one you agree with your callers in [audit] purpose_codes, which has no published code system URI, and attributing the code to a system it does not come from would misdescribe it. The DICOM Audit Message schema of PS3.15 §A.5 defines no purpose element at all — EventIdentification, ActiveParticipant, AuditSourceIdentification and ParticipantObjectIdentification, and none of them carries one — so on that side it is a limit of the format, not something this code withholds.

Note

Every row of this table is asserted by a test (app/ferroehr/tests/it/audit_ehds_mapping.rs), including the gaps: the tests that record an absence fail the day the field arrives, so the table and the code cannot drift apart in either direction.

The common specifications the regulation’s Article 36 provides for have not been adopted. When they are, this mapping is re-verified against them rather than assumed to still hold; see the EHDS readiness page.

Emergency access (EHDS Art. 11(5))

A natural person may restrict health professionals’ access to parts of their data (EHDS Art. 8). Art. 11(5) lets a professional reach restricted data where the person’s vital interests require it, and requires such cases to be “logged in a clear and understandable format” and “easily accessible for the data subject”. Art. 9(1) counts these accesses among the ones the person is told about.

You name the purpose codes your callers declare for an emergency in [audit] emergency_purpose_codes. Each one must also be on [audit] purpose_codes; the server refuses to start otherwise. An access whose declared purpose is one of them carries the emergency mark:

  • The stored record: the emergency_access column is true. It is false for every other access and for records written before the column existed.
  • The FHIR AuditEvent: the entity named ehds-emergency-access that ehds-logging writes, whose description states the mark in plain words (“Emergency access: the accessor declared that the access was necessary to protect the vital interests of the data subject (Regulation (EU) 2025/327 Art 11(5)).”) and whose ehds-emergency-purpose details name the purposes that marked it. The declared code stays on agent.purposeOfUse and on purposeOfEvent. A patient portal keys on the entity and shows its description.
  • The DICOM syslog feed: no mark. The PS3.15 schema has no purpose element.

The person reads their own log, the marked accesses included, through the subject-scoped ITI-81 retrieval.

[audit]
purpose_codes = ["TREAT", "ETREAT"]
emergency_purpose_codes = ["ETREAT"]

The mark records the declaration and lifts no restriction. A composition or EHR restricted under GDPR Art. 18 stays refused with an emergency purpose, because Art. 18(2) admits processing only with consent, for legal claims, for the rights of another person or for an important public interest, and the person’s own vital interests are not on that list. The refused attempt is still logged with the mark. An EHDS Art. 8 restriction of access by health professionals is different: a caller holding authz.rbac.emergency_access_role that declares an emergency purpose reads the restricted data, and its record carries the mark (restriction of access). An emergency the caller does not declare is not marked.

Retention, and who chooses it

[audit.store] retention_days decides how long the repository keeps a record: 0, the default, keeps them forever, and any other value sets the horizon. Every record stores the first date it may be deleted on, and the reaper deletes each record hourly on or after that date and never before it. An access record takes its end from the retention its categories and origins call for (Regulation (EU) 2025/327 Annex II 3.4), so two records written the same day can end on different dates; every other record ends the day after its date of access plus the horizon. A record written before FerroEHR stored per-record ends is reaped by the horizon.

The default is deliberate. An access log is the evidence a data subject’s right of access is served from, and a repository that silently forgets is worse than one that grows — so the software keeps everything until an operator says otherwise, rather than choosing a horizon on their behalf.

Choosing that horizon is the deployment’s, and it is a legal question: EHDS Art. 9(2) makes the access information “available for at least three years from each date of access to the data”, and national law can ask for longer. NEN 7513 is the Dutch reference for this log and the Wabvpz for how long it must survive. The setting is per node, and one node serves one organisation, so two organisations needing different horizons run two instances.

A floor is enforced where a jurisdiction sets one. The jurisdictions a deployment answers to are the ones its [privacy.identifier_scan] rules name, and where one publishes a minimum retention for the access log the server refuses to boot with a shorter horizon, naming the configured value and the floor. 0 always passes. The floors are written in calendar years, as the law writes them: a horizon set as [audit.store] retention_years is compared against the years exactly, and one set as retention_days against the most days those years can span (the day count in brackets below). The floors registered:

JurisdictionFloorSource
Every EU Member State (AT, BE, BG, CY, CZ, DE, DK, EE, ES, FI, FR, GR, HR, HU, IE, IT, LT, LU, LV, MT, NL, PL, PT, RO, SE, SI, SK)three years from each date of access (1096 days, the most three calendar years can span)Regulation (EU) 2025/327 (EHDS) Art. 9(2); a longer national floor wins. Iceland, Liechtenstein and Norway are not listed: EHDS reaches the EEA only through an EEA Joint Committee decision
NLfive 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
CHone year, and kept apart from the processing system (366 days, the most one calendar year can span)Datenschutzverordnung (DSV) Art. 4 Abs. 5, as amended on 1 December 2025, for the logs of a large-scale automated processing of sensitive personal data; the separate storage is the deployment’s, through the forwarding sinks

For the Netherlands the five national years win over the three EHDS years; Switzerland is outside the Union and keeps its one year. Of the other shipped jurisdictions, DE, FI and SE carry the EHDS floor, while GB and NO have no floor registered: an unknown requirement is never guessed at, and a deployment there sets its own. Art. 9 applies from 26 March 2029 for patient summaries, prescriptions and dispensations and from 26 March 2031 for the other priority categories (Art. 105). The floor is enforced from this release on, so a log kept today already meets it when Art. 9 applies. The effective retention is on the boot line beside the audit-enabled facts and on GET /management/info under audit.

Getting the log out

Two routes, and they answer different questions.

  • ITI-81 retrieval — the audit search serves the records back as FHIR AuditEvent resources, filtered by patient, agent, date and outcome. This is the operator’s route and the one a patient portal would build on: everything Annex II 3.2 records is reachable through it, including the purpose and the organisation, which the syslog feed’s DICOM form cannot carry.
  • The BALP feed — records fan out to syslog and to a FHIR feed as they are written, for a SIEM or a central Audit Record Repository.

There is no patient-facing route in the product: a portal that shows a person who accessed their record builds on ITI-81 and authenticates the person itself. What the product gives that portal is a grant of its own size: authz.rbac.subject_audit_role reads the log for one subject at a time, with the patient parameter required, so the portal never holds an admin credential over every patient’s log (GDPR Art. 15 with Recital 63, under which the controller “should be able to provide remote access to a secure system” where possible; EHDS Art. 9; for Dutch deployments Wabvpz Art. 15e). The portal’s build is the deployment’s, and the shared-responsibility page says so.

Sinks

Records fan out to independently configured sinks ([audit] in ferroehr.toml; see the configuration reference):

SinkDefaultWhat it does
[audit.store]onThe 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]offThe classic ATNA feed: DICOM PS3.15 XML as RFC 5424 syslog over RFC 5425 TLS to an external ARR, per IHE ITI-20.
[audit.fhir_feed]offThe RESTful-ATNA feed (ITI-20 ATX: FHIR Feed): each FHIR AuditEvent is POSTed to an external FHIR ARR. With the local store on, delivery is outbox-driven: an ARR outage loses nothing, pending records ship on recovery. The outbox holds the records written while the feed is configured; with the feed off, no record is owed. Records stored before FerroEHR kept an outbox and never delivered ship once a feed runs.

Emission never blocks the request path: a record is handed to a bounded queue (queue_capacity) and written by a background drain.

The local store is the durability anchor. Under fail_mode = "closed", an operation whose audit record cannot be recorded answers 503 Service Unavailable: the deployment demanded an audit trail it cannot currently deliver, so no un-audited PHI access happens, and the same holds for the domain-level records the linkage, subject and identifier resolutions and the EHR-Extract transfers write. open, the default, drops-and-meters instead, and every loss path is metered (the atna_audit_* counters in Operations); the server announces that default at boot and on the readiness indicator as a stated caution, because a metered loss is still a loss. Login and rejection records never gate a response in either mode.

Tamper evidence

An audit trail that anything with the application’s database password can quietly rewrite is a log, not an accountability record. The local store is therefore tamper-evident: every record is linked into a SHA-256 hash chain maintained inside PostgreSQL, so each record commits to its predecessor and to its own content. The chain is built by the database itself, not by the server, which means it covers every writer: the per-event insert, the batched drain, and any statement typed by hand.

Each record’s digest covers every column of the record except the digest itself (row_hash) and the two per-sink forwarding stamps (delivered_syslog_at, delivered_fhir_feed_at). A column added by a later release joins the digest in the same release.

The store keeps a record’s facts in columns and renders its FHIR AuditEvent from them when the record is read (ITI-81) or delivered (the FHIR feed). What the rendering takes from the configuration (source_id, enterprise_site_id, server_host, value_if_missing, the store horizon, [audit.categories] and emergency_purpose_codes) is kept once per distinct configuration in audit.audit_render_policy, keyed by the SHA-256 of its text, and every record names the policy it was written under. A record therefore renders the same document after the configuration changes, and the verification below reports a policy whose text no longer matches its digest, as it reports a changed record. Release 5.6.0 starts on an empty audit store: its migration refuses a store that holds records written by an earlier release, and names the remedy.

Four controls sit on top of it, and they are separate on purpose:

  • The table refuses the ordinary rewrite paths outright. The only permitted change to a stored record is the per-sink forwarding stamp; an UPDATE of any other column, a DELETE, and a TRUNCATE are refused by the database. Retention pruning goes through the one sanctioned deletion path, which records which records it removed and what the surviving chain must link back to, so reaping does not look like tampering, and tampering does not look like reaping.

  • The privileges are narrow. The runtime role may record an event, stamp it forwarded, and read the trail back. It holds nothing that can rewrite a record, remove one, or alter the chain’s own bookkeeping. That posture is only fully in force with db.migrate = "verify"; see Operations, because a self-migrating server owns the schema and can therefore turn the enforcement off.

  • Verification is a query you can run yourself. It recomputes every digest, re-walks every link, and checks both ends of the chain:

    SELECT * FROM audit.verify_audit_chain();
    

    An empty result means the trail is intact. Any row names one record (its chain position, its id, when it was recorded) and what is wrong with it: content modified after it was written, a render policy changed or missing, records deleted with no retention record for the removal, or records removed from the end of the chain where no successor would have noticed.

  • The server runs the check itself, on a schedule. With the local store on, the check runs one minute after boot and then every [audit.store] verify_interval_seconds (default 3600, one hour; 0 turns it off). Each run verifies the records written since the newest link an earlier run found intact, and the chain head, so its cost follows the records written since rather than the size of the trail (audit.verify_audit_chain_incremental()). A full pass runs every [audit.store] full_verify_interval_seconds (default 604800, one week; 0 leaves it to the query above) and after every run that found damage, so damage to an older record stays reported. A run that finds damage logs at ERROR, one summary line with the finding count and one line per finding (the first 20), and adds the count to the atna_audit_chain_findings counter with kind = "damage". A run that cannot verify at all counts as kind = "unverifiable", because a trail that cannot be checked is never a pass. The latest result is the audit_chain indicator on GET /health/readiness: UP while intact and DEGRADED when damaged or unverifiable; its detail on GET /management/health names the first finding. It never flips readiness, because the trail keeps recording while the damage is investigated. Alert on the counter or on that component, and use the query above to see every finding. Each replica runs its own check.

Important

This is detection, not prevention, and the boundary is worth stating plainly. The chain is unkeyed, so a party with unrestricted write access to the audit schema (the database owner, or a superuser) can delete a record and recompute every hash after it. What closes that case is keeping the trail somewhere that party does not control. Enable [audit.syslog] or [audit.fhir_feed] so records leave the box as they are written, and give the server an app-role-only DSN so it is not that party.

Retrieving audit records (ITI-81)

The RESTful-ATNA ITI-81 Retrieve ATNA Audit Event transaction is a FHIR search on AuditEvent, so it is served at FerroEHR’s FHIR R4 base, /ferroehr/rest/fhir/r4, beside the openEHR API rather than under it, where a FHIR client or an IHE Audit Consumer expects [base]/AuditEvent. It is the one FHIR route FerroEHR serves:

GET /ferroehr/rest/fhir/r4/AuditEvent

It returns a FHIR searchset Bundle of the matching records’ AuditEvent documents, rendered from the store, newest first, with the full match total, a self link and a next link while more pages follow. The query is read by ihe-atna, the family’s audit crate. At least one date parameter is required (RESTful ATNA §3.81.4.1.2.1): a query without one is a 400. Every search parameter §3.81.4.1.2.2 says the repository shall support is applied. A parameter with several comma-separated values matches any of them, and every parameter of the query must match. A token takes the three forms of FHIR R4 Search §3.1.1.4.10: code, |code (recorded without a system) and system|code (or system| for any code of that system). FerroEHR records identifiers without a system, except the object of an access record, whose system is urn:openehr:object-id: entity.identifier=urn:openehr:object-id|<uid> and entity.identifier=<uid> find it, and a query that names another system for an identifier matches nothing. Every parameter answers through an index. A parameter on an element of the document (address and the .identifier, entity-type, entity-role, type and subtype parameters) matches the search tokens the store takes from the record’s rendered document when it writes the record (audit.audit_search_token, audit.audit_search_set and the record’s search_tokens), so the search finds what the served document says and renders only the page it serves.

ParameterMatches
daterequired, repeatable: a FHIR date value with a prefix (eq when none, gt, lt, ge, le, sa, eb); a day such as 2026-07-12 covers the whole day, read in UTC
patientthe recorded patient (EHR subject) id
patient.identifierentity.what.identifier of the entity whose role is Patient
agentthe authenticated principal
agent.identifieragent.who.identifier
entitythe touched resource id
entity.identifierentity.what.identifier of any entity
entity-typeentity.type
entity-roleentity.role
source.identifiersource.observer.identifier, the audit source id
typetype
subtypesubtype (the REST interaction and the openEHR operation)
addressa part of agent.network.address, compared without regard to case
outcomethe outcome indicator: 0, 4, 8 or 12 (FerroEHR records 0, 4 and 8)
actionthe action code: C, R, U, D or E
_count / _offsetpaging; page size defaults to 50, capped at 1000; _count=0 returns the total alone

A malformed value is a 400 carrying a FHIR OperationOutcome with invalid; another FHIR R4 AuditEvent parameter, which §3.81.4.1.2.2 does not ask for, is a 400 with not-supported; a parameter the crate does not know is ignored (§3.81.4.1.3). Under RBAC the unscoped retrieval is admin-only; a caller holding the configured authz.rbac.subject_audit_role reads it scoped to one subject: one patient or patient.identifier with one value is required, and the caller is refused with a 403 that names the parameter when it omits it. Reading a subject’s log is itself recorded as an access naming that subject and the record count served. An emergency access in the subject’s log carries its mark as the ehds-emergency-access entity. The surface answers 404 when the local store is disabled.

# Who accessed patient-42's data this month?
curl -u admin:pw \
  "https://cdr.example.org/ferroehr/rest/fhir/r4/AuditEvent?patient=patient-42&date=ge2026-07-01T00:00:00Z"

This is also the openEHR “record demerging” instrument: when data lands in the wrong EHR, the patient-filtered audit search shows exactly who read it.

Note

Audit records are themselves sensitive (they name patients, subjects and actions) so this endpoint is a PHI-disclosure surface and is authorized like any other.

Node authentication (ITI-19, mutual TLS)

ATNA’s second half is node authentication. [server.tls] terminates TLS natively and can demand a verified client certificate:

[server.tls]
certificate_file = "/etc/ferroehr/server.pem"
key_file = "/etc/ferroehr/server.key"
min_version = "1.3"               # 1.3 (default) | 1.2
client_auth = "required"          # none | optional | required
client_ca_file = "/etc/ferroehr/client-ca.pem"

The protocol floor is TLS 1.3 only by default, as RFC 9325 §3.1.1 has it supported. Setting min_version = "1.2" enables 1.2 alongside 1.3, never instead of it, for a client that genuinely cannot do 1.3, such as an older integration engine or a pinned Java runtime. TLS 1.1 and 1.0 are refused when the configuration is read (RFC 8996).

With client_auth = "required", only clients presenting a certificate chaining to your explicit trust anchor complete the handshake: the IHE mutually-authenticated-node posture. Deployments terminating TLS at an ingress leave [server.tls] unset and enforce mTLS there instead.

Warning

The separate-port management listener (management.port) stays plain HTTP even with [server.tls] set, and it binds all interfaces. Treat it as an internal surface and keep it off any publicly routed port. Under deployment_profile = "production" setting it opens the plaintext_listener gap, so the server refuses to start unless the gap is accepted by name.

Complete the posture with time synchronisation (IHE Consistent Time): run NTP/chrony on every node so audit timestamps align across systems.

Against independent ATNA implementations

The transactions above are tested by FerroEHR’s own suites, which read the profiles the way FerroEHR’s authors read them. EHDS Annex II 1.4 asks that a component meant to work with other products does so reliably, so scripts/interop/atna.sh runs the quickstart against software FerroEHR’s authors did not write, each from a published image:

TransactionIndependent implementationWhat the run requires
ITI-20 FHIR feedHAPI FHIR, an open-source FHIR R4 server, as the Audit Record Repository, with IHE BALP 1.1.4 loadedIt accepts every record, and its validator finds no error against base R4 and the BALP profile each record names
ITI-20 syslogsyslog-ng over RFC 5425 TLSEvery message parses as RFC 5424, and its payload is a well-formed DICOM PS3.15 A.5.1 AuditMessage (libxml2)
ITI-81HAPI FHIR’s AuditEvent search over the records the feed deliveredThe same searches answer the same records on both
cargo build --release -p loadtest
bash scripts/interop/atna.sh ghcr.io/ferrohealth/ferroehr:<tag> \
  ghcr.io/ferrohealth/ferroehr-postgres:<tag> atna-out

It prints every disagreement and exits non-zero on any. A disagreement is either FerroEHR’s or a shared crate’s defect, filed and fixed, or the other side’s, recorded with the profile text that shows it.

The run of 2026-10-29 found no disagreement: HAPI FHIR accepted and validated all 71 records of the window, syslog-ng parsed all 72 messages, and the ten ITI-81 searches answered the same records on both repositories (the record).

Configuration summary

Auditing defaults to on with the local store only; see the configuration reference for every [audit] key and its FERROEHR__AUDIT__* environment form. The syslog sink’s own keys are host / port / tls_ca_file / tls_identity_cert_file / tls_identity_key_file under [audit.syslog]; the switches shared by every sink stay directly under [audit].

Note

There are no alternative spellings for any of these: an unrecognized FERROEHR_* variable or an unknown TOML key is a boot error naming the spelling it should have had, never a silently ignored setting. Validate a deployment’s configuration up front with ferroehr 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).

ModeWhat is storedWhat it establishes
digest (the default)sha256: + base64(SHA-256(canonical form))the version has not been altered since committal
pgpan ASCII-armored RFC 4880 detached signaturethat, 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

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, the OBJECT_VERSION_ID this repository allocated;
  • preceding_version_uid, when the version has a predecessor;
  • contribution, the OBJECT_REF of the enclosing CONTRIBUTION;
  • commit_audit, including its committer and time_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 data attribute at all: openEHR deletes by committing a new version whose data is Void. Its digest therefore covers the identity, provenance and 523|deleted| lifecycle state, and nothing else. It is a signature over a real version, not an empty one.

When it is computed

At commit, inside the write transaction, before any row is inserted. The commit instant and the CONTRIBUTION id are both known up front (the commit instant is the transaction timestamp), so the server assembles the exact version envelope it will later serve, signs that, and writes the version row, the CONTRIBUTION and the audit together.

Two properties follow, and both matter for verification:

  • The signed bytes and the stored bytes are the same bytes. The content is decomposed into storage rows once and reassembled once; that reassembled value is what gets signed and what a read returns. There is no second serialization that could drift.
  • A version that fails to sign does not commit. Signing sits inside the transaction, so a canonicalization or signer failure rolls the whole change back rather than storing an unsigned version.
flowchart TD
    A["commit<br/>(direct write or CONTRIBUTION)"] --> B{"UPDATE_VERSION.signature<br/>supplied by the client?"}
    B -->|yes| C["stored verbatim<br/>signature_client_supplied = true"]
    B -->|no| D{"signing.enabled"}
    D -->|false| E["no signature stored"]
    D -->|true| F["assemble the VERSION envelope<br/>uid, contribution, commit_audit,<br/>lifecycle_state, data,<br/>at-committal attestations"]
    F --> G["canonical form:<br/>drop signature, RFC 8785 (JCS)"]
    G --> H{"signing.mode"}
    H -->|digest| I["sha256: + base64(SHA-256(canonical))"]
    H -->|pgp| J["RFC 4880 detached signature,<br/>ASCII-armored"]
    I --> K[("version row<br/>same transaction")]
    J --> K
    C --> K
    E --> K

What a stored digest proves

It proves that the version served today canonicalizes to the same bytes it did at commit. Any change to the content, the committer, the commit time, the lifecycle state or an at-committal attestation moves the digest, so a row edited behind the server’s back is detected the next time that VERSION is read.

It proves nothing about who wrote the version. A digest needs no key, so anyone holding the bytes can compute the same value, and an attacker who can rewrite a version row can rewrite its digest with it. Digest mode is a tamper-detection control against accidental corruption and against an actor who reaches the data but not the write path. Authorship and non-repudiation need a key, which is PGP mode.

It also covers one of the two copies FerroEHR stores. Every version’s content is written twice in the same transaction: as the materialized document a point read serves, and as the decomposed rows the AQL engine queries. The digest is computed over the first one, and read-time verification recomputes it from the same place. The decomposed rows are never recomputed on a read, so a row edited behind the server’s back is invisible to this check and can still reach a client through an AQL scalar result.

That copy has its own channel: POST /ferroehr/rest/extensions/v1/admin/integrity/verify re-derives every stored version from its decomposed rows and reports any that no longer match the stored document. It is an admin route, it runs outside the request path, and it reports by identifier rather than by content. The admin API reference documents the report and its five defect values.

Verification at read

signing.verify_on_read resolves to strict whenever signing is enabled, so the default deployment checks its own digests. On a VERSION read the server rebuilds the envelope from the stored row, recomputes the canonical form, recomputes the digest, and compares.

verify_on_readOn a mismatch
strict (the default while signing is enabled)500; the record is provably corrupt and is not served
warnlogs at error level, increments version_signature_invalid_total{verdict="digest_mismatch"}, still serves
offno 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 strict mismatch is a 500 on 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, warn is 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.signature a 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_VERSION wrapped 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:

  1. read the stored value, jq -r '.signature' version.json;
  2. drop that member, jq 'del(.signature)' version.json;
  3. 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);
  4. openssl dgst -sha256 -binary, base64, and prefix sha256:.

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 attestations list 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

digestpgp
Stored valuesha256: + base64(SHA-256(canonical form))ASCII-armored RFC 4880 detached signature
Key materialnonean armored secret key the server loads at boot
HashSHA-256SHA-256, inside the OpenPGP signature
Provesthe version was not altered after committalthat, plus which key signed it
Boot behaviournothing to validaterefuses to start without a usable key
Verification at readrecompute and compareverify against the configured certificate

The bytes being signed do not change. The server assembles the version envelope, drops the signature attribute, reduces it to its RFC 8785 canonical form, and hands that string to the signer. Only the last step differs.

Configuring the key

pgp mode needs a key_path pointing at an armored secret key, and the passphrase that unlocks it if it has one:

[signing]
enabled = true
mode = "pgp"
key_path = "/etc/ferroehr/signing.asc"
key_passphrase_file = "/run/secrets/pgp-pass"
retired_key_paths = ["/etc/ferroehr/signing-2025.pub.asc"]

The same settings as environment variables:

FERROEHR__SIGNING__MODE=pgp
FERROEHR__SIGNING__KEY_PATH=/etc/ferroehr/signing.asc
FERROEHR__SIGNING__KEY_PASSPHRASE_FILE=/run/secrets/pgp-pass
FERROEHR__SIGNING__RETIRED_KEY_PATHS=/etc/ferroehr/signing-2025.pub.asc

Prefer key_passphrase_file over the inline key_passphrase: it is the shape Docker Secrets and Kubernetes Secrets deliver, and the passphrase then never appears in the environment. Setting both of the pair is a boot error. The full key table, the Kubernetes config.files mount, and the rotation walkthrough are in the configuration reference.

Boot is fail-closed. The server loads the key, then signs a fixed test string with it. A missing key_path, an unparseable file, a wrong passphrase, or a certificate with no usable signing component all stop startup with an error rather than leaving a running server that cannot sign.

The signing component is chosen at boot. No subkey signs while the primary key is revoked (RFC 9580 §5.2.1.11: “A revoked key is not to be used”) or past the Key Expiration Time its self-signature states (§5.2.3.13). RFC 9580 does not say in so many words that a subkey inherits its primary key’s revocation or expiry, so applying it is FerroEHR’s own choice: the primary key’s binding is what vouches for the subkey. Otherwise a subkey qualifies to sign when all of these hold:

  • its newest valid Subkey Binding signature (RFC 9580 §5.2.3.10) carries the data-signing key flag 0x02 (§5.2.3.29);
  • neither that binding nor the subkey has expired (§5.2.3.13);
  • no valid Subkey Revocation signature covers it (§5.2.1.12: “A revoked subkey is not to be used”).

Of the qualifying subkeys, the one created last signs; on equal creation times, the later one in the file. RFC 9580 gives no rule for choosing among several signing subkeys, so preferring the newest is FerroEHR’s own choice. It is what makes adding a signing subkey a rotation. An encryption subkey never signs.

When no subkey qualifies, the primary key signs, provided the certificate lets it:

  • its governing self-signature carries the signing flag 0x02. RFC 9580 keeps the primary key’s flags in a Direct Key signature or, by version 4 convention, in a User ID self-signature (§5.2.3.10), so the governing one is the newest valid Direct Key signature when it carries key flags, else the newest valid self-signature on the primary User ID. A Direct Key signature that a valid Certification Revocation created at or after it revokes is cancelled and not chosen (§5.2.1.13, §5.2.3.10: “Revoking a Direct Key signature cancels that signature”). A version 6 key without a valid Direct Key signature in force is not used at all, as §5.2.3.10 requires. A self-signature with no key flags grants no signing, since §5.2.3.29 counts an unstated flag as zero. RFC 9580 gives no precedence between the two places, so preferring the Direct Key signature is FerroEHR’s own choice;
  • neither that self-signature nor the primary key has expired (§5.2.3.13, counted from the primary key’s creation);
  • no valid Key Revocation signature covers it (§5.2.1.11: “A revoked key is not to be used”).

When neither a subkey nor the primary key may sign, the server refuses to start and names the signing flag. It never signs with a key the certificate does not authorise. Add a signing subkey (gpg --quick-add-key <FINGERPRINT> ed25519 sign), or re-issue the primary key’s self-signature with the signing flag.

The choice holds until the next restart, so restart after adding, revoking or expiring a subkey, and before the chosen component expires.

Sign with Ed25519. It is the one algorithm the server signs with that has neither a refusal nor a warning.

An ECDSA or Ed448 signing key is refused at boot. The private-key arithmetic of both runs on crypto-bigint 0.5, whose operations meant to be constant-time can branch on secret values (RUSTSEC-2026-0387), and the 0.5 line has no fixed release. That covers ECDSA on every curve (P-256, P-384, P-521, secp256k1, Brainpool). The server stops before it makes a single signature and names the advisory. Only the signing component counts: a certificate whose primary key is ECDSA and whose chosen signing subkey is Ed25519 is accepted. Such a certificate stays usable for verification as a retired key, since verifying uses only public values.

An RSA signing key is accepted with a warning. Every commit would then perform an RSA private-key operation, the operation the Marvin timing sidechannel (RUSTSEC-2023-0071 / CVE-2023-49092) concerns, and the underlying rsa crate has no fixed release. The server warns at boot and keeps working, because a repository whose history is RSA-signed still needs that key to verify it. An Ed25519 key keeps that code off the signing path.

Retired keys and rotation

A stored VERSION.signature records no key identifier, and it is an immutable committed fact that cannot be re-issued. Rotating a key therefore has to keep the old one verifiable. Two mechanisms do that:

  • A new signing subkey on the same certificate. After a restart the server signs with the newest qualifying subkey (see above), the certificate retains the previous one, and verification tries the primary key and every subkey. Revoking the old subkey stops it signing and keeps it verifying. Nothing else changes.
  • retired_key_paths. A replaced certificate’s public half is listed there and consulted during verification. A public key verifies and can never sign again, so a retired entry cannot become an active signer.

A retired certificate may use any algorithm the server can verify, ECDSA, Ed448 and RSA included, so moving off a refused signing key keeps every version it signed verifiable.

Server-generated versus client-supplied signatures

A VERSION.signature in this repository is one of two things, and the distinction is recorded on the row rather than guessed from the value.

Server-generated is the ordinary case. The direct write routes (POST/PUT/DELETE on a composition, the EHR status, the directory, a demographic party) carry no signature field at all, so every version they commit is signed by this server when signing is enabled.

Client-supplied reaches the server through one route: an UPDATE_VERSION.signature inside a CONTRIBUTION commit. When a member carries one, the server stores it verbatim and does not sign that version itself, whatever signing.mode says. openEHR models the signature as a fact created by the committer and carried with the data, potentially in another agreed serialization, so the server has nothing to recompute it against. Such a signature is never re-verified at read, and no verify_on_read setting changes that.

Note

A client-supplied value is stored as sent, so an opaque or invalid one is served back unchanged rather than refused. It is a claim by the committer, and this server neither vouches for it nor mistakes it for its own. Only signatures the server generated are covered by read-time verification.

Imported versions carry two signatures

An EHR Extract import wraps each received ORIGINAL_VERSION in an IMPORTED_VERSION, and openEHR is explicit about what happens to signatures there. The wrapped original “is never modified”, and the wrapper is signed like any other version: “all attributes of the object are serialised and then used to generate a signature. The result will be that the IMPORTED_VERSION instance will carry its own signature which signifies the act of importing and making available locally an ORIGINAL_VERSION from another system.”

FerroEHR implements exactly that:

  • The wrapper is signed by this server, over the whole IMPORTED_VERSION including the wrapped item, 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 item untouched. 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.
  • strict is the default while signing is enabled. A mismatch is a 500 rather than a served record that is provably altered. warn downgrades that to a logged and metered event, which is a deliberate reduction in an integrity guarantee rather than a setting to leave on.

FerroEHR Viewer

ferroehr-viewer is a standalone web UI for managing an ITS-REST-1.1.0 CDR, this server or any other. It is a pure REST client: everything it does goes through the CDR’s public API (never the database), so what you see in the viewer is exactly what the API serves. The whole application is Rust, drawn on the server as plain HTML with FerroUI, the component library the FerroHEALTH consoles share; the only scripts are FerroUI’s own small modules, and there is no hand-written JavaScript anywhere, including its browser tests.

Running it

The quickstart compose ships the viewer as the ferroehr-viewer service on port 3000, behind the viewer profile, so it is opt-in and does not start with a plain docker compose up:

docker compose --profile viewer up
# → http://localhost:3000  (log in with the quickstart user ferroehr/ferroehr)

Standalone, point it at any CDR:

docker run -p 3000:3000 \
  -e FERROEHR_VIEWER__CDR__BASE_URL=https://cdr.example.org \
  ghcr.io/ferrohealth/ferroehr-viewer

On Kubernetes

The Helm chart deploys the viewer as its own Deployment, Service and ServiceAccount, with an optional Ingress and a NetworkPolicy that confines its egress to the CDR and DNS: the viewer is a REST client of the CDR by mandate, so the chart enforces that rather than trusting it. Off by default, and off renders nothing:

# values.yaml
viewer:
  enabled: true
  ingress:
    enabled: true
    hosts:
      - host: viewer.example.org
        paths:
          - path: /
            pathType: Prefix
  auth:
    oidc:
      enabled: true
      issuer: https://keycloak.example/realms/ferroehr
      clientId: ferroehr-viewer
      publicBaseUrl: https://viewer.example.org
  # the OIDC client secret is MOUNTED from a Secret you create, never env-borne
  existingSecret: viewer-oidc

It needs no database credential and never reaches the database. Before you enable OIDC: a registered client whose redirect URI matches publicBaseUrl, and a Secret holding its client secret. To turn the viewer off, set viewer.enabled: false and upgrade; every viewer object is removed and the CDR is untouched.

Note

To scale the viewer past one replica, set the same session.secret on every replica: the session is a sealed cookie any key-holding replica can serve. Without a configured secret each replica seals with its own ephemeral key, and visitors get signed out whenever a request lands on another pod.

Signing in

The sign-in page offers exactly the methods that can actually work: the viewer’s configured login modes intersected with the authentication schemes the CDR advertises (its WWW-Authenticate challenge). A Basic form is never shown against a bearer-only CDR, and vice versa. If the CDR cannot be reached at all, the page falls back to the viewer’s own configuration and renders anyway, and the outage then surfaces on the login attempt instead of hiding the page. A username or password the CDR refuses brings you back to the sign-in card with the refusal under the password.

The viewer verifies the CDR and the OIDC issuer against the platform trust store and the extra certificate authorities its configuration names: a CDR or an identity provider whose certificate comes from a private CA is trusted once cdr.tls.trust_roots_file or auth.oidc.tls.trust_roots_file names a PEM bundle holding that CA, on Linux and macOS alike. A file that cannot be read, or holds no certificate, refuses the start.

The viewer manages no accounts of its own: it authenticates you against the CDR (Basic) or your identity provider (OIDC), and there are no user, role, or password screens to find; those live in the CDR’s configuration and in your IdP.

The button with your name at the end of the top bar opens the user menu: who is signed in and how, Your preferences, Your access, the theme, Keyboard shortcuts and Sign out.

Your access

Your access (/access) answers what may this session do, and who says so: the principal and how it signed in (a Basic session replays its CDR account and carries no SMART scopes; an OIDC session carries an access token whose scopes are listed), every scope on the session read as its parsed grant (the compartment, the resource family and id pattern, and which of create, read, update, delete and search it permits, with a broad access marker on a bare *; an identity or launch scope such as openid, profile or launch/patient reads as the request it makes), and a previewer: paste any scope or a whole claim, press Read it, and read the same rendering. A malformed scope says what the grammar expected. The user menu and Server’s SMART tab link here.

The reading parses with the same grammar the CDR’s SMART on openEHR enforcement reads, so the two cannot drift. Scopes narrow access and never grant it; the CDR remains the enforcer.

When your session ends

A session ends when you sign out, when its idle window (session.idle_minutes) passes with no request from your browser, or when the CDR stops accepting the credential behind it (an expired or revoked token). Every page is drawn on the server, so the next page you ask for is where the viewer notices: it answers with the sign-in page, which says the session ended, and signing in again (Basic or OIDC) returns you to the page you asked for.

The idle window slides on each request your browser makes. The viewer polls nothing in the background, so a tab left open and untouched idles out after session.idle_minutes like a closed one, and the page it shows stays as it was drawn until you ask for another.

The deployment profile

The status pill at the end of the top bar says whether the CDR answers. A deployment that declares the sandbox profile (deployment_profile in its configuration, reported on GET /rest/status) raises a band under the top bar on every signed-in page: this deployment must not hold real patient data, with the number of production separations the server reported as open (a shared database credential, a shared cluster, no subject pseudonym namespace, no durable audit trail, schema preparation on the runtime credential). Its Details opens Server’s Overview, which names each one. A production deployment raises no band. The quickstart and the composed stacks run as sandbox; a CDR older than the profile reports none and the viewer claims nothing. See the deployment profile in the server configuration.

Configuration

One TOML file (ferroehr-viewer.toml, read from the path --config or FERROEHR_VIEWER_CONFIG names; with none named, no file is read), with FERROEHR_VIEWER__<SECTION>__<KEY> environment overrides and --set key=value pairs over both. Unknown keys and unknown FERROEHR_VIEWER_ variables are refused at startup. The CDR refuses only an unknown FERROEHR__ override, because the viewer’s variables share its prefix:

KeyDefaultMeaning
server.listen127.0.0.1:3000 (0.0.0.0:3000 in the image)The socket address the viewer listens on. LEPTOS_SITE_ADDR no longer sets it: a value other than server.listen refuses the start.
server.tlsunsetThe listener’s certificate and key, as the CDR’s [server.tls] table; unset, the viewer speaks plain HTTP behind a terminating proxy.
server.request_timeout_ms / body_limit_bytes60000 / 16777216How long one request may take, and the largest body it may post (a template or composition posts whole).
server.drain_delay_ms / shutdown_timeout_ms0 / 10000On SIGTERM the viewer withdraws readiness, waits the drain delay, then gives the requests in flight the shutdown bound.
server.*as the CDR’sThe other keys of the CDR’s [server] table (header_read_timeout_ms, idle_timeout_ms, max_concurrent_requests, retry_after_s, trusted_proxies, forwarded_header, compression, compression_min_bytes) apply as they do there. base_path and caller_rate are refused: the viewer’s pages are served at /, and it mounts no per-caller limit.
telemetry.*as the CDR’sThe CDR’s [telemetry] section: format (auto, json or pretty), filter (default info,hyper=warn,tower=warn,h2=warn, refused at startup when it does not parse; RUST_LOG is a lower-priority fallback, as on the CDR), logged_query_parameters, environment and the [telemetry.otlp] export. A viewer request line has the CDR’s fields, and a panic writes the CDR’s one fixed line. The service.name is ferroehr-viewer; the viewer serves no Prometheus surface, so its http.server.request.duration histogram leaves only over OTLP with otlp.metrics.
cdr.base_urlhttp://localhost:8080The CDR origin (cdr.base_path is appended).
cdr.base_path/ferroehr/rest/openehr/v1The CDR’s ITS-REST base path. Mirror of the CDR’s own rest.base_path: set the same value here when a deployment shortens it, or the viewer calls paths the CDR does not serve. The status document, the OpenAPI documents and the SMART discovery document are derived from it by the same rule the CDR uses.
cdr.request_timeout_secs30Per-request timeout toward the CDR.
cdr.tls.trust_roots_fileunsetA PEM bundle of certificate authorities trusted for the CDR beside the platform’s roots, for a CDR behind a private PKI.
cdr.tls.client_identity (_file)unsetThe client certificate chain and its private key, in one PEM, presented to a CDR that asks for mutual TLS.
cdr.tls.min_version"1.3"The oldest TLS version the CDR client speaks; "1.2" lowers it.
cdr.management_base_urlderived from cdr.base_urlThe CDR’s management surface, base path included; set it when the CDR serves management on its own internal listener (management.port) or under a renamed base path. Drives Server’s management tabs.
auth.basic_enabledtrueOffer the username/password form (validated against the CDR; held server-side).
auth.oidc.enabledfalseOffer OIDC login (authorization code + PKCE).
auth.oidc.issuer / client_id / client_secret (_file) / public_base_url / scopes—The OIDC client registration; public_base_url is the viewer’s externally visible origin for the redirect URI. Enabling OIDC without issuer, client id and public base URL is a startup error.
auth.oidc.tls.trust_roots_file / client_identity (_file) / min_versionunset / unset / "1.3"The issuer client’s TLS, as cdr.tls: extra certificate authorities for an issuer behind a private PKI, a client identity, and the oldest TLS version.
auth.oidc.resolve—A host=ip:port override for the issuer host, for split-horizon DNS: the viewer reaches an issuer whose canonical name only resolves inside the container network, while browsers and tokens keep the canonical URL.
login.noticeemptyInformational text on the sign-in card, line breaks preserved. A demo or evaluation deployment states its public credentials and usage expectations here.
login.linksemptyLinks under the sign-in card, each { label, href } — an API reference, a documentation page.
session.idle_minutes60Session idle expiry: the window slides on each request the browser makes, and the viewer polls nothing in the background (carried inside the sealed cookie).
session.cookie_securetrueOn by default; set false only for plain-HTTP local development.
session.secretemptyThe session-cookie sealing key: base64 of at least 64 bytes (openssl rand -base64 64). Every replica of a scaled deployment must hold the same value. Empty = an ephemeral per-instance key, fine for exactly one replica.
session.secret_file—Path to a file holding the sealing key; wins over session.secret.
ui.localeenThe language a page renders in when the browser’s Accept-Language names none the viewer ships. English is the one language that ships today. A language is added as a catalog file under app/ferroehr-viewer/locales/<tag>/ (the viewer’s words) beside FerroUI’s (the components’ words) with no code change; the catalogs are embedded when the viewer is built, so a new one reaches a deployment in a rebuilt image, never at run time. A value that is no BCP 47 language tag is a startup error.

Commands

ferroehr-viewer (or ferroehr-viewer serve) serves until SIGTERM or SIGINT. ferroehr-viewer config check reads the configuration and builds the CDR and issuer clients, reading every TLS file, without reaching either. ferroehr-viewer healthcheck, the image’s HEALTHCHECK, asks the viewer beside it for GET /health/readiness over the loopback (over TLS when server.tls is set) and exits 0 only on 200. The viewer serves the family’s health routes: /health/liveness, /health/readiness and /health.

Browser security headers

Every response carries a Content-Security-Policy that admits scripts and styles from the viewer’s own origin alone (script-src 'self'; style-src 'self'), with no nonce and neither 'unsafe-inline' nor 'unsafe-eval', beside X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer and Cross-Origin-Opener-Policy: same-origin. Every document is Cache-Control: no-store; FerroUI’s stylesheet, theme and scripts under /ferrohealth-ui/<version>/ are no-cache, so a browser revalidates them. A request that is not a safe method (a form post, the search panel’s answer) is refused 403 unless the browser’s Sec-Fetch-Site says it came from the viewer’s own pages; without that header its Origin or Referer must name auth.oidc.public_base_url.

The viewer is stateless: it has no database and keeps no local files of its own. Everything it shows, including how stored queries are grouped, which is derived from the namespace in each query’s qualified name, lives in the CDR and is read over ITS-REST, so nothing here needs backing up and every replica shows the same repository. Sessions are a sealed cookie (AES-256-GCM, keyed by session.secret), so any replica holding the key can serve any signed-in visitor.

Login and sessions live in the viewer’s server; the browser stores only the encrypted session cookie: CDR credentials and bearer tokens never reach it in readable form.

How the pages work

The viewer draws every page on its server and sends it complete. Every link is a link and every action is a form that posts to the page it sits on, so the whole viewer works with JavaScript off. The browser then loads FerroUI’s small script modules, which add behaviour to a few parts of a page (the code editor, the path trees, the chart legend, the date pickers, the search panel’s suggestions, the user menu’s keyboard handling, the subject reveal and Copy buttons); nothing on a page waits for them. There is no hand-written JavaScript.

An editor (a new EHR, a composition, the EHR status, the directory, a party, a subscription, a template upload) ends in a bar docked at the foot of the page: what has changed, Discard, and the save. A save that the CDR refuses keeps what you typed on the page beside the CDR’s own words.

The screens are listed in the sidebar; below 48 rem it folds into a drawer behind the menu button.

  • Dashboard: record counts, the compositions committed per day, and the stored queries by namespace. See Queries.
  • EHRs: find, create and delete EHRs; each EHR’s compositions, directory, status, contributions and tags. See Templates and EHRs.
  • Parties: the demographic parties, their versions, relationships and tags. See Parties.
  • Templates: upload and read ADL 1.4 and ADL 2 operational templates. See Templates and EHRs.
  • Terminology: the terminologies the CDR serves. See Terminology.
  • Stored queries and Query: build or write AQL, run it, chart and export the rows, save it to the CDR. See Queries.
  • Server: what the CDR is, how it runs and what it serves, and the Audit log. See Server and audit log.
  • Subscriptions: the CDR’s event subscriptions. See Event subscriptions.
  • Configuration: the CDR’s redacted running configuration, by section.
  • About: the viewer, the CDR and their versions, the manufacturer and the CDR’s conformance manifest.

The top bar’s search takes an EHR id, a composition or party uid, a subject id, a template id or a stored-query name. From the second character a panel under the field shows what matches, five of a kind; the arrow keys move through it, Enter opens a hit and Escape closes it. Submitting the search opens the find page, which lists every match, or the other ways to find a record when nothing does.

The footer under every page names the viewer and its version, the manufacturer (Cadasto B.V.), and an About link.

Paging

A listing that can show patient data (the EHRs, an EHR’s compositions and contributions, the audit log) pages with First, Previous, Next and Last under the table and keeps its page in a cookie for the browser session, never in the address or the history, as FerroUI asks of every family console. A filter stays in the address, so a filtered view is still shareable; the page you were on is not. Each step is a form that posts to /paging and comes back to the listing, so paging works with JavaScript off.

Preferences and the keyboard

Your preferences keeps the viewer’s display choices, each in its own cookie for that browser: the theme, the reading choices, and the keyboard shortcuts. See Preferences and the theme.

Audit log

The audit log has its own section in Server and audit log.

Acknowledgment

The viewer’s feature set (the Template Manager, the point-and-click Query Builder, and saved, grouped and cohort queries) is inspired by EHRServer by Pablo Pazos and CaboLabs Health Informatics (Apache-2.0). The interface is reimplemented in Rust over FerroEHR’s own AQL engine, and no code is copied.

Dashboard & queries

The dashboard opens the viewer, and Query and Stored queries cover AQL end to end: build a query from a template or write it, run it, read the rows as a table or a chart, export them, and save the query to the CDR as a versioned stored query.

Dashboard

What the CDR holds, as it answered just now: the EHR, composition, template and stored-query counts (each a link to its screen), the compositions committed per day over the last 30 days, read by commit time with one AQL query, and the stored queries grouped by namespace.

Query

Query has two tabs over one form: Build and AQL. Run in the page header runs whichever tab is open; Save to the CDR beside it stores the query.

Build

Build lowers a template and a few choices into AQL you can read:

  • Template, the operational template the query reads, and Returns: data points, whole compositions, a count, or the EHRs (a cohort). Apply redraws the form for a new template or shape.
  • Data points, each a path from the template’s Web Template and an optional column name; Add a data point adds a row.
  • Conditions, in groups that match all or any of their conditions, each negatable. A condition is a path, an operator for that data type and a value: a quantity takes a number with its unit, a date a date, a coded text one of the codes the template allows. Add a condition and Add a group close each group.
  • Order and limit.

The AQL the form lowers to is shown under it, and is what Run sends.

Every control is a field of the page’s form, and Add and Remove post it, so Build works with JavaScript off.

AQL

The AQL tab is a code editor with line numbers and the AQL grammar’s findings beside the line they name, before anything is sent. A $parameter in the query gets a row under Parameters: its name and the value Run binds. Open in Build appears when the query is one Build can represent, and opens it there.

Results

Results come back under the form: how many rows, whether the query carried its own LIMIT, and the rows as a table.

Chart in the Results heading runs the query again and draws the numeric columns as lines over the first time column (or over the row order when there is none), one series per column with a legend; Table goes back, and the next Run keeps the view you chose. The table under the chart names each point by its time. With FerroUI’s scripts loaded, pressing a legend entry hides that series; the last one shown stays.

Export in the Results heading opens a menu of the formats: CSV and JSON download the rows. The query runs again on the viewer’s server and the file arrives as aql-export.csv or aql-export.json. The menu works with JavaScript off, and with no row Export is unavailable. The viewer keeps no results between requests, so an export is always the CDR’s answer at that moment.

Stored queries

Stored queries lists every stored query the CDR holds, grouped by the namespace of its qualified name (org.example.vitals::latest-weights sits under org.example.vitals): its versions, its parameters, and Run, Open in Query and Delete this version from each row’s menu.

A stored query’s page has three tabs. Definition shows its AQL and facts.

Run runs it on the CDR with a value for each parameter. Which version runs is the openEHR resolution choice: the latest (no version sent), a version prefix (1 or 1.1, the latest matching it) or an exact version; the line under the choice names the request it sends. Numbers and true/false are sent as they are; quote a number to send it as text.

Versions lists every version.

Saving and versions

Save to the CDR takes a namespace, a name and a version. An empty version lets the CDR assign one. A stored (name, version) pair cannot change: saving the same pair again is refused and the popover says so. Opening a stored version in Query fills Save with its namespace, its name and the next minor version.

Deleting a version needs the CDR’s ADMIN role and asks first: Delete version… on the query’s page or in its row menu.

Templates & EHRs

Templates holds the operational templates the CDR validates compositions against, and EHRs opens every EHR with its compositions, directory, status, contributions and tags.

Templates

The list has a tab per family, ADL 1.4 and ADL 2, each with its count, a find field over id, concept and archetype, and a row per template with Build a query and Delete… in its menu.

Uploading a template

Upload a template opens an editor for one source: choose or drop a file (.opt or .xml for ADL 1.4; .adl, .adls, .adlt, .adl2 or .txt for ADL 2; up to 16 MiB) or paste the source, then Upload this template. The CDR validates it; a refusal keeps the source on the page beside the CDR’s diagnostics, for ADL 2 the openEHR-ADL engine’s own words. With JavaScript off, paste the source.

A template

A template’s page has four tabs. Overview reads its identity and how to query it.

Paths is its Web Template as a tree: pick a node to read its path, type and codes, and on a COMPOSITION template Build a query on this path. A demographic template’s nodes offer no query, since the query builder reads compositions. With FerroUI’s scripts loaded the tree folds in place and answers the arrow keys; without them each node is a link.

Source is the template as stored, with line numbers and Copy; Download in the More menu saves it as a file. Example is the composition the CDR generates for it, in the format, type and detail you pick.

ADL 2 templates

The ADL 2 tab lists every release of every ADL 2 template, one row each. A template’s page shows its latest release with a picker over the others, and the JSON tab beside Source shows the OperationalTemplateV2 document. An older release opens on its Overview, says which release you are reading, and links back to the latest.

Deleting a template

Delete… in a row’s menu opens the template with the confirmation inline; on the template’s page it opens a dialog. The CDR refuses while compositions use the template, and deleting needs its ADMIN role; either refusal arrives in the CDR’s words.

EHRs

The list shows every EHR the CDR holds, newest first, and finds one by its subject id and namespace. The subject is shown masked.

New EHR creates one with its status: an optional subject (an id the CDR keeps without reading it, and its namespace) and an optional EHR id; leave the id empty and the CDR mints a UUID. An id that is no UUID is refused before anything is sent; an id the CDR already holds is refused with a link to that EHR.

An EHR

An EHR’s page names it, its subject masked until you press Reveal (with JavaScript off the reveal is a link), and its Queryable and Modifiable badges, on every tab. A revealed subject hides again after the time set in Your preferences (30 seconds unless you change it). New composition and More (Commit several changes, Create the directory, Copy EHR id, Delete EHR…) sit in the header.

The tabs:

  • Compositions: the EHR’s compositions, filtered by composer, template and start date; the filters stay in the address.
  • Directory: the folder tree, Show one folder by path, and the directory’s versions with Restore… on each older one. Edit opens the editor: add, rename and remove folders and add references to the EHR’s compositions; nothing reaches the CDR until you save. Older versions reads further back than the newest ten.
  • Status: the current EHR_STATUS and its versions; Edit status sets Queryable, Modifiable and the other details as one new version.
  • Contributions: the contributions per day over the last 30 days and every contribution, each opening its versions.
  • Tags: every tag in the EHR, on a composition or on the EHR status, by key and value; a row opens the object it tags.

Every save names the version it supersedes (If-Match). A save over a version someone else replaced meanwhile is refused with the CDR’s 412, and the editor offers to reload the newer version.

Deleting an EHR

Delete EHR… asks for the first eight characters of the EHR’s id, then removes the EHR and everything in it; it needs the CDR’s ADMIN role.

Committing several changes at once

Commit several changes builds one contribution: stage a new composition, an amended one or the EHR status, each with its document in an editor of its own, as many as you need, then commit them together with a description. The CDR takes all of them or none: a refused change keeps every staged change on the page beside the CDR’s words.

Compositions

A composition’s page has four tabs. Reading shows it as a person reads it, label and value, by section.

Document shows it in the format you pick (JSON, XML, FLAT or STRUCTURED), as the CDR converts it. Versions lists every version and reads the VERSIONED_COMPOSITION and the chosen version’s envelope; At a time opens the version that was current then. Tags lists the composition’s tags and sets or removes one; a tag stays on the composition across its versions and commits no version.

Edit as a new version opens the document in an editor; the save writes the next version. Delete composition… marks it deleted with a new version; its versions stay readable and it leaves the EHR’s list.

Parties

Parties reads and writes the demographic parties the CDR holds (people, organisations, groups, agents and roles), their versions, the relationships between them, and their tags.

Note

Parties follow the released RM 1.1.0 demographic model. The openEHR REST API for them is still in development, and the relationship routes are this server’s own extension: a different openEHR server will not serve them.

Finding a party

Pick a kind and type a party’s uid, then Open. There is no list of parties: the demographic API publishes no “list all people” endpoint, and AQL reads EHRs. A versioned object uid opens the latest version; a version uid opens the object it belongs to. Open is a plain form and works with JavaScript off.

The one demographic collection the API publishes is the tag index, under Open: every tag on any party, each row opening the party it tags.

Creating a party

New party opens an editor with a starting document for the kind you pick (Start over swaps it for another kind’s). Every attribute in it is one openEHR requires: the party’s name, which openEHR uses for its type (the names a person goes by live in identities), the archetype_details and the root archetype_node_id, at least one identity, and for a role its performer. Fill in the identity, replace the archetype ids with your own demographic archetypes’, and create. The document is sent as you wrote it; a refusal comes back with the CDR’s words and the path it names, the document still on the page.

A party

A party’s page has five tabs. Overview reads its facts and identities.

Document shows it as the CDR serves it. Versions lists every version, each opening its envelope and its contribution; At a time opens the party as it stood then. Relationships lists the relationships the party is the source of. Tags lists its tags and sets or removes one.

Edit opens the whole party as JSON; the save writes the next version with the opened one as its If-Match, so a party someone else changed meanwhile is refused with the CDR’s 412. Delete party… marks it deleted; its versions stay readable.

Relationships

A relationship joins a source party to a target: an employment, an authority, a care relationship. openEHR models it two ways, and both are visible here:

  • inside the source party, which carries the relationships it is the source of; its Relationships tab lists those, each row opening the target;
  • as an object of its own, with its id, versions and history, the shape this server’s relationship routes serve.

The target side cannot be listed: openEHR defines “relationships pointing at this party” as a derived attribute the CDR does not populate, and the tab says so where you would look.

Relate this party opens a new relationship with this party as its source: a type (openEHR stores it as the relationship’s name), the target’s kind and uid, and optional details. A relationship’s own page links both ends, reads its facts, versions and document, and edits its type and details; the ends do not change, since a relationship between other parties is another relationship.

Tags

A tag is a key, an optional value and an optional target path. Setting one re-sends the party’s whole tag collection, as the openEHR tag update does: the viewer reads the current tags and merges yours in, so a tag another client added between your read and your save can be lost; read again before editing a busy party. A tag stays on the party across its versions and commits no version.

Contributions

Every write here commits a contribution, openEHR’s record of one change set: who committed it, when and why. A version’s contribution link opens it, with the versions it touched, each linked to the object it belongs to.

Terminology

The Terminology screen browses the coded vocabularies the CDR can answer questions about: which terminologies it serves, what a code means, which codes a value set holds, whether a given code is one of them, and whether one code subsumes another. Everything on it is a read of the CDR’s public API; nothing is stored on the viewer’s side.

Note

The terminology API is this server’s own extension. openEHR’s REST release publishes no terminology contract, so the operations, URLs and JSON envelopes are this server’s own design over the openEHR TERM specification. A different openEHR server will not serve them.

Switching it on

The surface is opt-in and off by default. While it is off the CDR answers its terminology routes as if they were not mounted, and the screen says so in one card instead of pretending to work:

[terminology]
api_enabled = true

or, as an environment override, FERROEHR__TERMINOLOGY__API_ENABLED=true.

Turning it on exposes only lookups over terminologies the server already holds: the bundled openEHR terminology plus the external code sets beside it. Wiring the FHIR terminology server is a separate setting, and it changes what these lookups can answer; both are covered in Terminology servers.

The terminologies

Terminologies, the first of four tabs, lists exactly what the CDR reports: the internal openehr vocabulary and the external code sets beside it (languages, countries, character sets, media types). A row opens the terminology’s descriptor: publisher, identifying URI, version, and the fields an extract may ask for, with links to look a code up in it and to expand one of its groups. Fields the server does not publish are left out.

Look up

Pick a terminology, type a code and Look up. A defined term comes back with its text, the language it is written in and whether it is the preferred term. An effective date is optional: supplied, it asks for the definition as it stood that day; the openEHR bundle is one pinned release, so a date changes nothing there, while an external terminology server can answer differently.

A code the terminology does not define is an answer, not an error: the page says so, naming the code and the terminology.

Note

openEHR terminology codes are scoped to their group: 532 is complete in one group and completed in another. A lookup reads the openehr terminology as flat and returns the first group’s rubric, so expand the group under Value sets whenever the group matters.

Value sets

Pick a terminology and a value set (for openehr, a vocabulary group such as audit_change_type, offered as you type) and Expand it into its members. Under them, Validate answers whether a code is a member, as a sentence either way; a value set the CDR does not know has no members.

Hierarchy

Test asks whether one code is broader than another. The test is strict, so a code is never broader than itself, and the openEHR vocabulary defines no hierarchy, so for any two openEHR codes the answer is no; the page says why. Hierarchical answers come from an external terminology server when one is bound.

Every tab is a GET form: the question stays in the address, so an answer is shareable, and every tab works with JavaScript off.

In the query builder

A condition on a coded text in Build offers the codes the template allows as a select, so a coded criterion is never typed from memory; the query carries the bare code.

Server & audit log

Server says what the CDR this viewer reads is, how it runs and what it serves, as six tabs, and the Audit log browses its security audit trail. Everything on them comes from the CDR’s own endpoints over HTTP; the viewer has no privileged channel.

Overview

The CDR’s state, its deployment profile and version, and how it was built: the version, the git commit, the build date and rustc, the PostgreSQL target, the audit posture (whether auditing is on, its fail_mode, the local store and the retention), the active spec_profile with the openEHR specification versions it selects, the manufacturer, and the end month of the release’s support. The conformance manifest the CDR answers at OPTIONS on its base path (its product, vendor, conformance profile and the API groups it mounts) is on About.

Health, Metrics and Logging

These three tabs read the CDR’s management surface, and appear only when the CDR serves it. To get them, enable the surface and give each endpoint an access level (Operations → The management surface); every endpoint ships off:

[management]
enabled = true

[management.endpoints]
info = "admin_only"     # the probe the tabs appear on; enable it with the rest
metrics = "admin_only"
env = "admin_only"
loggers = "admin_only"

If the CDR serves management on its own internal listener (management.port) or under a renamed base path, point the viewer at it with the full prefix (FERROEHR_VIEWER__CDR__MANAGEMENT_BASE_URL); unset, the viewer derives {cdr.base_url}/management. Being allowed to read an endpoint is the CDR’s decision per request, and a refusal is reported on the tab that asked.

Health lists what the CDR depends on (the database, the migrations, the audit chain and sender, and any optional components) with each one’s state and the CDR’s detail. It reads the public readiness probe (GET /health/readiness). The pill in the top bar answers a different question: whether the API answers at all.

Metrics has three tiles (compositions committed, AQL queries, database connections in use) over the whole metric registry: pick a metric and Show its current samples with their labels. The choice stays in the address (/server/metrics?metric=db_pool_connections). For dashboards and alerts, scrape GET /management/prometheus.

Logging shows the filter in effect and the boot filter, and changes the live filter without a restart: type tracing directives (ferroehr=debug,sqlx=warn), Apply…, and confirm in the dialog; Reset to the boot filter puts the startup value back. The tab then reads the CDR’s answer, so what you see is what the server confirmed.

Important

A log-filter change applies to that CDR instance only and lasts until it restarts. Behind a load balancer each instance is set on its own; for a lasting change, set log.filter in the server configuration.

API and SMART

API lists the OpenAPI documents the CDR serves for itself, one family at a time: pick one (openEHR EHR, Query, Definition, Demographic, Admin, and the FerroEHR extensions, or the complete surface) and read its endpoints by tag. The family stays in the address (?family=query).

SMART reads the CDR’s SMART discovery document: the scopes it supports as parsed grants, the grant types and PKCE methods, and the document itself. Your access shows the scopes your session holds.

Configuration

Configuration in the sidebar shows the CDR’s redacted running configuration by section, each key with what it means; secrets show as their reference, never their value.

Audit log

The Audit log browses the CDR’s security audit trail (who accessed what, with what outcome) through the IHE ITI-81 retrieval (GET /fhir/r4/AuditEvent; see the Audit trail chapter). Filter by principal, outcome, action, date window and patient; the filters stay in the address, so a filtered view is shareable. The page lives in the session, as on every listing that can show patient data.

A row opens its record: the event, who, the patient, what was done and the outcome, and the stored FHIR R4 AuditEvent with line numbers and Copy.

A filter that matches nothing says so, with the filters named and a way to clear them.

The audit trail is an operator surface: the screen needs the CDR’s ADMIN role, and a session without it is refused in the CDR’s words. When the CDR’s local audit store is off, the screen says so.

Event subscriptions

The Subscriptions screen administers the CDR’s event subscriptions: the server-side filters that decide which committed versions are published to your message broker, and to which queue. Everything on it comes from the CDR’s own subscription API over HTTP; the viewer has no privileged channel and keeps no subscription state of its own.

When it appears

Change eventing is an extension: no openEHR specification governs event publication or a subscription resource, so the whole surface (its paths, its payloads, its status codes) is this CDR’s own design, and it is off unless a deployment turns it on. See Change events (AMQP) for what the stream carries and how a queue is bound.

The screen is probe-and-hide: on every page load the viewer asks the CDR for GET /admin/event_subscription, and the sidebar entry appears only if that route exists. A 404 (the CDR’s answer while the subscription API is off, which is the default) hides the entry entirely; any other answer counts as present, so a refusal reaches you as a message on the screen that asked rather than as a missing screen.

To get the screen, turn the subscription API on ([events]):

[events]
admin_api = true     # mount /admin/event_subscription

Reaching /subscriptions on a deployment without it is not an error either: the screen names that switch instead of drawing a table that cannot be read.

Note

admin_api and enabled are 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 = true connects the server to one.

Note

The API is mounted under /admin, so the CDR’s role-based access control classes every call here as admin work. The screen renders whenever the surface exists; being allowed to use it is the CDR’s per-request decision, and a session without the ADMIN role is refused with a message naming what is missing.

What a subscription is

A subscription is a name plus three predicates, each of which matches one facet of a committed version:

FieldMatchesExample
Kindthe versioned object’s typeCOMPOSITION, EHR_STATUS, FOLDER
Change typethe audit change-type code249 (creation), 251 (modification), 523 (deletion)
Template idthe template a composition was committed againstvital_signs.v2

A field left empty matches anything. Each row says in words what it selects (“Every Creation of a Composition”), so a feed of every committed version reads as one.

The name is unique on the CDR and is also the suffix of the queue the server declares for the subscription (ferroehr.events.<name> with the default exchange), so it may hold only letters, digits, and _, . or -, and it cannot be changed afterwards; a rename would be a different queue.

State is the enabled flag: an enabled subscription is one the server binds and delivers to; a disabled one is kept, exactly as you defined it, and not delivered. Disabling is therefore the reversible way to stop a feed.

Administering them

The list shows every stored subscription, newest first: its name, what it selects in words, its state and when it was created, with Edit, Disable or Enable, and Delete… in each row’s menu.

  • New subscription opens an editor: a name, and as many predicates as you want to narrow by; leave them all empty for a feed of everything, and switch delivery off to keep it without sending. A name the CDR cannot accept, or one it already holds, is refused on the form, what you typed kept.
  • Edit opens the subscription’s current values. Saving replaces every predicate, so a field you clear becomes any on the CDR: the whole update, not a patch. The name is shown and never editable.
  • Delete… opens the subscription with the confirmation, and nothing is sent until you confirm.

Every write reports its outcome as a notice, a failure as visibly as a success.

Warning

Deleting a subscription stops the server binding its queue; it does not delete the queue from your broker. A durable queue left behind keeps what it holds and stops receiving new messages, so remove orphaned queues on the broker in the same change.

Preferences & the theme

Your preferences (in the user menu) keeps the viewer’s display choices for you, in this browser: each one is its own cookie, read when the server draws a page, so a choice applies from the first paint and nothing is kept in browser storage. Nothing here names a patient, and nothing changes what you may do. Reset to defaults… clears them all.

Appearance

  • Theme: light, dark, or the one your system uses. The user menu sets it too.
  • Density: comfortable (the default) gives rows and forms room; compact fits more rows in every list.
  • Reduce motion: follow the system, or always reduce.

Reading

  • Language: follow the browser, or one of the languages the viewer ships (English today; see ui.locale under Configuration).
  • First day of the week in the date pickers.
  • Show a revealed identifier for 15 seconds to 2 minutes: how long a subject id stays shown after Reveal.

Keyboard

Shortcut hints, the key caps after a label: on wide screens with a keyboard (the default), on all wide screens, or never. Phones never show them. The keyboard shortcuts themselves are listed under Keyboard shortcuts in the user menu.

The dark theme

Every screen is drawn in both themes:

Operations

Running a clinical data repository in production means more than starting the binary: the database must be backed up and least-privileged, traffic must be encrypted, upgrades must be safe while the service stays up, and you need to see what the system is doing. This chapter is a production checklist (database roles, TLS, backup and point-in-time recovery, upgrades and migrations, observability, the health probes, and the management surface) drawn from how the container image and Helm chart are built to run. The operator-facing HTTP surfaces have their own page: Admin & messaging APIs.

Database roles and least privilege

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:

RolePurposeUsed by
ownerowns the databaseprovisioning only
ferroehr_migratorruns the schema migrations; owns the helper functionsthe migration step

and five cover serving, split by pseudonymisation domain — the clinical record, the identity of its subject, and the map between the two:

RoleReads and writesBarred from
ferroehr_clinicalclinical, archival tier includedparty, linkage
ferroehr_partyparty, archival tier includedclinical, linkage
ferroehr_clinical_readerread-only over clinicalparty, linkage
ferroehr_party_readerread-only over partyclinical, linkage
ferroehr_linkagelinkage (the party-to-EHR map)clinical, party

The migrations create these roles idempotently, apply the per-schema grants, and revoke every other domain explicitly in both directions, and revoke the ability to create objects in the public schema. All five are NOINHERIT and none is a member of another, so a boundary cannot be crossed by picking up a membership. GDPR Art. 4(5) defines pseudonymisation as processing where attribution to a person needs additional information “kept separately and subject to technical and organisational measures”, and Art. 32(1)(a) names it a security measure for health data (https://eur-lex.europa.eu/eli/reg/2016/679/oj); EDPB Guidelines 01/2025 require that separation to hold against internal actors, operators with database access included.

Each serving role holds SELECT/INSERT/UPDATE/DELETE on its own domain’s tables, EXECUTE on the ext helper functions, and MAINTAIN on its store’s partitioned tables (and ehr, for ferroehr_clinical), and nothing else: it is not a superuser, does not bypass row-level security, and cannot create, alter or drop a table, index, schema or role. On the audit trail it is narrower still: it may record an event and stamp it forwarded, and it holds no privilege that can rewrite or remove one (see Audit).

MAINTAIN is there so the server can keep those tables’ planner statistics (see Planner statistics of the partitioned tables). It also allows VACUUM, CLUSTER, REINDEX and LOCK TABLE on exactly those tables. The role can already write every row of them, so what it adds is the locks and index rebuilds those commands take. The reader roles do not hold it.

Turning the schema split into a role split

The schema separation is unconditional: the server always reads and writes parties in party, whatever it authenticates as, and each of the four pools carries only its own schema on its search_path. The role separation is a deployment choice, and it is one configuration table per domain:

[storage.party]
url = "postgres://ferroehr_party:***@pg:5432/ferroehr"

[storage.linkage]
url = "postgres://ferroehr_linkage:***@pg:5432/ferroehr"

(or url_file, for a mounted secret). With a domain’s url set the server opens that pool on that credential, and a flaw that reaches one of them reaches one domain. Left unset, the domain uses [db].url and the separation is schema-only. linkage holds which party is the subject of which EHR — the one map that re-joins the other two — so ferroehr_linkage is barred from both of them and both of them from it. Set every domain and no credential the server uses can perform that join in SQL; the crossing happens in the application, over two connections, and is recorded as an access event (see Security → Resolving across the boundary).

A DSN that names another host moves the domain to a database or cluster of its own, which is the separation a schema cannot make: a base backup, WAL archiving and physical replication carry every schema of a database together (PostgreSQL 18, Backup and Restore). A database that holds the linkage domain without the party domain also receives the party migration set, its schema and grants and never a row, because the linkage set revokes a function the party set creates.

Two boot checks make the posture honest. Two domains configured on different DSNs that authenticate as the same database role are refused — a separation that exists only in the configuration reads as one that holds, which is worse than none. And a domain role that does not exist is a warning under deployment_profile = "sandbox" and a refusal under production: absent roles mean absent grants, and the boundary check would otherwise pass by having nothing to measure.

Which credential prepares the schema

None of the domain credentials. Preparing a database spans every schema in it at once: the DDL of each resident migration set under db.migrate = "apply", and their _sqlx_migrations bookkeeping tables under "verify" — a read, but a read across the whole database. Each runtime role holds exactly one domain, so none can do it, and verify is not the exception: a role that is a member of ferroehr_clinical and nothing else is refused on the very first set: it cannot read the ext, party, linkage or audit bookkeeping.

So the credential that prepares the schema is named separately, and the server uses it for that one boot step:

[db]
url = "postgres://ferroehr_clinical:***@pg:5432/ferroehr"
migrate_url = "postgres://ferroehr_migrator:***@pg:5432/ferroehr"

[storage.party]
url = "postgres://ferroehr_party:***@pg:5432/ferroehr"

[storage.linkage]
url = "postgres://ferroehr_linkage:***@pg:5432/ferroehr"

migrate_url prepares every domain that reaches the same DATABASE it does, which is all of them in the posture above: three credentials, one database. A domain whose DSN reaches a DIFFERENT database is prepared there on that database’s own migrator, [storage.migrate.<domain>] url, because migrate_url names one database and a relocated domain is not in it; unset, it falls back to the domain’s own DSN, which a DML-only role cannot use. Which case a domain is in is read from the server itself (pg_control_system() and current_database()), never from the DSN text — two DSNs that differ only in their credential reach the same database.

(or migrate_url_file, for a mounted secret). The connection is opened for preparation and closed again: no pool is held on it, and no request is ever served through it. Left unset it falls back to url, which is exactly what a single-credential deployment has always run — its behaviour is unchanged by this key existing.

A credential that cannot read a set is told which schema and which role, so the fix is visible from the message:

database role `app_ehr` cannot read the migration state of schema
`ext`: … point `[db] migrate_url` (or `migrate_url_file`) at the credential
that prepares the schema — unset, it falls back to `[db] url` — and, for a
domain in a database of its own, `[storage.migrate.<domain>] url` at that
database's migration credential

Either way the server refuses to boot when the grants themselves are wrong: a self-check enumerates every table, view, sequence and function in each domain and fails, naming the role and the object, if either runtime role can read across the boundary. ferroehr db verify runs the same check. When the five roles do not exist at all (the development, compose and test-harness case, where the migrator holds no CREATEROLE) the check warns rather than inventing a failure — under deployment_profile = "production" it refuses, because absent roles mean absent grants and there is then nothing to measure.

On Kubernetes the same choice is chart values, mounted as files the same way the clinical DSN is, so no credential enters the pod’s environment:

database:
  existingSecret: ferroehr-db                 # postgres://ferroehr_clinical:…
  party:
    existingSecret: ferroehr-db-party         # postgres://ferroehr_party:…
  linkage:
    existingSecret: ferroehr-db-linkage       # postgres://ferroehr_linkage:…
  audit:
    existingSecret: ferroehr-db-audit         # the audit repository's own DSN
  migrateExistingSecret: ferroehr-db-migrator # postgres://ferroehr_migrator:…

Leave a domain’s block unset and that pool uses the shared DSN, which is the schema-only posture.

Provisioning the five roles is yours in both cases. The migrations create them only when the migrator holds CREATEROLE and skip them with a NOTICE otherwise, so on a managed database — where the migrator usually does not — create them before the first deploy:

CREATE ROLE ferroehr_clinical NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_party NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_clinical_reader NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_party_reader NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_linkage NOLOGIN NOINHERIT;

then give each login role membership of exactly one of them. ferroehr db verify tells you whether the boundary holds afterwards.

The local Audit Record Repository is written on its own pool ([storage.audit]), and the audit schema is granted to ferroehr_clinical (record an event, stamp it forwarded, run the retention reaper, verify the chain) and to ferroehr_clinical_reader (read it and verify the chain). A clinical login role that is a member of ferroehr_clinical alone writes its own access log; no extra membership is needed. The audit trail is not a pseudonymisation domain, so this grant adds no reach into party or linkage.

The compose stacks create all five and grant the clinical and party domains to the single dev login role, which owns the database. That demonstrates the schema separation and exercises the boot self-check; it is deliberately not the credential separation, because one container with one DSN cannot show that half honestly.

Which of these postures is exercised, and which is not

A recommendation nothing runs is a guess. This is what the suites cover.

Exercised against a real PostgreSQL 18:

  • Two runtime credentials serving both domains through the assembled router, each of the server’s own pools refused every relation of the other domain (app/ferroehr-rest/tests/it/credential_separation.rs), and the single-DSN fallback still serving both.
  • Three runtime credentials across the whole identity-to-party-to-EHR crossing, with the linkage credential refused the identifier map (app/ferroehr/tests/it/pseudonymisation_boundary.rs).
  • All five domain roles refused every relation in the domains they do not own, enumerated from information_schema rather than from a written list (same file).
  • The boot sequence on separated credentials with [db] migrate_url, under both verify and apply, and the single-DSN fallback under both (app/ferroehr/tests/it/schema_preparation_credential.rs).
  • The linkage domain relocated to a second database, prepared and verified there on [storage.migrate.linkage], with the companion party set, and the refusal when the domain’s two DSNs reach different databases (app/ferroehr/tests/it/relocated_domain_preparation.rs).
  • The chart’s production overlay on four PostgreSQL clusters, one per domain, prepared by the migration Job and booting under production (scripts/deploy-probe-k8s.sh --production-only, probes P-K8S-PROD-BOOT and P-K8S-PROD-SHARED).
  • The compose stack’s grant boundary read back from the running database by scripts/deploy-probe.sh, which also records that the stack is single-credential by design.

Not exercised by anything, and stated rather than left to inference:

  • A separated migrate_url alongside the linkage credential in one boot. The preparation tests configure the clinical and party DSNs.
  • A real deployment on separated DSNs: a container booting with the DSN files mounted, and the chart wiring them through database.party.existingSecret, database.linkage.existingSecret and database.migrateExistingSecret. What is covered is the in-process half.
  • Role provisioning on a managed database where the migrator holds no CREATEROLE and 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 of ferroehr_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 on db.migrate_url — see which credential prepares the schema, and set that key whenever db.url is a role that holds one domain.

Migrations are append-only, so upgrading an existing database in place is the supported path and always has been the one your data takes. A released migration is never edited: sqlx records a checksum of each applied file and refuses a database whose recorded checksum no longer matches, so a corrected schema arrives as a new migration rather than as a change to an old one. A CI guard fails any pull request that modifies, renames or deletes a migration the base branch already carries.

With verify, something else has to run the migrations first. Use the binary’s own subcommand under the migrator DSN: a CI/CD stage, a one-shot job, or the Helm chart’s migrations.job.enabled pre-install/pre-upgrade hook Job, which Helm waits on so a failed migration fails the release (it takes its own migrations.job.existingSecret holding the migrator DSN, deliberately a different credential from the runtime one, and rendering fails without it):

FERROEHR__DB__URL='postgres://ferroehr_migrator:***@pg:5432/ferroehr' \
  ferroehr db migrate     # applies; exits when done
FERROEHR__DB__URL='postgres://ferroehr_clinical:***@pg:5432/ferroehr' \
FERROEHR__DB__MIGRATE_URL='postgres://ferroehr_migrator:***@pg:5432/ferroehr' \
  ferroehr db verify      # read-only check; exit 0 iff the schema is current

db verify splits the same way the boot sequence does, and for the same reason: the schema state is read on migrate_url, while the pseudonymisation boundary is measured on the runtime DSNs — asking the migrator credential whether it can reach every domain would answer yes by design and tell you nothing about the roles that serve requests.

Gate the rollout on the migration step so two server versions never race the schema. Note the difference in failure shape: a verify server refuses to boot against an unmigrated database (loud, immediate), while an apply server that loses its schema later stays up and reports readiness DOWN, the warning below.

Warning

Migration is a boot step, and nothing re-runs it. A running instance whose database is replaced, wiped, or reachable-but-empty does not migrate. It reports

{"state":"down","phase":"serving","indicators":[{"name":"db","state":"up"},
 {"name":"migrations","state":"down"}]}

on /health/readiness (503), with the detail core schema tables missing (migrations not applied) on /management/health, leaves the load balancer’s rotation, and keeps passing liveness (correctly, since the process is healthy) so nothing restarts it. Under Kubernetes that is a Deployment sitting at 0/N ready 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 UP within 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: then kubectl rollout restart deploy/ferroehr (or a migration job) is the remedy.

For the out-of-band flow this means: the migration step must complete before the first instance starts, or that instance sits unready until the schema appears, harmless but confusing, and it delays the rollout rather than failing it. Gate the rollout on the migration job.

Planner statistics of the partitioned tables

node, version and vo_attestation are partitioned by storage tier in both stores, and PostgreSQL estimates a join over them from the statistics of the partitioned table itself. Autovacuum analyzes the partitions and never the partitioned table (PostgreSQL 18, §24.1.3 Updating Planner Statistics), so FerroEHR runs ANALYZE ONLY on the partitioned tables itself, and on ehr while autovacuum has never analyzed it:

  • after it applies the migrations, as the migration credential;
  • after load_ehrs loads an archive;
  • at boot and then every hour, as each store’s serving role.

Without these statistics an AQL CONTAINS can read every COMPOSITION’s nodes. ANALYZE needs the MAINTAIN privilege on the table, and the migrations grant it to ferroehr_clinical on clinical.node, clinical.version, clinical.vo_attestation and clinical.ehr, and to ferroehr_party on the three partitioned tables of party. When a serving role still lacks it, for example because the roles were absent when the migrations ran, the hourly refresh logs a warning naming the tables it could not analyze. Grant the role MAINTAIN on those tables, or run the same ANALYZE ONLY from a scheduled job as the migration role.

Recovering a partially wiped database

A wipe that removes some of the server’s schemas is not a fresh start, and the server cannot tell it from one.

The server owns five schemas: ext, clinical, party, linkage and audit. A fresh start drops all five. Each domain’s cold archival tier is a partition of the relation it archives, inside that domain’s own schema, so a DROP SCHEMA clinical CASCADE takes the archived rows with the live ones. There is no separate mirror schema left to survive a wipe of the tier it mirrors.

Dropping some of the schemas is not a supported reset, because they are not independent. btree_gist is installed in ext, so DROP SCHEMA ext CASCADE also drops the temporal uniqueness constraint on linkage.subject_ehr that the extension’s operator classes back, and the linkage set, still recorded as applied, never recreates it. linkage also maps parties to EHRs that a dropped clinical no longer holds. The next boot rebuilds the dropped schemas, and its migration check reads each set’s bookkeeping only, so it passes over a database that has lost those guarantees.

A database written by a release before the storage rewrite is refused outright:

this database predates the storage rewrite: schema `ehr` carries its own
migration bookkeeping, which only a release before the rewrite wrote.

The new migration sets replace the old ones rather than upgrading them, so there is no in-place path. Dump anything worth keeping, recreate the database, and start the server against it.

Tip

When you wipe a FerroEHR database deliberately, drop the database, not a schema. DROP DATABASE cannot leave half a repository behind, and it is the only wipe with no partial-state failure mode. ferroehr db erase drops all five schemas of a database in one transaction, for taking an instance out of service (Decommissioning).

TLS and database security

These are database-side settings that belong to whoever provisions PostgreSQL; the deployment references them but cannot enforce them:

  • TLS in transit. Require hostssl on the server and put ?sslmode=verify-full in the DSN so the client verifies the server certificate.
  • pgaudit. Run pgaudit as the database-layer complement to the openEHR audit and the ATNA trail (for example pgaudit.log = 'ddl, role, connection' globally plus object-level audit on the PHI tables) and ship the audit log to an immutable store with long (roughly six-year) retention.
  • Encryption at rest. Encrypt at the volume or disk layer. Do not encrypt the stored clinical JSON with pgcrypto: it would break AQL’s ability to query inside the data.

Backup and point-in-time recovery

Enable WAL archiving and point-in-time recovery from day one (pgBackRest or a managed PITR), because a CDR’s data is not reconstructible. Clinical and audit tables are never UNLOGGED. Test your restore, not just your backup.

Dump each domain separately

A logical backup is where the three pseudonymisation domains are easiest to re-join by accident. One pg_dump of the whole database produces a single file holding the pseudonymised clinical record, the identities of its subjects and the map between them, and whoever can read that file can re-identify every record in it — which is the separation the schema split and the database roles exist to maintain. Three domains, three dumps, into targets with different access control.

How many dumps, by DSN layout. The number of dumps follows the domains, not the databases: co-located, the four domains share one database and are dumped --schema by --schema as below; relocated, each database is dumped for the domains that live in it. What never changes is that the clinical record, the identities and the map land in three different artefacts with three different audiences. ferroehr config check prints the resolved layout, which is the list a backup runbook is written from. The audit repository travels with the clinical dump while it shares that database (--schema=audit below) and needs a dump of its own once [storage.audit] names another one. On Kubernetes that fourth dump is not yours to schedule: the chart renders a backup.audit CronJob as soon as database.audit carries a Secret of its own, and drops --schema=audit from the clinical job in the same render.

# The clinical domain
pg_dump --dbname="$CLINICAL_DSN" --format=custom --no-owner \
  --schema=clinical --schema=ext --schema=audit \
  --file=/backups/clinical/clinical-$(date -u +%Y%m%dT%H%M%SZ).dump

# The identities, into a different directory, owned by a different group
pg_dump --dbname="$PARTY_DSN" --format=custom --no-owner \
  --schema=party \
  --file=/backups/party/party-$(date -u +%Y%m%dT%H%M%SZ).dump

# The party-to-EHR map, into a third directory with the narrowest audience
pg_dump --dbname="$LINKAGE_DSN" --format=custom --no-owner \
  --schema=linkage --extension=btree_gist \
  --file=/backups/linkage/linkage-$(date -u +%Y%m%dT%H%M%SZ).dump

# The audit trail, ONLY when [storage.audit] names a database of its own —
# co-located it is in the clinical dump above, and dumping it twice is a second
# copy of who read what
pg_dump --dbname="$AUDIT_DSN" --format=custom --no-owner \
  --schema=audit \
  --file=/backups/audit/audit-$(date -u +%Y%m%dT%H%M%SZ).dump

Important

--extension=btree_gist on the linkage dump is load-bearing. A --schema dump carries no extension — “pg_dump makes no attempt to dump any other database objects that the selected schema(s) might depend upon” — and linkage.subject_ehr’s temporal UNIQUE … WITHOUT OVERLAPS is 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_restore ignores the error by default, and the table comes back with its rows and without the key that admits one open mapping per party.

The third dump is a separate artefact for the same reason the first two are, and it is the one that matters most. linkage.subject_ehr says which party is the subject of which EHR: it is the additional information that turns a pseudonymised record back into a person (GDPR Art. 4(5)), so a file carrying it beside either side of that map rebuilds the join the split exists to withhold. Folding it into the party dump would put the identities and the map in one holder’s hands — the artefact this whole section is written to prevent.

All three examples ship. In Compose they are the opt-in backup profile:

docker compose --profile backup run --rm ferroehr-backup-clinical
docker compose --profile backup run --rm ferroehr-backup-party
docker compose --profile backup run --rm ferroehr-backup-linkage

Set FERROEHR_BACKUP_CLINICAL_DIR, FERROEHR_BACKUP_PARTY_DIR and FERROEHR_BACKUP_LINKAGE_DIR to the three targets; they default to ./backups/clinical, ./backups/party and ./backups/linkage. Run it as yourself — FERROEHR_BACKUP_USER="$(id -u):$(id -g)" — and the dump lands owned by you. Left unset, the job runs as root inside the container and keeps one capability, DAC_OVERRIDE, because that is what writing a directory it does not own actually requires: the container drops every other capability, and without this one uid 0 is just another user against your directory’s permission bits. Everything else stays off — no privilege escalation, read-only root filesystem. Under Kubernetes the chart renders one CronJob per domain — see the chart’s backup values.

Warning

A backup credential is not the application role. Each domain’s runtime role is revoked from the other domains, which is the pseudonymisation boundary doing its job — so a dump taken through one of them is silently partial rather than refused. Give each backup job its own role, read-only on its own domain and nothing else.

Two further properties are yours to arrange, because no configuration file can enforce them: the three targets carry different access control, and the credential each job uses reaches one domain. Give the demographic job the demographic DSN once you run two (Deploying); with a single credential you have separated the artefacts but not the authority to produce them.

Note

Point-in-time recovery stays instance-wide. All three domains live in one cluster, so a WAL archive covers them together and a recovery target restores them together. The separation this section is about is the logical dump.

Restoring

A restore is not finished when pg_restore exits. The grants are the boundary — a database whose roles came back wrong is a database where the clinical role can read the identities — so re-apply the role grants before the server starts, then let the server check them:

createdb ferroehr_restored
pg_restore --dbname=ferroehr_restored --no-owner clinical-….dump
pg_restore --dbname=ferroehr_restored --no-owner demographic-….dump
pg_restore --dbname=ferroehr_restored --no-owner linkage-….dump
# then, before anything serves traffic:
ferroehr db verify

Restore the clinical dump first. It is the one carrying the ext schema, whose helper functions the other domains’ relations depend on.

ferroehr db verify issues no DDL. It checks that the database carries exactly this build’s migrations, all five sets, so a restore that skipped a domain is refused by name, and that no runtime role can read across the domain boundary, exiting non-zero when either is untrue. It is the same check the server runs at boot, which is why a server pointed at a badly restored database refuses to start rather than serving from it.

What it does not read is a constraint. pg_restore ignores a failed statement unless you pass --exit-on-error, so a table can come back with its rows and without a key while every check above stays green. Read the restore’s own output, and confirm the map kept its temporal key:

psql -d ferroehr_restored -c \
  "SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
     WHERE conrelid = 'linkage.subject_ehr'::regclass AND contype = 'u'"
# expect: uq_subject_ehr_party | UNIQUE (party_id, sys_period WITHOUT OVERLAPS)

Restoring only one domain is a supported outcome, not a mistake: a demographic dump restored on its own gives a database with identities and no clinical record, which is what a rehearsal of the identity domain’s recovery looks like. Such a database is not one the server will serve from — ferroehr db verify refuses it for the domains that are missing, which is the correct answer for a rehearsal target.

Rotating the national-identifier key

Only relevant when [demographic.identifier_protection] is on. With it on, a national identifier of a configured scheme never sits in the versioned body: the value lives in party.national_identifier, sealed with AES-256-GCM under a key derived per domain from your root key, with an HMAC-SHA-256 digest beside it so an identifier can be looked up without decrypting anything.

The root key is load-bearing. Lose it and the sealed identifiers cannot be read back by anything, including you. Hold it the way you hold the database credentials: in your secret manager, injected as key_file, never in the TOML you commit.

Rotation re-encrypts; it is not an edit. Both keys have to be present while it runs, because every row is opened under the old key and sealed under the new one, and the lookup digest changes with the key too — so the rotation rewrites the digest column and every stored reference keeps pointing at the same row. The rotation has to cover retired rows as well as live ones: every party version keeps rows of its own, and an older version opens its value from a retired row.

The procedure:

  1. Generate the new key: openssl rand -hex 32, or any source of 32 random bytes rendered as hex.
  2. 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.
  3. 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.
  4. Run the rotation under the migrator role, which is the only identity with rights on the whole table.
  5. Swap key_file to the new key and restart. Verify by resolving one known identifier and reading one party back.
  6. Destroy the old key once step 5 is verified, not before.

The same key binds the admin dump archives. A dump carries the sealed rows as ciphertext, so restoring it needs the root key the rows were sealed under. An archive dumped before a rotation holds rows sealed under the old key, so step 6 also leaves the identifiers in those archives unreadable. Keep the old key for as long as you may restore one of them.

Warning

There is no automated rotation command yet. Until there is, the rotation is a scripted job you run against the demographic database with both keys available, and this page is the procedure it has to follow. Treat the absence of a command as a reason to rehearse the rotation on a copy first.

The subkeys are derived from the root key rather than stored, so rotating the root rotates all of them together. The pseudonymisation domain is part of that derivation: a subkey derived for the clinical domain opens nothing in the party one, so the per-schema backups above are separate artefacts under separate keys even where one root key is configured.

The container image and pod hardening

The published image is distroless and non-root (shell-less, with no package manager) and is multi-architecture (amd64 and arm64) on GHCR. It is not a static binary: the server links glibc and libgcc_s dynamically, which is why the base image is the cc distroless variant rather than the smaller one, and why the runtime needs no OpenSSL, no JVM and nothing else from a package manager.

Under Kubernetes the pod runs the Kubernetes restricted profile: non-root uid/gid 65532, read-only root filesystem with one writable /tmp, no privilege escalation, an empty capability bounding set, RuntimeDefault seccomp, and no service-account token (the workload never calls the Kubernetes API). The chart’s own gates assert that per container on every render, and the posture read back from a running container, plus the two things the chart cannot do for you (applying the namespace enforcement label, and narrowing the NetworkPolicy’s ingress sources: the shipped policy narrows the ports and, until you set networkPolicy.ingressFrom, admits every source on them; set networkPolicy.ingressAllowAll: false, and viewer.networkPolicy.ingressAllowAll for the viewer, to have that state refused at render instead), are covered in Installation → The workload: security context & admission and Namespaces, network & policy → Ingress.

The Compose path carries a comparable floor, and one guard keeps it: scripts/checks/compose-hardening.sh runs over every committed compose artifact and fails on a service definition without cap_drop: [ALL] and no-new-privileges:true, on any privileged: true, on a seccomp=unconfined or apparmor=unconfined override, on a mounted Docker daemon socket, and on a published port that names no host address. The compose files go further than the guard checks (capabilities are added back one at a time only where an entrypoint provably needs them, file-descriptor limits are bounded, ports default to the loopback interface, and most services run a read-only root filesystem) but those extras are conventions, not enforced properties, so check them when you adapt a file.

Two services in the quickstart file deliberately do not run read-only, and each says why in place. The S3 gateway writes its volume store; the server cannot, because Compose refuses an inline config in a read-only service and that inline config is what makes the file standalone. A deployment that mounts its configuration from a file instead can add read_only: true and a tmpfs at /tmp.

What the host owes, and we cannot enforce

Four controls belong to whoever runs the daemon. They are stated here because a container hardening story that ignores them is misleading: the strongest pod security context in the world sits on top of these.

Keep the host kernel and Docker Engine current. A container is a kernel namespace, not a virtual machine: a kernel privilege-escalation bug is a container escape, and every runtime hardening above assumes the kernel enforcing it is patched. Track your distribution’s kernel updates and the Docker Engine release notes with the same urgency you would give a public-facing service.

Prefer rootless mode. Running the daemon as a non-root user means a container escape lands as an unprivileged user rather than as root on the host (https://docs.docker.com/engine/security/rootless/). The images here need no privileged operation, no host networking, and no daemon socket, so nothing in this deployment prevents rootless: the constraints are usually the host’s (cgroup v2, newuidmap/newgidmap, and no privileged ports below 1024, which is why the server binds 8080 rather than 80).

Set the daemon log level to info (the default) and keep it. Docker’s debug level records request payloads and can put secret material into daemon logs, which are typically world-readable to anyone with host log access and are shipped wholesale to log aggregators.

Control who can pull and push your images. GHCR access is the deployment’s authorization boundary for what runs in production: whoever can push a tag your manifests reference can run their code with your database credentials. Restrict push rights, prefer digest pins over mutable tags for anything you deploy (the compose files pin the third-party images by digest for exactly this reason), and verify the published attestations before rollout; the verification command is in Installation → Kubernetes & Helm.

None of these four is something this project can assert on your behalf, which is why they are written as your checklist rather than as our claim.

Upgrades

  • Upgrade in place; the migrations are append-only. A released migration is never edited again, so a database created by an earlier release takes the new release’s migrations on top of the ones it already carries. The server records the checksum of every applied migration and refuses to start against a database whose recorded checksums differ from the files it carries (migration 1 was previously applied but has been modified), which is why editing one would lock every existing installation out of its own database rather than revise its history. A correction ships as a new migration, and a CI guard fails any pull request that modifies, renames or deletes one the base branch already has. A rolling upgrade must still stay compatible with the previous schema for the window in which both versions run: additive changes first, destructive changes a release later.

    Releases up to and including 4.0.18 predate this policy and did edit migration files in place. A database created by one of those and never upgraded since is recreated rather than migrated.

  • Sweep for stale decomposition after a release that changes how content is decomposed. The version body is unaffected and every read serves it correctly; what is stale is the decomposed index over it, which a row-level query predicate depends on. POST /ferroehr/rest/extensions/v1/admin/integrity/verify reports each such version as stale_decomposition and POST /ferroehr/rest/extensions/v1/admin/integrity/rebuild-nodes, run once unscoped, rewrites the rows from the stored document. The changelog names the affected object type at each release that needs it; 4.2.0 is one, for demographic parties. Both routes are on Admin & messaging APIs.

  • Upgrading a 5.5.0 that sets [server.tls] client_ca_file: set client_auth = "required" beside it. Before 5.5.0 the CA alone meant a required client certificate; 5.5.0 read it under the new client_auth default none and asked for no certificate, and from 5.6.0 such a table is refused at load (GHSA-52mf-qg39-4q4j). See [server.tls].

  • Bound the DDL yourself. Every pooled connection carries the db.statement_timeout_ms value (60 seconds by default), and the migration step runs on that pool, so a runaway statement is cut off, but there is no lock_timeout, so a migration that waits behind a long transaction waits as long as that transaction lasts. On a busy table use CREATE INDEX CONCURRENTLY, add constraints NOT VALID and VALIDATE later, and set lock_timeout in the migration session (or on the migrator role) if you need the wait bounded.

  • Pin the image. Deploy an immutable tag or, better, a @sha256 digest, never latest; roll back by re-pinning the prior digest, since the schema’s backward compatibility makes that safe.

  • Stay available. Keep at least two replicas (or autoscaling) and a pod disruption budget so node drains and upgrades never fully interrupt the API. The default 30-second termination grace period covers the server’s short shutdown drain of the audit and event outboxes.

How long the version you pinned is supported

Plan the upgrade cadence around this:

  • Only the newest release is supported. When a new release is published, every earlier release stops receiving fixes: there are no maintenance branches, no long-term-support line and no backports. If you are not on the newest release, upgrade; upgrading is free. Under the Cyber Resilience Act, Cadasto B.V. commits to keep publishing security fixes for at least five years after each release (Art 13(8)) and ships them only in the newest release (Art 13(10)), which every user of an earlier release may run under the same licence terms. Staying on an older release means running without the fix.
  • A fix normally arrives as the next patch on the current minor, so taking it does not oblige you to take new behaviour, but that is the usual case, not a promise. Where a fix is only correct alongside a behavioural change, the release carrying it carries the change, and the changelog entry says so.
  • A published release is never repaired in place. Release immutability means the assets and the tag of a published release cannot be modified at all, so the remedy for any defect is a new version.
  • The Helm chart and the published crates follow their own version lines, each supported only in its newest published version, which is where its fixes ship. A chart-only fix ships as a new chart version between server releases.

The running server states the end month of the commitment. Cadasto B.V. keeps publishing fixed releases for at least five years after each release (CRA Art 13(8)), and the server reads its release date from its changelog heading at build time. The end month, the date until which fixed releases keep coming, does not make the running release supported: a release is supported only while it is the newest. The end month is on the boot banner (Support line), in the boot log, in ferroehr --version, under support in GET /management/info, and under support in GET /management/status as {release_date, support_ends, status}, where support_ends is YYYY-MM and status is supported (the commitment is still running), ended or unreleased. Once the end month has passed, status reads ended and the server logs a warn line at boot and once a day (CRA Art 13(19)). A build of a version whose changelog heading carries no date is not a release: it reports unreleased and no end month.

The server does not contact anyone to look for newer releases. To hear about them, subscribe to the release feed (https://github.com/FerroHEALTH/FerroEHR/releases.atom) and poll the published security advisories (GET https://api.github.com/repos/FerroHEALTH/FerroEHR/security-advisories).

The consequence for change control: budget for taking every release, or budget for maintaining a fork. There is no third option, and the full policy (with the reasoning and what to do if you need something stronger) is SECURITY.md.

Observability

tracing is the single instrumentation API. From it, three signal families fan out, and identified data never enters any of them: telemetry uses only closed-set labels and opaque request/trace ids, so correlation to a patient is possible only through the audit trail.

  • Logs go to stdout (JSON when not attached to a terminal, pretty on a TTY). Shipping and rotation are the platform’s job. FERROEHR__TELEMETRY__FORMAT (auto/json/pretty) and FERROEHR__TELEMETRY__FILTER (or RUST_LOG, default info,hyper=warn,tower=warn,h2=warn) control them, and the level can be changed at runtime through the loggers endpoint below. Every request writes one request-log line: method, route template, status, latency and the server’s request id. On boot the server prints a one-time ASCII banner (version, maintainer, project URL, and spec pins) to stdout ahead of the logs; it is suppressed under FERROEHR__TELEMETRY__FORMAT=json so machine log consumers see only structured lines.

  • Traces export to any OpenTelemetry collector (Tempo, Jaeger, and so on) over OTLP, but only when you configure [telemetry.otlp]. Each request’s span is named by route template, never by a path containing ids. A client’s traceparent is never read: every request starts a trace of the server’s own.

  • Metrics come from one OpenTelemetry meter provider with up to two readers: a Prometheus reader behind /management/prometheus, and (with [telemetry.otlp] and metrics = true) a periodic OTLP reader. Every instrument reaches both by construction, so a family can never exist on the scrape surface and be missing from the push. The catalogue covers HTTP request duration (http.server.request.duration); authentication failures and authorization decisions (Cedar and remote-PDP); database pool state, acquire latency and transaction counts; AQL query counts, latency and plan- cache events; compositions committed (by openEHR audit change type); validation failures and version-signature faults; WebTemplate cache events; events published; the whole ATNA audit pipeline; Tokio runtime gauges; process start time and resident memory; and the ferroehr_build_info identity.

    Instrument names carry no _total suffix and no unit suffix: units are declared on the instrument and the Prometheus exporter derives _total/_seconds/_bytes itself. Read the exposition to learn the exact rendered names rather than assuming either spelling.

The telemetry environment variables:

Environment variableDefaultMeaning
FERROEHR__TELEMETRY__OTLP__ENDPOINTunset (nothing exported)OTLP collector endpoint
FERROEHR__TELEMETRY__OTLP__TRACEStrueexport the spans
FERROEHR__TELEMETRY__OTLP__METRICStruepush the metrics beside the Prometheus reader
FERROEHR__TELEMETRY__OTLP__SAMPLE_RATIO1.0share of traces sampled (start at 0.1 in production)
FERROEHR__TELEMETRY__ENVIRONMENTunsetreported deployment environment
FERROEHR__MANAGEMENT__FLAME_FILEunset (layer not installed)write folded span-timing samples to this file for offline rendering, diagnostic sessions only

Neither reader is reachable until you say so: the Prometheus surface needs management.enabled plus an access level on the prometheus endpoint (see the management surface), and the OTLP reader needs telemetry.otlp.endpoint. A server with neither still records every instrument; nothing exports it.

Tip

A single-container dev stack (grafana/otel-lgtm, bundling an OTLP collector, Prometheus, Tempo, Grafana, and Loki) ships as a Compose overlay, together with a provisioned Grafana dashboard (request rate/errors/duration, database pool, AQL latency, validation failures, audit health) and a starter alert pack; point the server at it with the OTLP variables above.

On Kubernetes, the same keys arrive through the chart’s config passthrough, and the metrics half has a second switch that is easy to miss:

# values.yaml
config:
  telemetry:
    environment: production
    otlp:
      endpoint: http://otel-collector.observability:4317
      sample_ratio: 0.1
      metrics: true
  management:
    enabled: true
    endpoints:
      prometheus: admin_only   # the scrape endpoint is off until you name it
metrics:
  enabled: true                # adds the prometheus.io/* pod annotations
  serviceMonitor:
    enabled: false             # or true, with the Prometheus Operator CRDs installed

metrics.enabled only adds the scrape annotations; the endpoint itself is opened by config.management.endpoints.prometheus. Both are needed for an annotation-discovering Prometheus, and neither is needed if you push over OTLP instead. To turn the export off, drop telemetry.otlp; nothing is exported without it.

The default dashboard. The “FerroEHR — service overview” Grafana dashboard (request rate/errors/latency, AQL rate and phase latency, plan-cache hit ratio, database pool, Tokio runtime, audit throughput — every query written against the served metric names) reaches Grafana three ways:

  • Compose: the observability overlay provisions it automatically; nothing to do.
  • Kubernetes: set metrics.grafanaDashboard.enabled: true and the chart ships it as a ConfigMap labelled grafana_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’s searchNamespace. metrics.grafanaDashboard.folder sets the grafana_folder annotation for sidecars with folder routing configured.
  • Anywhere else: import deploy/helm/ferroehr/files/dashboards/ferroehr-overview.json by hand (Grafana → Dashboards → Import).

Warning

With the chart’s default-deny egress policy on, add the collector to networkPolicy.egress.rules (port 4317). An OTLP exporter that cannot reach its collector fails silently: no traces, no error.

The usage report

Apart from your own telemetry, the server sends one report of its own: the usage report to FerroPULSE, the collector Cadasto B.V. runs. It is on by default outside a CI run and goes out at each start and once a day, with the version, the licence grant type and coarse latency figures, and no patient data. ferroehr usage-report --print shows the exact JSON, and usageReport.enabled=false (Helm) or FERROEHR__USAGE_REPORT__ENABLED=false switches it off. With the chart’s egress policy on, it needs a rule for report.ferropulse.eu on port 443; without one it logs a warning.

The admin and messaging APIs

Two operator-facing HTTP surfaces have a page of their own, because each route carries its own switch, authorization class and status-code contract:

  • The admin API ({base}/admin/…, off by default): physical deletion, the activity report, archiving to the cold tier, and whole-repository dump and load.
  • The messaging API (/ferroehr/rest/extensions/v1/message/…, always mounted): EHR Extract export and import.

The full reference is Admin & messaging APIs.

Warning

Enabling the admin API puts irreversible, whole-repository operations on the wire: DELETE {base}/admin/ehr/all with 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

EndpointContractUse it for
GET /healththe readiness status (200 or 503) with the state alone, {"state": …}load balancers, anything that must never be auth-gated
GET /health/livenessconstant 200 with {"state":"up"}, touches nothingKubernetes livenessProbe and startupProbe
GET /health/readiness200 while ready, 503 while the process boots or drains or an indicator is down; the FerroHEALTH readiness document (state, phase and each indicator’s name and state), each check bounded to one second. Only db and migrations read down; every other indicator reads at worst degradedKubernetes readinessProbe, ops dashboards
GET /ferroehr/rest/statuspublic status document: status, server_version, openehr_rest_api_version, timestamp and deployment.profile. The full document, which adds licence (the grant in force: state, use, licensee, not_after, configured_token), the whole deployment posture (the declared profile, the separations still open and the ones accepted by name) and support (release_date, support_ends, status), is GET /management/statusversion/identity checks
GET /management/*ops introspection; see belowoperators, off by default, enable deliberately

Every management request is itself recorded in the audit trail as a system-domain access under its own operation id (management_info, management_env, management_loggers_set, …), so a runtime filter change or a configuration read is never an unrecorded administrative act.

There is exactly one health surface: the /health family above, the one every FerroHEALTH product serves, and the route the container’s ferroehr healthcheck subcommand asks is /health/readiness. /ferroehr/rest/status is a different contract, and no health endpoint exists under the REST root.

Not every indicator blocks readiness, and the distinction is deliberate:

IndicatorChecksBlocks readiness
dba pooled connection answersyes
migrationsthis build’s schema is present, re-tested on every probeyes
audit_senderthe audit posture: DEGRADED with the consequence stated when auditing is off (no access log, no EHDS logging component); UP with fail_mode, the local store and its retention in the detail, and a stated caution under fail_mode = "open"no: reports DEGRADED, never 503
audit_deliveryeach forwarder to an external Audit Record Repository (present when [audit.syslog] or [audit.fhir_feed] is on): the records waiting, those the repository refused, those delivered, and whether the last attempt reached it; DEGRADED when the repository is unreachable or a record was refusedno: records wait in their spool or the store, so delivery problems never take the instance out of rotation
audit_chainthe latest scheduled verification of the local audit store’s hash chain (incremental from the last intact link, with a periodic full pass) (present when the local store is on and [audit.store] verify_interval_seconds is not 0): UP while intact or before the first run, DOWN naming the finding count and the first damaged position, DEGRADED when the verification could not runno: a damaged chain reports DOWN in the body, never 503, because the trail keeps recording while the damage is investigated
eventsthe event publisher’s broker delivery (present only when eventing is enabled)no: reports DEGRADED, never 503, since the outbox buffers while the broker is down

An instance whose event broker is unreachable therefore keeps taking traffic and says so in the body; an instance that cannot reach its database, or whose database lost the schema, leaves rotation. The detail strings are written to carry no connection information (no DSN host, database name or role) because this surface is unauthenticated by design.

Important

Liveness and readiness are deliberately different: liveness never touches a dependency, so a database outage takes the instance out of rotation (readiness 503) instead of getting the container killed and restarted in a loop. Wire livenessProbe and startupProbe to /health/liveness and readinessProbe to /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’s ferroehr healthcheck subcommand instead, because that subcommand probes the status document rather than a health endpoint, which would leave readiness never touching the database.

While the database restarts or is out of reach, requests answer 503 Service Unavailable with Retry-After: those that cannot get a connection, and those whose open connection the restart or the network cut (SQLSTATE class 08, 57P01 admin shutdown, 57P02 crash shutdown, 57P03 cannot connect now, or a reset socket). A transaction the cut interrupted is rolled back by PostgreSQL. A request that waits more than a second for a connection makes the server try a connection of its own: when that cannot reach the database, the request answers 503 at once, and so does every request after it, until a fresh attempt (at most every half second) connects again. A pool that is only busy keeps its requests queued up to db.acquire_timeout_secs (30 seconds by default).

A statement the database cancels (SQLSTATE 57014, by db.statement_timeout_ms or by an operator’s pg_cancel_backend) also answers 503 with Retry-After: the statement rolled back, so the request can be repeated. With the defaults the request timeout (30 seconds) answers first, so the statement timeout (60 seconds) does not fire on a request. PostgreSQL measures its timeouts against the database host’s wall clock, so a host whose clock steps forward, such as a virtual machine resynchronised after a stall, cancels every running statement at once; keep the database host’s clock synchronised by slewing, not stepping.

The management surface

The management surface is ops introspection only: build info, Prometheus, the metric views, the effective configuration, and runtime log control. It is off by default on the bare binary, and each endpoint is independently opt-in with an access level (admin_only, private, or public). It can be bound to its own internal port so it never appears on the public API listener. Keeping it off costs you nothing operationally: the health probes above do not depend on it.

Environment variableDefaultMeaning
FERROEHR__MANAGEMENT__ENABLEDfalseenable the management surface
FERROEHR__MANAGEMENT__BASE_PATH/managementbase path for the surface
FERROEHR__MANAGEMENT__PORTunset (main listener)serve management on its own port
FERROEHR__MANAGEMENT__ENDPOINTS__<NAME>offthe 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.

EndpointEndpoint name to setPurposeLevel to give it
GET /management/infoinfoproduct name and version, build SHA, build date, rustc, the active spec_profile, the openEHR specification versions that profile selects, the PostgreSQL target, the audit posture (enabled, fail_mode, local_store, retention_days), the manufacturer of the release (name, postal_address, email, website), and the support period (release_date, support_ends, status)admin_only
GET /management/prometheusprometheusPrometheus text expositionadmin_only, or public only when the port is not reachable outside the cluster; a public endpoint is served OUTSIDE authentication
GET /management/metricsmetricsJSON list of the registered metric namesadmin_only
GET /management/metrics/{name}metricsthe current value(s) of one metric; 404 for a name that is not registeredadmin_only
GET /management/envenveffective configuration, with secrets redactedadmin_only
GET/POST/DELETE /management/loggersloggersread and change the log level at runtimeadmin_only
GET /management/flamegraphflamegraphon-demand CPU flamegraph of the running serveradmin_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/env and /management/loggers expose and change server internals; keep them admin_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) and frequency (default 99 Hz) are capped by management.profiling.max_seconds and max_frequency (30 and 999 out of the box); a request beyond a cap is refused with 400, 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_only on 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 to file:line.

With the surface enabled, the viewer grows an Operations screen over it (dependency health, build provenance, the metric registry, and runtime log control) which appears only while the CDR serves /management/info. See Viewer → Server.

The manufacturer and the deployment report

Cadasto B.V. is the manufacturer of each tagged release. The running system names it, with its postal address and single point of contact, on every surface an operator or a user reads:

SurfaceWhat it shows
startup bannerManufactured by Cadasto B.V. · https://www.cadasto.com/contact/, then the postal address and info@cadasto.com
ferroehr --versionthe version (a release build prints the release, any other build appends its commit as +dev.<commit>, as server_version on GET /ferroehr/rest/status does), then Manufactured by Cadasto B.V., Comeniusstraat 2d, 1817 MS Alkmaar, The Netherlands, info@cadasto.com and the website
GET /management/infoa manufacturer object with name, postal_address, email and website
OPTIONS on the API base pathvendor defaults to Cadasto B.V. ([rest.identity])
viewerthe footer of every screen, and a Manufacturer card on /system
container imagesthe eu.ferroehr.image.manufacturer label

ferroehr report writes one JSON file naming what a deployment runs: the version, commit and build date, the enabled features, the specification pins and openehr-* crate versions, the schema migration level, the deployment posture, the licence summary, the manufacturer and the effective configuration with every secret redacted. Attach it to a complaint or a serious-incident report; see ferroehr report for what it holds and what it leaves out.

Next

Admin & messaging APIs

Two groups of routes sit beside the clinical API for operators rather than for clinical clients: the admin API, which physically deletes, reports on, and moves repository content, and the messaging API, which exports and imports whole records as openEHR EHR Extracts. This page is the operator’s reference for both: every path, what gates it, what it accepts, and every status code it answers.

Where these routes live, and what gates them

The routes ITS-REST defines (the two EHR deletes) sit under the openEHR base path, {base}: with the default rest.base_path it reads /ferroehr/rest/openehr/v1. Every other route on this page is FerroEHR’s own extension and sits beside it under {ext}, which reads /ferroehr/rest/extensions/v1; so {ext}/admin/dump is /ferroehr/rest/extensions/v1/admin/dump. Nothing ITS-REST does not define is served under the openEHR base path.

The two groups are gated differently, and the difference matters operationally:

GroupSwitchAuthorization classWhile switched off
{base}/admin/… and {ext}/admin/…admin.enabled (FERROEHR__ADMIN__ENABLED), default falseadmin: the configured authz.rbac.admin_role (ADMIN by default)every route answers 405 Method Not Allowed with an empty Allow header: the resource exists but currently serves no method
{ext}/message/…none (always mounted)ordinary clinical, exactly like the composition APIn/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, READONLY by 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/all with no parameter empties the repository. There is no confirmation step and no undo. Keep admin.enabled off 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.

RouteSuccessRefusals
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 gone400 malformed id (rejected before any deletion), 404 no EHR with that id
DELETE {base}/admin/ehr/all204: bulk delete400 any id in the list is not a well-formed UUID; the whole request is refused before anything is deleted
DELETE {ext}/admin/party/{party_id}204: the demographic party, every PARTY_RELATIONSHIP naming it as source or target, and all their versions, contributions and commit audits are physically gone400 malformed id, 404 no party with that id (a relationship id is refused the same way)
DELETE {ext}/admin/template/{template_id}204404 unknown template, 409 a committed version still references it
DELETE {ext}/admin/query/{qualified_query_name}/{version}204404 unknown name, or a known name with no such version
GET {ext}/admin/config200: the effective configuration as a redacted JSON tree—

Every route additionally answers 401 unauthenticated, 403 for an authenticated caller outside the admin class, and 405 while the group is switched off.

What an EHR delete reaches, in the order it runs: one clinical transaction removes the EHR row and, through the foreign-key graph, its versions over both storage tiers, their nodes, attestations, contributions, commit audits, item tags, folder memberships, the restriction and retention marks, the multimedia blob references and every pending change event of the EHR; an erasure tombstone is appended to the change-event stream in the same transaction, so a consumer that derived anything from the EHR is told to delete it (GDPR Art. 19). The cross-reference row that names the EHR’s subject is then erased in the linkage domain, and the externalized multimedia blobs no surviving version still references are removed from the object store.

A party delete announces itself the same way. DELETE {ext}/admin/party/{party_id} deletes a party physically. The released Admin API defines deletion for EHRs only, so the route is our own extension, and the served OpenAPI document says so. It writes one erasure tombstone on the party domain’s own outbox before its transaction commits, naming the party and every PARTY_RELATIONSHIP erased with it. A consumer of the party stream has no other way to learn the identity is gone. The access log records it the way it records an EHR delete: action D under the operation admin_party_delete, on the demographic resource, with the party id as the resource.

What it keeps: the audit records naming the ehr_id or the party. Art. 17(3)(b) withholds erasure where processing is necessary for compliance with a legal obligation, and the national access-logging periods are that obligation. Data outside the running database (backups, replicas, exports) is reached by your own rotation, not by this call.

Details that decide behaviour:

  • The bulk delete’s parameter is optional, and its absence means everything. DELETE {base}/admin/ehr/all with no ehr_id deletes 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 no 404.
  • Deleting a template never orphans clinical data. The 409 is a deliberate guard: while any stored composition was committed against the template, the delete is refused and the message says how many committed versions still hold the reference. Delete those compositions first.
  • A stored-query delete removes exactly one (name, version) row. The query’s other versions survive.
  • The config view is redacted structurally, not by key name. Passwords and password hashes, signing-key secrets and S3 secret keys render as ***; every URL that can carry credentials (database, AMQP, the FHIR audit feed, the terminology server and its token endpoint, the remote policy server, the OTLP collector, the object store, the usage-report endpoint) keeps its host and path and masks the embedded credentials (postgres://***@host:5432/db). The OIDC issuer and the SMART URLs are refused at boot when they carry credentials, so they render as set. Non-secret identifiers (usernames, roles, an OIDC issuer) stay visible. Redaction is a property of the configuration’s secret types, so no secret value can reach this response.

Note

The template delete, the stored-query delete and the config view are FerroEHR extensions: the openEHR admin API defines only the two EHR deletes. They share the same switch and the same authorization as those deletes.

The activity report

Four read-only counters over the repository’s change history, behind the same switch and role as the deletes.

Every route takes two query parameters:

ParameterRequiredMeaning
a_serviceyesthe service whose versioned content to report on: one of Admin, Definitions, Ehr, Ehr_index, Demographic, Message, Query, System_log, matched case-insensitively
time_intervalno<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.

Route200 body
GET {ext}/admin/report/contributionthe matching CONTRIBUTION ids as a JSON array, ordered by commit time then id
GET {ext}/admin/report/contribution/counthow many there are, as a bare JSON number
GET {ext}/admin/report/versioned_composition/counthow many distinct COMPOSITION version containers had a version committed in the interval
GET {ext}/admin/report/composition_version/counthow 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.

RouteBodySuccess
POST {ext}/admin/archive/ehrs{"ehr_ids": ["…"]}204: every named EHR and all its versioned content is archived
POST {ext}/admin/archive/parties{"party_ids": ["…"]}204: every named demographic party is archived
POST {ext}/admin/archive/ehrs/restore{"ehr_ids": ["…"]}204: every named EHR’s archived content is back in the primary tier
POST {ext}/admin/archive/parties/restore{"party_ids": ["…"]}204: every named party’s archived content is back in the primary tier

All four are all-or-nothing and idempotent: a body of the wrong shape or a malformed id is 400 and an id that names nothing is 404, in both cases before anything is moved; re-archiving an already-archived record, or restoring one that is not archived, changes nothing. An empty list succeeds and moves nothing. A body sent without Content-Type: application/json is 415. A party that is currently archived is still found by its restore call (the existence check spans both tiers) so an archived party is never reported missing.

A party’s PARTY_RELATIONSHIPs are not carried along: each is an independently addressable versioned object, archived in its own right.

What “cold storage tier” means here: the archived rows are physically moved out of the primary tables into a separate schema in the same database, so the tables and indexes that serve everyday traffic shrink by exactly what was archived: no extra tablespace, volume, or external service to operate. Reads of unarchived records never touch the cold tier; a read that addresses an archived record is served from it. Writing to an archived record brings it back to the primary tier first, so a versioned object is never split across tiers; a physical delete clears both tiers; and {ext}/admin/dump still exports archived content.

Two consequences to plan for:

  • AQL queries see the primary tier only, so an archived record stops appearing in query results; that is exactly what shedding the query tables’ rows and indexes buys you. Everything addressed by id (an EHR, a composition, a folder, a party, a version, a revision history) keeps working as before. Restoring puts the record back in query results.
  • There are three ways back, and only one of them is deliberate. The …/restore routes 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.

Eleven routes behind the same switch and role, carrying the four marks the law asks a repository to hold beside its clinical content. None of them deletes anything.

RouteBody or parameterSuccess
POST {ext}/admin/restriction{"ehr_id": "…", "vo_id": "…"?, "ground": "…", "note": "…"?}204: the restriction is recorded and in force
GET {ext}/admin/restriction?ehr_id=…200: the register, newest request first, lifts included
POST {ext}/admin/restriction/lift{"ehr_id": "…", "vo_id": "…"?}204: every in-force restriction at that grain is lifted
POST {ext}/admin/research-objection{"ehr_id": "…", "objected": true, "ground": "…"?}204: the objection, its override, or its withdrawal is recorded
PUT {ext}/admin/retention/policy{"kind": "…", "jurisdiction": "…", "category": "…"?, "origin": "…"?, "period": "…", "anchor": "…", "source": "…"}204: the period is declared, keyed on one EHDS priority category when category is set and on one registered origin ([origins]) when origin is set
GET {ext}/admin/retention/policy—200: the whole retention register, each period with its category and origin (null for every object of the kind, or every origin)
PUT {ext}/admin/retention/anchor{"ehr_id": "…", "jurisdiction": "…", "anchored_at": "…"?, "hold_at": "…"?, "hold_ground": "…"?}204: the EHR’s anchor and any whole-record hold
POST {ext}/admin/retention/hold{"vo_id": "…", "held": true}204: the per-object exemption is placed or released
GET {ext}/admin/retention/due?limit=100200: what has run out, oldest first, per period and category, with the due and held object counts
POST {ext}/admin/access-restriction{"ehr_id": "…", "vo_id": "…"?, "note": "…"?}204: the EHDS Art. 8 restriction of access by health professionals is recorded and in force
GET {ext}/admin/access-restriction?ehr_id=…200: the access-restriction register, newest request first, lifts included
POST {ext}/admin/access-restriction/lift{"ehr_id": "…", "vo_id": "…"?}204: every in-force access restriction at that grain is lifted

A body of the wrong shape or a malformed id is 400, an id that names nothing is 404, and a body without Content-Type: application/json is 415, in each case before anything is recorded. Setting or lifting a mark is an access record of its own.

A category is named as [audit.categories] names it (a priority category by its code, Patient-Summaries, Electronic-Prescriptions, Electronic-Dispensations, Medical-Imaging, Laboratory-Reports or Discharge-Reports, or as <system>|<code>; a declared national category as its <system>|<code>; or none; an unknown one is 400) and stored as its <system>|<code> token, which serves EHDS Annex II 3.4: retention periods that take the categories of the data into account. The server mirrors the map into the register at every boot. A period keyed on a category lists the objects the map places in it; an object the map cannot classify is listed under the longest period configured for its kind, and a category row with no object is left out of due. A period with no category lists every object of its kind, as before.

Restricting a record changes what every other route answers: reads of a restricted object become 403, it leaves AQL results at every scope, exports skip it and the event stream withholds it, and writes to it are refused. On a write the restriction is the LAST gate: content validation answers 422 and the preconditions that run before the commit transaction, a non-modifiable EHR or a second directory, answer 409, while the mark is read inside the transaction. A malformed write to a restricted object therefore answers 422 rather than 403. Both refuse the write and neither changes what is stored, so read the register itself to learn whether a restriction is in force. What each mark means, which provision it serves and what the deploying organisation still has to decide are on Retention, restriction and objection.

An access restriction is not a restriction of processing: it limits what a health professional reaches and nothing else, and it is invisible to them. What it changes is on restriction of access by health professionals.

Reading a party’s protected identifiers

With national-identifier protection on, a party’s versioned body carries a reference where each protected identifier stood, and every ordinary read serves that reference. GET {ext}/admin/party/identifiers is the one route that puts the values back for a reader entitled to see them. No openEHR specification governs it; it is our own extension.

curl -H "Authorization: Bearer $TOKEN" \
  "$BASE/admin/party/identifiers?version_uid=8d7c…::ferroehr.local::2"
{
  "version_uid": "8d7c…::ferroehr.local::2",
  "expanded_schemes": ["nl-bsn"],
  "version": { "_type": "ORIGINAL_VERSION", "data": { "_type": "PERSON", … } }
}
  • Two gates. The route is in the admin group, so it needs admin.enabled and the admin role, and it also needs the role authz.rbac.identifier_reader_role names. Holding the admin role alone reveals no national identifier, and with the key unset nobody may call the route (403).
  • Any version. version_uid names one party version; a historical version shows the value it committed.
  • A view, never a write. The body is the stored ORIGINAL_VERSION with the values put back, so it no longer matches the signed version. Nothing is written back.
  • Every read is recorded. Each scheme expanded is one access record in the demographic domain, naming the reader, the party and the scheme (party:<id>#national-identifier:<scheme>), never the value. A version that holds no protected identifier still leaves one record. Under audit.fail_mode = "closed" a record the trail cannot take withholds the values with 503.
  • Refusals. 400 for a missing or malformed version_uid, or a deployment that configures no identifier protection; 404 when no party holds that version.

Storage integrity

Two routes that check the stored data against itself and repair what they find, behind the same switch and role. FerroEHR stores every version’s content twice: once as the materialized document a point read serves, and once as the decomposed rows the AQL engine queries. A commit writes both in the same transaction, so they always agree; anything that changes one of them behind the server’s back breaks that agreement.

Route200 body
POST {ext}/admin/integrity/verifythe sweep report
POST {ext}/admin/integrity/rebuild-nodesthe 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:

ParameterEffect
ehr_idcover only versions belonging to that EHR
vo_idcover only versions of that versioned object
sys_versioncover only that version of it; the ordinal is per-object, so it needs vo_id and is a 400 on its own
committed_sincecover 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:

typeWhenCarries
mismatchas each disagreement is foundthe same five fields the report’s mismatches entries carry, domain included
progressonce per page of versions readthe counts so far
summaryonce, at the endthe final counts and elapsed_ms
errorinstead of summary, if the sweep failed part-waya short message; the detail is in the server log

Two consequences are worth planning around. The status code is sent before the work is done, so a sweep that fails half-way still answered 200: read to the end and check that the last line is a summary, not an error. And the stream carries no reporting cap, so every mismatch reaches you rather than the first thousand.

The stream has to be asked for by name. A request sending */*, or no Accept at all, gets the aggregated document below, unchanged.

{
  "versions_checked": 128,
  "versions_with_body": 126,
  "versions_without_body": 2,
  "mismatch_count": 1,
  "mismatches": [
    {
      "domain": "clinical",
      "vo_id": "8849182c-82ad-4088-a07f-48ead4180515",
      "sys_version": 2,
      "kind": "COMPOSITION",
      "defect": "content_differs"
    }
  ],
  "truncated": false,
  "elapsed_ms": 431
}

domain is clinical or demographic, naming the schema the damaged version lives in. It is also what the repair below uses to reach it.

defect is one of five values:

  • content_differs: both copies exist and hold different content.
  • nodes_missing: the version has a stored document but no decomposed rows.
  • nodes_unreadable: the decomposed rows exist but no longer form one tree.
  • unexpected_nodes: the version is a logical delete, which stores no document, yet decomposed rows exist for it.
  • stale_decomposition: the rows hold exactly the stored document, but not in the shape this version of the server decomposes it into. An older release wrote them. The content is intact and every read serves it correctly; what is stale is the index over it, so a query that depends on the current shape does not reach this version until the repair below rewrites its rows.

mismatch_count is the full count. mismatches is capped at 1000 entries and truncated says whether the cap was reached; every mismatch is logged at warn level with its identifiers whatever the cap does, so a truncated report never loses a finding. The log line and the response carry identifiers only, never content.

A finding is not a request failure: the sweep ran and is telling you what it saw, so the status stays 200 and the report is the body. Check mismatch_count, not the status code.

Note

This is the companion check to digest signing. A stored digest covers the document a point read serves; it says nothing about the decomposed rows, which no read-path check recomputes. Together the two cover both copies.

Repairing what the sweep found

The sweep is the diagnosis. POST {ext}/admin/integrity/rebuild-nodes is the repair: it re-derives the decomposed rows of every damaged version from that version’s stored document, one transaction per version.

curl -s -X POST "$BASE/admin/integrity/rebuild-nodes?ehr_id=$EHR_ID"

It runs the same sweep first and writes only the versions that sweep reports damaged, so a run over healthy data writes nothing at all. The scope parameters are the same four, so a repair can be as narrow as one version:

curl -s -X POST \
  "$BASE/admin/integrity/rebuild-nodes?vo_id=$VO_ID&sys_version=2"

Which copy wins is not a choice the route makes. The stored document is the version’s canonical serialized form, the bytes a point read serves and the bytes a digest was taken over; the decomposed rows are an index derived from it. So the repair only ever runs in that direction, and a version whose document is the damaged copy is refused rather than having the damage copied into the index.

Each version is repaired inside one transaction: the document is read under a row lock, decomposed, the old rows deleted, the new set inserted, and the version re-derived from what was just written and compared with the document before the commit. Anything that fails rolls that transaction back, so the version’s rows are left exactly as they were. One refusal never stops the run that found it.

An archived object is thawed for the repair and re-archived in the same transaction, marker and all, because a write to a versioned object always happens in the primary tier.

A logically deleted version stores no document, so it rebuilds to no rows, which is the repair for unexpected_nodes.

The same route is the upgrade step after a release that changes how content is decomposed. Rows written by the older release hold the right content, so nothing is damaged, but they are the wrong shape for the new one and the sweep reports them stale_decomposition. Rebuilding writes them again from the stored document, which is all the upgrade is. Run it unscoped once and the next sweep is clean:

curl -s -X POST "$BASE/admin/integrity/rebuild-nodes"

The changelog names the affected object type at each such release.

{
  "versions_checked": 128,
  "versions_damaged": 2,
  "versions_rebuilt": 1,
  "versions_refused": 1,
  "records": [
    {
      "domain": "clinical",
      "vo_id": "8849182c-82ad-4088-a07f-48ead4180515",
      "sys_version": 2,
      "kind": "COMPOSITION",
      "defect": "content_differs",
      "outcome": "rebuilt",
      "node_rows": 41
    },
    {
      "domain": "demographic",
      "vo_id": "1f0b7d64-6b2a-4a1f-9f0e-6b1d2a3c4d5e",
      "sys_version": 1,
      "kind": "PERSON",
      "defect": "content_differs",
      "outcome": "refused",
      "reason": "the stored body does not decompose: ..."
    }
  ],
  "truncated": false,
  "elapsed_ms": 812
}

defect is the sweep verdict that selected the version, from the five values above. outcome is rebuilt, carrying the node_rows the version now has, or refused, carrying the reason. records is capped at 1000 entries with truncated saying whether the cap was reached; every record is logged at info level with its identifiers whatever the cap does.

A refusal is not a request failure, so the status stays 200. Check versions_refused: anything above zero means a stored document is itself damaged, and that is a restore-from-backup question rather than a rebuild one.

Unlike the sweep, this route writes, so a server in read-only mode refuses it.

Dump and load

Two routes that move the whole repository to and from an archive on the server’s file system, behind the same switch and role. Both answer 200 with a JSON array of per-entity failure reports; an empty array means everything succeeded.

POST {ext}/admin/dump

Writes an archive of every EHR and of every standalone demographic container: the parties and party relationships that live outside any EHR. The body is {"file_sys_loc": "…"} plus the optional export settings:

FieldValuesDefault
logical_formatopenehr_canonical_json or openehr_canonical_xmlcanonical JSON
compression_formatzip or 7z (omit for loose files)uncompressed
segment_split_sizesegment 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_xml writes each version to its own versions/<version_uid>.xml entry instead: a complete ORIGINAL_VERSION document under the openEHR-published <version> root, ready to hand to any tool that reads canonical openEHR XML.

The archive is a directory holding a manifest.json, one or more segment-NNNN.json files, and a blobs/ subdirectory for any externalized multimedia. When the repository holds standalone demographic containers, the archive additionally carries a demographic-commons.json (their shared audits and contributions) and one or more demographic-NNNN.json segments, one record per party or relationship, in the same version-record shape the EHR segments use. With compression_format set, those same entries are packed into a single archive.zip or archive.7z inside the location instead. The archive’s own bookkeeping (the manifest and the segment skeleton) stays JSON in both logical formats, because openEHR publishes no XML document form for it.

POST {ext}/admin/load

Populates the repository from an archive. It takes the location and nothing else: the container (loose files, a single archive.zip, or a single archive.7z) is detected from what the location holds, and the logical format is read from the archive’s own manifest, so a load never has to be told how the dump was written. Archives written before the demographic wave existed simply carry no demographic entries and load unchanged.

The repository being loaded into need not be empty. An EHR whose id is already present is reported and skipped rather than failing the load, so the response array names each one; and a standalone demographic container that already exists is reported the same way, under its own kind (PERSON, ORGANISATION, GROUP, AGENT, ROLE, or PARTY_RELATIONSHIP) as the entity_type:

[ { "entity_type": "EHR",
    "entity_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
    "dump_status": false,
    "error": "an EHR with this id already exists" } ]

Both directions are lossless: a dump and a load reproduce every record, whichever format and container you choose.

With national-identifier protection on, a party’s demographic record also carries its sealed identifier rows, live and retired, as base64 ciphertext, nonce and lookup digest. The archive never holds a protected value in plaintext. The rows open again only under the same [demographic.identifier_protection] root key, so restore a dump into a deployment configured with the key it was taken under. A load reports a demographic container in the response array and restores none of it when:

  • one of its sealed rows holds a value that is already live for another party in this repository, or names a scheme this repository has not registered;
  • with protection on here, one of its versions carries a protected scheme’s value in plaintext, or references a sealed row the container does not carry.

Refusals

StatusWhen
400a 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
415the request Content-Type is not application/json
500a 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 …/restore routes go one step further: the service model declares the archive calls and no un-archive counterpart, so both the operation and its URL are ours. They gate no openEHR conformance claim; see Conformance.

EHR Extract export and import

Four routes under {ext}/message that move whole records between systems. Unlike the admin extensions above, these are not admin-gated; see the gate table.

  • GET {ext}/message/export/{ehr_id}: export one whole EHR. 200 with a JSON array holding one EXTRACT that carries every versioned object of the EHR, latest versions only. 400 if ehr_id is not a well-formed identifier, which is refused before any lookup; 404 if the EHR does not exist; 406 if Accept cannot be satisfied (the extract list is JSON only).
  • POST {ext}/message/export with an EXTRACT_SPEC body — export by specification. 200 with one EXTRACT per manifest entity, in manifest order. The manifest must name at least one entity, and each entity must name its record by ehr_id or subject_id, otherwise 400; an identifier that names nothing is 404. extract_type must 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-all other; 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 AQL POST.
  • POST {ext}/message/import with an EXTRACT body — 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 an EHR_STATUS (400), and the target must not already exist: 409, which also covers an imported EHR_STATUS naming a subject another EHR already holds.
  • POST {ext}/message/import/{ehr_id} with an EXTRACT body — 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 an EHR_STATUS or EHR_ACCESS under a different object id, or the imported status names another EHR’s subject; 422 if a version in the extract is semantically invalid (template, RM-invariant or terminology validation).

All four accept either application/json or application/xml bodies; any other Content-Type is 415.

Note

The whole {ext}/message group is a FerroEHR extension: the openEHR service model defines a Message component, but the released REST API publishes no message or extract endpoint at all. These URLs are our own and gate no openEHR conformance claim; see Conformance. For the workflow-level view (what an extract is good for and how to drive an import) see EHR Extract & messaging.

Other routes under {base}/admin

One further family shares the /admin path prefix but not the admin.enabled switch: it has its own, and answers 404 (not 405) while that switch is off, because the group is simply not serving:

RoutesOwn switchDocumented in
{base}/admin/event_subscription…events.admin_apiChange events (AMQP)

They are still admin-class routes for authorization, so the same 401 / 403 split applies.

For everything else an operator needs (migrations, health probes, the management surface, observability and upgrades) see Operations.

Usage report

Every FerroEHR instance sends a small JSON report to FerroPULSE, a collector that Cadasto B.V. runs. It goes out at each process start and once a day. It says that the instance is alive, which version it runs, under which licence grant type, and how fast it answers, in coarse aggregates. The report is on by default. Every boot logs one INFO line that says whether it is on and how to switch it off.

This page lists every field the report carries, says when it is sent and how to print it before it is sent, gives the switch, and carries the privacy notice for the data the collector stores. No openEHR specification governs any of it. Every FerroHEALTH product sends the same report through the family’s shared ferrohealth-usage-report crate; FerroEHR supplies its status, its route groups and the database that holds the report’s state.

When a report is sent

There are two kinds of report, and both are one HTTPS POST to https://report.ferropulse.eu/v1/report with a JSON body of at most 64 KiB.

  • start: at each process start, once the server has connected to its database. An instance sends at most one start report per ten minutes, however many replicas start: the time of the last one is kept in the database. A pod in a crash loop sends one start report per ten minutes, not one per restart.
  • daily: the first attempt 10 minutes after boot, then roughly every 24 hours (23.5 to 24.5 hours after the previous one, spread at random). One replica sends it for the whole instance. At each of its daily attempts a replica adds its counts to a window kept in the database; the replica that claims the report sends the totals and empties the window, and the counts of a replica that finds the report claimed wait there for the next one. A replica can claim it only when no daily report went out in the last 23 hours, so a restart does not add one.

The boot line, with the report on, reads:

usage report ON: instance id, version, licence type, deployment, uptime, host size and coarse request latency per route group, at each start and once a day; no clinical content, identifiers, URLs or request paths. Off: FERROEHR__USAGE_REPORT__ENABLED=false. Details: https://ferroehr.eu/docs/latest/usage-report.html

It carries the configured endpoint as the structured field endpoint. With the report off, the line reads:

usage report OFF: switched off (FERROEHR__USAGE_REPORT__ENABLED=false); nothing is sent. Details: https://ferroehr.eu/docs/latest/usage-report.html

What a report contains

Every report carries these fields:

FieldSent inWhat it is
schemabothAlways 2, the version of the report format.
eventbothstart or daily.
first_startstarttrue when this start created the instance id, which marks a new installation; false on every later start.
productbothAlways ferroehr.
instance_idbothA random UUID (version 4), created at the first start and stored in the database. It is not derived from the host, the licence or anything else about the deployment. Every replica shares it, and a restore of the database keeps it.
versionbothThe server version, for example 4.3.4.
git_shabothThe commit the binary was built from, 7 to 40 hex digits. A build that recorded no commit, such as one from a source tarball, sends 0000000.
licencebothThe licence grant type: commercial when an installed licence permits commercial use, non-commercial otherwise. Never the licence id.
deploymentbothHow the instance was deployed: helm, compose, binary or unknown. It comes from [usage_report] deployment, which the Helm chart sets to helm.
uptime_sbothSeconds since this process started.
cpu_bucketbothThe CPUs available to the process, as a range: 1-2, 3-4, 5-8, 9-16 or 17+.
memory_bucketbothThe memory available to the process in GiB, the smaller of the host’s memory and the container limit, as a range: <4, 4-8, 8-16, 16-32 or 32+. Off Linux the server cannot read it and sends <4.
statusbothThe instance’s state, below.
metricsdailyThe performance aggregates below.

The status object carries three members:

MemberWhat it is
db_okWhether the database answered the health check.
migrations_okWhether the database carries this build’s migrations.
postgres_majorThe PostgreSQL major version, for example 18. It is left out when the database did not answer.

Earlier releases send version 1 of the format, or version 2 with a fourth status member, spec_profile, and the AQL series in the daily metrics, as described in that release’s book.

A start report, formatted here for reading (the server sends it on one line):

{
  "schema": 2,
  "event": "start",
  "first_start": true,
  "product": "ferroehr",
  "instance_id": "5f0c6b2e-3d4a-4c8b-9e1f-2a3b4c5d6e7f",
  "version": "4.3.4",
  "git_sha": "1a2b3c4d",
  "licence": "non-commercial",
  "deployment": "helm",
  "uptime_s": 0,
  "cpu_bucket": "3-4",
  "memory_bucket": "8-16",
  "status": {
    "db_ok": true,
    "migrations_ok": true,
    "postgres_major": 18
  }
}

The daily metrics

The daily report adds one object that covers the window since the previous daily report:

"metrics": {
  "window_24h": {
    "requests_bucket": "1k-10k",
    "groups": {
      "composition": {
        "p50_ms": 12.5, "p95_ms": 80.1, "p99_ms": 240.0,
        "errors_5xx": 0,
        "histogram": [10, 40, 120, 300, 90, 30, 8, 2, 0, 0, 0]
      },
      "query": {
        "p50_ms": 60.0, "p95_ms": 310.0, "p99_ms": 900.0,
        "errors_5xx": 1,
        "histogram": [0, 5, 40, 200, 500, 300, 120, 30, 4, 1, 0]
      }
    },
    "counters": {
      "responses_4xx": 42
    }
  }
}
  • requests_bucket: the number of requests the REST API served in the window, as a range: 0, 1-100, 100-1k, 1k-10k, 10k-100k or 100k+. Each range includes its lower bound and excludes its upper one, so 1-100 means 1 to 99.
  • groups: one entry per group of endpoints that served at least one request. A request is placed in a group by the part of the route template it matched below the openEHR API mount (server.base_path followed by rest.base_path), never by the path the client sent, so a rest.base_path such as /ferroehr/ehr/v1 does not move a query into ehr. Routes outside the mount count in no group. The groups are ehr (EHR and EHR_STATUS), composition (compositions and their version history), contribution, query (query execution), definition (templates and stored queries), directory, admin, and other for the demographic, messaging, terminology and FHIR families. The health family, the management surface and the status and discovery documents are counted in no group.
  • p50_ms, p95_ms, p99_ms: the median, 95th and 99th percentile latency of the group in milliseconds, estimated from its histogram.
  • errors_5xx: the responses with a 5xx status.
  • histogram: 11 request counts, one per latency bucket. The bucket upper bounds in milliseconds are 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, and no bound for the last. Counts add up across instances, so a fleet percentile comes from summed histograms.
  • counters: responses_4xx counts the grouped requests answered with a 4xx status. It is present once the window holds one.

What a report never contains

A report carries no patient data, no EHR id or any other id taken from a request, no request path, no AQL text or query parameter, no error message, no audit-log content, no user name, no client IP address, no licence id, no host name and no customer or staff name. The report types in the server have no field that could hold any of these, and the collector refuses a report with a member it does not know.

The collector does record the IP address the report arrives from. See What the collector stores.

See the report before it is sent

ferroehr usage-report --print prints the exact JSON body the instance would send now, and sends nothing. Add --event daily for the daily report; the default is --event start. The command loads the same configuration as the server and connects to the database to read the instance id and the shared window, to which the running replicas add their counts at their daily attempts. It writes nothing. Run it where the server runs:

# Kubernetes (the image has no shell, so call the binary by its path)
kubectl -n ferroehr exec deploy/ferroehr -- /usr/local/bin/ferroehr usage-report --print

# Docker Compose
docker compose exec ferroehr /usr/local/bin/ferroehr usage-report --print --event daily

The JSON goes to stdout on one line. Notes go to stderr: when the database holds no instance id yet, the id shown is a sample, and the first start creates the real one; with the report off, the command says so, and names the CI variable when a CI run switched it off. It works with the report off, and then shows the instance id stored at an earlier start. That id is what you give Cadasto to have stored reports erased (see Your right to object).

Switch it off

Any one of these stops the report:

[usage_report]
enabled = false
FERROEHR__USAGE_REPORT__ENABLED=false

The quickstart docker-compose.yml forwards this variable from your shell, so export FERROEHR__USAGE_REPORT__ENABLED=false before docker compose up switches the report off there too.

# Helm values
usageReport:
  enabled: false

Under the Helm chart the switch is usageReport.enabled (on the command line, --set usageReport.enabled=false). The chart writes it into the rendered configuration together with deployment = "helm", and it refuses to render when config.usage_report.enabled or config.usage_report.deployment is also set.

The other FerroHEALTH products the chart can run report under their own names, and the same switch reaches them:

  • FerroTERM (terminology.enabled) follows usageReport.enabled unless terminology.usageReport.enabled is set, through FERROTERM__USAGE_REPORT__ENABLED.
  • FerroBRIDGE (bridge.enabled) follows it unless bridge.usageReport.enabled is set, through [usage_report] in its config.toml. The chart writes that section for a FerroBRIDGE release from 0.0.7 on; the release it pins sends no report.
  • FerroPIX (ferropix.enabled, on by default) is a dependency chart, and a dependency chart reads only its own values. Its switch is ferropix.usageReport.enabled, and the chart refuses to render usageReport.enabled=false while that stays true, so turning the report off takes both:
helm upgrade --install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
  --set usageReport.enabled=false --set ferropix.usageReport.enabled=false

In a CI run the report is off whatever the switch says: when GITHUB_ACTIONS or CI is true or 1 (in any letter case) in the server’s environment, it sends nothing, and the boot line reads usage report OFF: CI detected (GITHUB_ACTIONS); nothing is sent with the variable it found. A container does not inherit the CI runner’s environment, so a CI job that starts the image sets FERROEHR__USAGE_REPORT__ENABLED=false itself; the collector also hides reports that arrive from GitHub Actions runner addresses from every dashboard.

With the report off, the server sends nothing to FerroPULSE and writes nothing to its database for the report, and the boot line says usage report OFF. Nothing else in the server changes. Switching it off stops new reports only. Reports already stored at the collector stay until the retention period ends, unless you ask for them to be erased.

Network

  • Destination: outbound HTTPS to report.ferropulse.eu, port 443. The endpoint key moves it; the server refuses an endpoint that is plain HTTP (except to a loopback host) or that carries credentials.
  • One direction only: the server reads the status code of the answer and nothing else. It does not follow redirects, and nothing the collector answers changes the server’s configuration or behaviour.
  • Failures stay local: a send gives up after 5 seconds without a connection or 10 seconds in total. A failure, and a collector that refuses a report, is logged once at WARN. Boot, the health and readiness probes, and request handling never wait on the report, so a slow or unreachable collector affects none of them. A daily report that does not reach the collector puts its counts back in the database, releases its claim, and is tried again an hour later. A report the collector refuses is not resent.
  • NetworkPolicy egress: with the chart’s networkPolicy.egress.enabled on, the pod needs a rule in networkPolicy.egress.rules that admits report.ferropulse.eu on port 443. A NetworkPolicy cannot match a DNS name, so the rule is an ipBlock; see Egress. Without the rule the report logs a warning and nothing else is affected. If you do not want the report, switch it off: a blocked report is still attempted at every start and every day.

Privacy notice

This section gives the information that Articles 13 and 14 of the GDPR list, for the personal data a report and its stored record can contain. A report names no person. It can still relate to one: the collector stores the address each report comes from, and an instance run by one person, such as a practitioner on their own internet connection, can be linked to that person through the address or through the instance id. Where that is so, the report is personal data, and this notice applies to it.

This page is also part of the user information Cadasto supplies with FerroEHR under Annex II of the Cyber Resilience Act: it names the outbound connection, what it carries, and how to switch it off.

Controller

Cadasto B.V., the Netherlands. Write to info@cadasto.com or use https://www.cadasto.com/contact/. Cadasto has not appointed a data protection officer.

Purposes and lawful basis

Cadasto processes the reports for three purposes:

  • to know which FerroEHR versions run in the field, so it can tell how many installations a security release concerns and whether they upgrade;
  • to know how many installations run under each licence grant type;
  • to see how the product performs in real use, so it can find and fix slow paths.

The lawful basis is Art. 6(1)(f) GDPR, legitimate interest. The interest is Cadasto’s, as the maker of FerroEHR: keeping the product it publishes secure and maintained, which needs the versions in use and the reach of each security release, and improving it, which needs the licence mix and field performance. The data come from the FerroEHR instance itself. You are not obliged to send them: the report can be switched off, and switching it off changes nothing else in FerroEHR.

What the collector stores

  • Every report as it arrives, with the fields listed above.
  • With each report: the time of receipt, the UTC day, the source IP address (IPv4 or IPv6), and the country derived from that address.
  • Per instance id: when it was last seen, its current version, and whether it is alive.
  • Per day: latency totals summed across the whole fleet, without instance ids.

The country comes from an IP-to-country database bundled with the collector, so no address leaves the collector host for the lookup. IP geolocation by DB-IP, licensed CC BY 4.0. To limit request rates, the collector also keeps a short counter per source address in memory, which is lost when it restarts.

Recipients and location

Hetzner Online GmbH (Gunzenhausen, Germany) hosts the collector in its cloud and processes the reports as Cadasto’s processor, under a data processing agreement Cadasto has signed (Art. 28 GDPR). The collector runs in Hetzner’s data centre in Falkenstein, Germany. The FerroPULSE dashboard that shows the reports sits behind a GitHub login with no public or anonymous access; only members of the FerroHEALTH organisation can sign in.

Retention

A daily job deletes reports and start reports 13 months after they were received, an instance record 13 months after the instance was last seen, and the fleet-wide daily totals 13 months after their day. Backups of the collector’s database are kept for 14 days, so a deleted report can remain in a backup for at most 14 days more.

Your right to object

Important

You have the right to object at any time, on grounds relating to your particular situation, to the processing of reports that are personal data concerning you (Art. 21(1) GDPR).

To stop future reports, switch the report off as described in Switch it off. No request to anyone is needed.

To have the reports already stored erased, write to info@cadasto.com with the instance id that ferroehr usage-report --print shows. Cadasto then deletes every report, start report and instance record stored for that instance id, source addresses included (Art. 17(1)(c) GDPR). Switch the report off first, or the next report starts a new record.

The fleet-wide daily totals hold no instance ids, so an erased instance’s share in days already summed stays in those totals.

Your other rights

At the same address you can ask for access to the reports stored for your instance id (Art. 15), for their rectification (Art. 16) or erasure (Art. 17), or for restriction of their processing (Art. 18). You have the right to lodge a complaint with a supervisory authority, in particular in the Member State of your habitual residence, place of work or place of the alleged infringement (Art. 77).

Operators in Switzerland

Under the Swiss Federal Act on Data Protection (DSG Art. 19 Abs. 4), these are the states your report’s data are disclosed to: Germany, where the Hetzner data centre in Falkenstein runs the collector, and the Netherlands, where Cadasto B.V. is established. Both are listed in Annex 1 of the Swiss Data Protection Ordinance (DSV) as states whose legislation provides adequate protection (DSG Art. 16 Abs. 1).

Returning to the original state

This page resets a FerroEHR instance to the state its release shipped in: the configuration the release ships, and an empty database carrying the schema that release’s migrations create. Nothing else survives a reset. EHRs, compositions, templates, parties, the party-to-EHR map, the audit trail and the instance id the usage report sends are all gone, and the next boot starts as a fresh install.

Before you reset

Warning

A reset deletes the access log along with the records it describes. If the instance has ever held real personal data, the audit trail is subject to the retention floor of every jurisdiction you operate in (three years in every EU Member State, five in the Netherlands, one in Switzerland; see Audit), and your duty to keep it does not end when the instance does. Take a backup of every domain first, the audit trail included (Backup and point-in-time recovery), and keep it for that horizon.

A reset does not reach anything outside the instance. Your logical dumps, WAL archives, volume snapshots, log collector and any external Audit Record Repository still hold what they held. Deleting those is a separate decision with the same retention duties. To take the instance out of service for good instead of starting it again, follow Decommissioning.

The reset has two halves, and you need both:

  • Configuration: the server reads its built-in defaults, then a ferroehr.toml, then FERROEHR__… environment variables, then --set (Configuration reference). The original state is the release’s own file and no overrides.
  • Database: drop the database and let the release’s migrations build it again. Drop the whole database, never single schemas (Recovering a partially wiped database explains why).

The deployment_profile the release ships is sandbox, so a reset instance says on its boot banner and on GET /ferroehr/rest/status that it must not hold real patient data. Set deployment_profile = "production" again before it does.

Docker Compose

The quickstart keeps the database in the named volume ferroehr-pgdata (and, with the s3 profile, multimedia in ferroehr-seaweedfs), and its server configuration inline in docker-compose.yml.

  1. Stop the stack and delete its volumes:

    docker compose down --volumes
    

    --volumes removes the named volumes the file declares (docker compose down). Run it with the same -f overlay files and --profile flags you started the stack with, so it finds every service.

  2. Restore the configuration. Replace docker-compose.yml with the copy attached to your release on GitHub Releases, and remove every FERROEHR__…, DATABASE_URL and RUST_LOG variable you set, from your shell and from any .env file next to the compose file:

    env | grep -E '^(FERROEHR__|DATABASE_URL=|RUST_LOG=)'   # expect no output
    
  3. Start it again:

    docker compose up -d --wait
    

    The database image provisions the domain roles on the empty volume, and the server applies its migrations at boot.

  4. Read the result back. The effective configuration should be the file’s and nothing else:

    docker compose exec ferroehr /usr/local/bin/ferroehr config check
    

Kubernetes and Helm

The chart deploys no database, so the two halves are separate operations.

  1. Remove the release, so no pod holds a connection:

    helm uninstall ferroehr --namespace ferroehr
    

    This keeps the Secrets and PersistentVolumeClaims you created yourself, such as the DSN Secret and the backup claims.

  2. Drop and recreate the database, as a role that may (the bootstrap superuser or the database owner), connected to a different database:

    DROP DATABASE ferroehr WITH (FORCE);
    CREATE DATABASE ferroehr OWNER ferroehr;
    

    WITH (FORCE) terminates the sessions still connected (DROP DATABASE). Use the owner your deployment provisioned the database with. If [storage.party], [storage.linkage] or [storage.audit] name databases of their own, drop and recreate each of those too. The domain roles are cluster-wide objects, so they survive the drop, and the migrations reuse them.

  3. Install the chart again with only the values every deployment must supply (the posture, the DSN Secret and an authentication mechanism), and none of your other overrides:

    helm install ferroehr oci://ghcr.io/ferrohealth/charts/ferroehr \
      --version <your chart version> \
      --namespace ferroehr \
      -f minimal-values.yaml
    

    The schema is applied at boot (config.db.migrate: apply, the chart default), or by the migration Job when you enabled it.

The Kubernetes deployment probes run these three steps against a real cluster: the release is removed and the DSN Secret survives, the database is dropped and recreated by the bootstrap superuser, and the reinstalled release migrates the empty database and no longer finds an EHR written before the reset.

A single binary

  1. Stop the server.
  2. Drop and recreate the database as in step 2 of the Kubernetes procedure.
  3. Restore the configuration: replace your ferroehr.toml with a fresh copy of app/ferroehr/assets/ferroehr.default.toml (or delete it, which leaves the built-in defaults), and unset every FERROEHR__…, DATABASE_URL and RUST_LOG variable except the database DSN.
  4. Prepare the schema with ferroehr db migrate, or start the server with the default db.migrate = "apply".
  5. Check the effective configuration with ferroehr config check.

How the procedure is tested

The deployment probe harness (scripts/deploy-probe.sh, its original_state family) runs the Docker Compose procedure above. It writes an EHR, resets the stack as steps 1 to 3 describe, and checks that the volume was deleted, that the EHR is gone, and that the server reports the shipped configuration. The Kubernetes and single-binary procedures are not exercised by any probe.

Decommissioning

This page takes a FerroEHR instance out of service for good and removes what it stored. ferroehr db erase deletes the multimedia blobs and every schema the migrations created, which takes every EHR, composition, template, party, the party-to-EHR map, the audit trail, the stored settings and the instance id with it. The rest of the page lists what the command cannot reach and how you remove it yourself.

To start over with an empty instance of the same release instead, follow Returning to the original state.

Before you erase

Warning

The erase cannot be undone. Nothing in FerroEHR keeps a copy.

You are the controller of what the instance holds, and the law may require you to keep some of it after the instance is gone. Settle these before you run the command:

  • Clinical records. Medical-records law sets retention periods measured in years after the last treatment or after death. If the records must survive the instance, move them first: export each EHR over the API, or take a full dump through /ferroehr/rest/extensions/v1/admin/dump (dump and load), and check the copy in its new home before you erase. The retention marks and what is already due are described in Retention, restriction and objection.
  • The audit trail. Access logs have a retention floor of their own: three years from each access in every EU Member State, five in the Netherlands, one in Switzerland (Audit). The erase deletes the audit schema. Take a dump of it first and keep that dump for the longest period that applies to you (Backup and point-in-time recovery). A dump kept for this reason is still personal data and needs the same protection as the instance had.
  • Data subjects and processors. If the instance served other organisations or fed other systems, tell them before the data goes.

Stop every server

Stop every FerroEHR server and job that connects to the databases: the replicas, the migration Job, the backup CronJobs and anything else that holds a connection. A server that keeps running holds locks, and the erase gives up after 30 seconds of waiting for one rather than hang. A server that keeps running could also write a new blob after the blobs are deleted.

With the Helm chart, helm uninstall (see Kubernetes below) stops all of them. With Docker Compose, stop the ferroehr service and leave the database running.

The dry run

Run the command once without --confirm. It erases nothing, prints what an erase would delete, prints the value --confirm must carry, and exits with status 1:

$ ferroehr db erase
`ferroehr db erase --confirm` would permanently delete:
  12 multimedia blob(s) in bucket `openehr-multimedia`
  database `ferroehr`: schemas audit, linkage, party, clinical, ext
instance id: 0b5e3c1e-6f4a-4d1b-9a43-2b7c5f1d8e90
to erase, run: ferroehr db erase --confirm 0b5e3c1e-6f4a-4d1b-9a43-2b7c5f1d8e90
Error: dry run: nothing was erased

The command reads the same configuration the server does (--config, FERROEHR__… and --set), so run it with the configuration of the instance you mean to remove. When the domains live in several databases, it prints one line per database.

The value to confirm with names the instance:

  • The instance id, when the database stores one. The server creates it at the first start with the usage report switched on.
  • The name of the database the clinical domain lives in, when the database stores no instance id, because the usage report never ran.

Reading the value off the dry run is deliberate: you confirm the instance the configuration actually reaches, not the one you think it reaches.

The erase

ferroehr db erase --confirm 0b5e3c1e-6f4a-4d1b-9a43-2b7c5f1d8e90

The command runs in this order:

  1. It lists the blob store. A store that is configured but cannot be reached stops the command here, before any database is touched.
  2. It checks the confirmation. A value that does not match stops the command with nothing erased.
  3. It deletes every multimedia blob FerroEHR wrote to the bucket. These are the objects at the bucket root named by a 64-character lowercase hex SHA-256; any other object in the bucket is left alone.
  4. For each database, in one transaction, it drops the schemas ext, clinical, party, linkage and audit that live there, with DROP SCHEMA … CASCADE. The cascade takes every table, row, function, partition and stored setting in them, and the btree_gist extension installed in ext. A database is either erased whole or left as it was.

It connects as ferroehr db migrate does: on [db] migrate_url (or [db] url when that is unset) for every domain in that database, and on a relocated domain’s own DSN otherwise. That credential must own the schemas, which the credential that ran the migrations does.

If a step fails, fix the cause and run the same command again. Blobs are deleted before the schemas, so a failure while deleting blobs leaves the database whole. A second run after a successful one finds nothing, says so, and exits 0.

A build without the multimedia feature refuses to run against a configuration that names a blob store, because it cannot delete the blobs.

What the erase does not reach

The erase removes what FerroEHR stores in its databases and its bucket. The following are outside them, and removing them is your job.

Backups and dumps

Your pg_dump files, base backups and volume snapshots still hold everything. With the chart’s backup CronJobs (backup.enabled), the dumps are on the PersistentVolumeClaims you named in backup.clinical.persistentVolumeClaim, backup.party.persistentVolumeClaim, backup.linkage.persistentVolumeClaim and, when the audit domain has its own database, backup.audit.persistentVolumeClaim. The chart created none of these claims, so helm uninstall leaves them. Keep the ones your retention duties require and delete the rest.

WAL archives, replicas and standbys

A WAL archive (pgBackRest, WAL-G, archive_command) holds every change the database ever made, including the rows the erase dropped. A streaming replica or standby replays the drop, but a delayed standby, a logical replica or a replica you detached keeps its copy. Expire the archive and remove every replica once your retention period allows.

The databases and the roles

The erase drops schemas, not databases. Drop each database when nothing else lives in it:

DROP DATABASE ferroehr WITH (FORCE);

Run it as the owner or a superuser, connected to another database (DROP DATABASE). Repeat it for every database [storage.party], [storage.linkage] or [storage.audit] names.

The roles are cluster-wide, so neither command removes them: the migrations create ferroehr_migrator, ferroehr_clinical, ferroehr_clinical_reader, ferroehr_party, ferroehr_party_reader and ferroehr_linkage, and you created the login roles your DSNs use. Drop them when no other database on the cluster uses them:

DROP ROLE ferroehr_clinical, ferroehr_clinical_reader, ferroehr_party,
          ferroehr_party_reader, ferroehr_linkage, ferroehr_migrator;

DROP ROLE refuses a role that still owns objects or holds privileges in any database of the cluster.

Kubernetes and Helm

helm uninstall removes what the release created, including the Secret the chart renders from secrets.* values:

helm uninstall ferroehr --namespace ferroehr

It leaves what you created yourself: the Secrets named in the existingSecret values (the DSNs, the migrator DSN, the backup DSNs) and the backup claims. List them and delete each one you no longer need:

kubectl get secret,pvc --namespace ferroehr
kubectl delete secret <name> --namespace ferroehr
kubectl delete pvc <name> --namespace ferroehr

Whether a deleted claim’s volume is wiped or kept depends on its storage class’s reclaim policy (Persistent Volumes). A volume with the Retain policy survives the claim and must be deleted on its own. Delete the namespace last if nothing else runs in it.

Docker Compose

docker compose down --volumes removes the database volume ferroehr-pgdata and, with the s3 profile, the SeaweedFS volume ferroehr-seaweedfs. The dumps the backup profile wrote are in ./backups/ next to the compose file.

Configuration, keys and secrets

Delete the files the instance read: ferroehr.toml, the files the *_file and *_path keys point at (DSNs, TLS keys, the OIDC secret, the signing key and its passphrase, the national-identifier key, the multimedia secret key), the licence token, and any .env file. Remove the FERROEHR__… variables from your service definitions and your secret store. Revoke the credentials themselves where they live: the database passwords, the object-store access key, the OIDC client and the broker accounts.

Systems FerroEHR sent data to

Change events on the message broker, records sent to an external Audit Record Repository or a syslog collector, and the logs and traces your telemetry backend received are held by those systems. Remove them there, under the same retention duties.

The physical media

Dropping a schema or deleting an object frees the storage; it does not overwrite it, and a disk, a cloud volume or a bucket can still yield the bytes until they are reused. Sanitising the media (cryptographic erasure of an encrypted volume, the provider’s deletion guarantees, or destroying the disks) is outside what FerroEHR can do. Encrypting the volumes from the start (TLS and database security) is what makes cryptographic erasure possible at the end.

Removing a single party

The openEHR Admin API in ITS-REST defines deletion routes for EHRs only. FerroEHR adds one of its own for a party: DELETE /ferroehr/rest/extensions/v1/admin/party/{party_id} runs the specification’s physical_party_delete operation, removing the party and every PARTY_RELATIONSHIP that names it, with all their versions. It sits behind the admin API switch and the admin role, like the EHR delete, and leaves the same access record. This is the route for an erasure request about one person’s demographic record (GDPR Art. 17): Physical deletion.

The other two ways to remove a party:

  • DELETE on the demographic API removes a party logically: a new version marks it deleted, and its history stays.
  • ferroehr db erase removes every party along with the rest of the instance.

How the procedure is tested

The integration suite of the ferroehr crate writes an EHR with a composition, a party, a party-to-EHR mapping, an audit record and an instance id into a database, checks that a wrong confirmation changes nothing, erases, and checks that none of the five schemas, and so no row, remains and that a second run is a no-op. A second test deletes blobs from an in-memory store, and a third shows that an unreachable store stops the erase before the database is touched. The command has not been run against a real S3 bucket, a multi-database layout or a Kubernetes cluster by any probe.

Conformance

FerroEHR makes a measured claim: it is an openEHR-spec-conformant Clinical Data Repository, and that claim is backed by a test run you can reproduce. This chapter explains what conformance means here, how to run the suite against FerroEHR, and how to read the artefacts it produces.

What is measured

Conformance is checked by Veredictum, a separate CNF 2.0 reference runner. It lives in its own repository, has the same maintainer as FerroEHR, and is consumed here at a pinned version. It is a data-driven interpreter over a committed, machine-readable catalogue authored from the openEHR Conformance framework itself: protocol-neutral case cores anchored on the official platform test schedule (case ids follow the schedule’s own naming, e.g. I_EHR_SERVICE.create_ehr-main), per-operation bindings mapping every outcome to its cited wire expectation, closed vocabularies for outcomes/selectors/captures, a provenance-stamped corpus (the official openEHR Robot data sets re-adjudicated to spec-text-only evidence), and a typed ambiguity register; a specification silence is never resolved privately. Every expectation traces to specification text, never to any server’s observed behaviour. The catalogue spans the schedule’s chapters:

ChapterScope
EHR / EHR_STATUSEHR service and status operations
COMPOSITION / CONTRIBUTION / DIRECTORYClinical content, change sets, folder trees
DEFINITIONADL 1.4 + ADL 2 template and stored-query provisioning
QUERYAQL query execution with committed result-set grounds
CONTENTReference-Model and archetype-constraint accept/reject tables
DEMOGRAPHIC / ADMIN / MESSAGINGParty, admin, and messaging services
SYSTEMThe OPTIONS capability/conformance manifest
SFSimplified formats — FLAT and STRUCTURED commit/read, context, examples
SEC / SIGAuthenticated access, authorization separation, audit accountability, and version signing in both depths
SMARTSMART App Launch discovery and resource-scope enforcement
PERFThe 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 development profile, RM 1.2.0; the composed stack takes the profile from configuration, so a stable-profile run is the same catalogue against the released generation). The upstream Robot suites are reference material; their official data fixtures enter the corpus only as provenance-stamped re-adjudications.

The current result

The whole conformance story in one picture: every capability of the claims matrix, grouped by profile tier, colored AND glyph-marked by the evidence its cases produced (both charts are generated from the committed runner artefacts and regenerate-and-diff guarded in CI; no number on them is hand-typed):

The same run broken down two levels deep: a header per schedule chapter with its total, then one bar per band, the surface a case actually exercises (EHR resource, EHR_STATUS, COMPOSITION, …), with the exact outcome counts printed beside every row. Every band the taxonomy declares is drawn, so one with no case for this run shows as an explicit no cases row rather than disappearing, and a hatched segment marks cited-N/A so it reads as neither a pass nor a failure:

The published run against FerroEHR reports:

  • 1145 case-by-format executions: 1104 passed, 0 failed, 0 inconclusive, 41 not applicable with a machine-readable citation (run of 2026-10-30).
  • Profile verdicts — Core: PASS. Standard: PASS. Options: PASS. Security (SEC-BASIC): PASS.
  • 42/43 capabilities satisfied (passed, or excused by a schedule-registered ambiguity — an unrealizable wire on this technology profile is an explicit scope exclusion, never a silent pass).

Cases that did not execute are not-applicable with a machine-readable citation (an unrealized wire on this technology profile, an undeclared option branch, or a ground a shared server cannot establish), never silent omissions. Options aggregates optional capabilities under the Profiles book’s “any passes” rule.

The party set

The runner is a separate project with its own release line, so the instrument and the system it judges do not share a build. It assesses any openEHR CDR reachable over HTTP; this repository runs it against FerroEHR alone. What it reads about FerroEHR is the party set under docs/conformance/party/ferroehr/:

  • statement.json claims the capability folders of the catalogue FerroEHR serves and selects one arm of every option the catalogue leaves to the implementation (canonical XML on each resource, the Simplified Formats, version signing, the administrative delete, and the others). Each arm states what the server does, and veredictum validate --statement holds the file to the published statement schema and the catalogue before a run.
  • ixit.json describes the deployment: the system_id the server stamps into commit audits and the version ids it mints, the environment a measured run happened on, and exclusive_server, which lets the workflows that delete every EHR run.

Credentials are in neither file. The run reads them from the environment.

Running the suite yourself

The suite runs against a real, deployed server (the same container image and stack a deployment uses), so the wire under test is always the production artefact. From a checkout with Docker or Podman available:

bash scripts/conformance.sh

The script reviews the statement, composes one FerroEHR deployment from the current sources on fresh volumes, runs veredictum test through the pinned release image with the Basic users of docker/ferroehr.dev.toml, writes results.json, recording.har and the JUnit, CTRF and Markdown views export renders to docs/conformance/ferroehr/, and tears the stack down. The recording holds the server’s response bodies, so it is never committed.

Useful knobs: CONF_DOCUMENTS to narrow the run to named documents (a working tool, never the record), CONF_OUT for a different artefact root, CONF_NO_COMPOSE with CONF_BASE_URL to run against an already-deployed FerroEHR, SKIP_BUILD to compose a published image instead of building from source, and CONTAINER_ENGINE to choose between Docker and Podman.

The script also takes one stage: review runs veredictum validate --statement alone, up composes the deployment and leaves it serving, and down removes it with its volumes. The Conformance workflow uses these stages to run the same deployment in CI. It builds FerroEHR from the commit, runs up, and drives the catalogue through the Veredictum GitHub Action, pinned by commit SHA, on the same release image by digest. The job fails on a failed or errored row, puts the Markdown summary on the job summary, and keeps the results, JUnit and CTRF views as an artifact. It runs on pull requests that touch the server, the party set, the conformance compose files or the workflow, on main, on a release tag and twice a week.

The posture a run covers

The one deployment runs the SMART resource-server role with a trusted test issuer, digest version signing, and FerroTERM as its terminology server, in the fail-open posture.

  • SMART is part of the posture. The tokens the SMART documents present are signed by a committed test issuer: public test key material for the harness, never usable for anything else.
  • External terminology is part of the posture too. An archetype can constrain a coded element to a value set only an external terminology query server can resolve, so the pipeline composes a real FHIR R4B server beside the CDR, seeded with synthetic test code systems and value sets. That covers AQL TERMINOLOGY() resolved through the routed server, and commit-time validation of a bound value set. What a deployment does when the value set cannot be resolved at all is not decided by any openEHR text, so it is a declared option arm rather than a verdict.

A measured performance or stress run adds one more posture: rate limiting is turned off for the duration, because the instruments deliberately offer load past the server’s knee and a throttled request would measure the limiter instead of the server. Both instruments refuse to write a record if the server answered any 429, so a measurement can never be silently limiter-shaped.

Reading the artefacts

A run writes machine records and three human-readable documents to docs/conformance/<sut-name>/. Each has a distinct job.

The machine records

results.json is the party results record: one outcome per case with its rows-driven coverage, failing step and reason where applicable, and the excusing citation for every not-applicable entry, alongside the system-under-test identity, the runner’s verification-pack status, the technology profile, and the ixit digest. verdicts.json is the computed verdict report, and run-exceptions.json registers anything the interpreter itself could not cover. Every other artefact (the three documents, the badges, the charts) is generated from these; nothing downstream is hand-edited.

The conformance report

CONFORMANCE_REPORT.md is the honest, scoped record of this run: the system under test, the outcome counts, the per-capability evidence rollup, the machine-computed profile verdicts, and every not-applicable entry with its excusing citation. Read this when you want to know exactly what happened and why any case did not run.

The conformance statement

CONFORMANCE_STATEMENT.md is the concise, generated claim: the supported specification versions, the declared external data formats (JSON and XML), and the profile results. Every line is a pure function of the machine verdicts, so the statement can never claim more than the run proves.

The conformance certificate

CONFORMANCE_CERTIFICATE.md follows the structure of the openEHR conformance certificate template: the system under test, the scope of test, and a per-capability profile report showing which capabilities are required in each profile, what each was verified against, and whether each passed. The Realization column separates capabilities verified over released ITS-REST operations from any verified over routes a product serves of its own design — the latter never gate an openEHR profile tier. Where the certificate carries a measured run, its Workload Coverage table additionally shows which claimed capabilities the hospital-simulation load actually exercised; a capability the simulation does not reach must carry an adjudicated exclusion, printed with its reason, and the runner’s validation gate refuses an artefact tree that leaves such a row undecided. It always identifies itself as a framework assessment with the claim computed from the attached run; it is never an official openEHR certification. This is the document to hand to a procurement or evaluation reviewer who wants the capability-by-capability picture.

Tip

The conformance badges in the project README are generated from the same run and carry the measured amounts (per-profile capability counts, the overall driven-case count, the earned performance class). A badge can never show a pass unless the machine verdict does, so a green badge is a claim you can immediately reproduce with scripts/conformance.sh.

What conformance does not cover

The catalogue measures the openEHR platform surface, including the simplified (FLAT/STRUCTURED) formats chapter of the ITS-REST specification. It deliberately does not stand in for a performance benchmark: durations recorded during the functional run are telemetry only, and the measured classes own that claim (Performance).

The other honest boundary is the gap between openEHR’s service model and its released REST wire. Several service operations were never surfaced as endpoints (listing an EHR’s contributions, counting stored templates or queries, deleting a template or a stored query) so a case that addresses one has no wire to drive on this technology profile. Those cases are excused through the schedule’s typed ambiguity register, with the citation printed in the report, and reported as an explicit scope exclusion on the certificate; never a silent pass and never an unavoidable failure. FerroEHR does serve routes of its own design for several of them (see Admin & messaging APIs and the archetype routes in Templates & validation); those are marked extension on the certificate and never gate an openEHR profile tier.

Performance

FerroEHR applies the same discipline to performance that it applies to conformance: a class is a verdict a server earns by measurement on a stated environment, or does not. The performance chapter of the CNF suite runs an open-loop clinical workload at a published offered-load floor, records the result as a re-checkable histogram, and lets the verdict pipeline recompute (earned or not earned) from that artefact. Nothing on this page is hand-typed; every number comes from the committed measurement records or the generated assets below.

The volumetric class ladder

Performance conformance is graded on a small, closed ladder of deployment classes: proof-of-concept (POC), small (S), large (L), and regional (R). Each class fixes an offered-load floor (the peak API arrival rate the server must sustain), a latency budget (a p99 service-level objective, uniform across classes), and an error budget (zero: a failed request under load is a failed class). A class is earned only when a measured run holds every threshold; a class is never declared.

Crucially, a class verdict is environment-bound: it is meaningful only alongside the hardware, core count, memory, storage class, and topology it was measured on, which the runner records in the measurement’s environment block and stamps into every asset. The same binary earns different classes on different hardware, and the artefact always says which.

Where the floors come from

The offered-load floors are anchored to population, so a class corresponds to a real catchment a deployment might serve. The derivation is a short chain of published, official activity statistics:

  • Clinical documents per person per year. Summing the major encounter types that each commit a clinical document gives roughly forty-six documents per capita per year: primary- and specialist-care consultations (OECD, Health at a Glance 2023), inpatient discharges (OECD/Eurostat hospital discharge statistics), emergency-department visits (OECD emergency-care indicators), laboratory reports (Royal College of Pathologists activity data), diagnostic-imaging events (NHS England Diagnostic Imaging Dataset, 2023/24), and dispensed prescriptions (NHS Business Services Authority Prescription Cost Analysis, 2024/25). The result sits between the major-document exchange rates Denmark and Estonia report and Finland Kanta’s all-inclusive figure, which is the sanity check that keeps it arguable.

  • Average write rate. Multiplying a class’s served population by that per-capita rate and dividing by the number of seconds in a year gives the average sustained document-write rate for the class:

  • Busy-hour peak. Real clinical traffic is not flat: it concentrates in ward rounds and clinic hours. Following the ITU-T E.500 busy-hour engineering convention, the average is scaled to a busy-hour peak by a peak factor of eight:

  • Read multiplier. A CDR is a read-heavy OLTP system: charts are read far more often than they are written. Following the read-heavy OLTP convention used by standard database benchmarks (YCSB, OLTP-Bench), the offered load applies a read-to-write ratio of 10:1 on top of the write rate; audit-log evidence of how many record interactions one encounter actually generates bounds the read-heavy band from above.

The floors that fall out of this chain are the published defaults the runner enforces; the concrete rates per class are carried in the class ladder above and the summary table below, never re-typed into this prose.

The durability floor — what a single write can never beat

Every committed version is durable: the transaction’s WAL records are flushed to disk before the server answers, so an acknowledged commit survives a crash. That flush (one fsync on the WAL device per commit) is a physical lower bound on single-client write latency, and no storage design takes a lone sequential client below it. On a laptop-class Docker setup the flush alone dominates a single write’s budget; on server NVMe it is typically an order of magnitude smaller. FerroEHR’s optimization target is therefore everything around the flush: the database’s own per-commit work (one folded commit statement, one merged placement read) stays far below the flush itself. That is also what makes concurrent throughput scale: PostgreSQL group-commits, amortizing one flush across every transaction that reaches it in the same window.

The knob behind this boundary is PostgreSQL’s synchronous_commit. Setting it off makes commits return before the WAL flush: single-client write latency drops to the statement work alone, and a server crash can lose the most recent acknowledged commits (up to three times wal_writer_delay, per the PostgreSQL documentation). The database stays consistent; the tail of acknowledged writes is what is at risk. FerroEHR never defaults this off and does not recommend it for clinical data. It is an operator decision, made per deployment on the database side, defensible only where the record of loss is acceptable (a load-test rig, a reseedable sandbox, an analytics replica). All published FerroEHR numbers are measured with full durability on.

The hospital simulation

The measured workload is not a flat operation mix: it simulates a hospital, end to end. Load arrives as clinical journeys (ordered, time-offset operation sequences drawn from a committed journey catalogue):

  • ADT flow: an admission creates the EHR, sets its status, commits the admission problem list and summary, and opens the per-episode directory folder tree; a discharge writes the discharge summary and closes the episode out.
  • Monitoring: nursing observation rounds commit vital-signs documents at ward cadence.
  • The medication loop: an order is followed by scheduled administration commits at the drug-round interval; medicines reconciliation reads the standing medicines list and amends it as a new version.
  • Order → result pipelines, asynchronous: a laboratory or imaging request is committed at one instant and its result lands as its own arrival after a realistic turnaround drawn from the catalogue; the ordering clinician’s chart review follows later still. Nothing ever blocks on anything else.
  • Clinical review: ward-round chart reads (at version, current, and the revision history), per-patient AQL trends, cross-EHR ward worklists, and a registered stored query executed continuously.
  • Governance: versioned amendments, the occasional logical delete, contribution inspection (the audit trail’s read side), and workflow tagging of hot documents.
  • The platform surface: template listing and retrieval (the integration-engine poll), specialist synoptic reports, registry submissions, and statutory public-health notifications.

Every stage of every journey instance is its own planned arrival instant on the global open-loop schedule (an order at , its administrations at , the result at ) so many patients’ journeys interleave exactly as wards do, and cross-operation state effects (status transitions during active commits, folder consistency under parallel writes, version chains under interleaved amendments, AQL against a mutating corpus) are exercised under load, which a flat four-operation mix can never reach. A dependent stage whose prerequisite has not landed when its instant fires (a stalled server) records honestly as an error; that is the measurement.

The journey payloads commit against published openEHR CKM templates (vital signs, laboratory results, ePrescription, medicines list, problem list, the International Patient Summary, imaging and cancer synoptic reports, registry and public-health forms), vendored with provenance and committed as byte-identical example skeletons so every measured server receives exactly the same bytes.

The envelope stays population-anchored. The derivation above still fixes the aggregate operation arrival rate (the class floor) and the read:write ratio; the journey catalogue only decomposes those totals into many more operation kinds. Each journey cites the activity statistic that grounds its shape (the same register the floors derive from) and the runner’s artefact validator recomputes the expansion on every load: the catalogue-expanded write share must reconcile to the derivation’s read-heavy band (between the 10:1 floor convention and the audit-log-evidenced ceiling), so the mix stays arguable, never arbitrary.

For the extended eight- and twelve-hour holds, the schedule can follow a diurnal day curve (morning and afternoon peaks, shift-change bumps, a night-time trough) applying the same ITU-T E.500 busy-hour convention the peak factor cites: the class floor is then the busy-hour rate, and the off-peak troughs are the design, not a shortfall.

Finally, the conformance certificate prints the workload coverage: the set of claimed capabilities the simulation actually exercised, joined against the claims matrix. A claimed capability the hospital never touches must carry an adjudicated exclusion printed with its reason: a design-time operation like archetype provisioning is not a sustainable per-patient arrival, and saying so beats pretending the load reached it.

How a measured run works

A performance run is deliberately open-loop: the runner plays a seeded arrival schedule (request is due at a planned instant computed before the run starts) rather than a closed loop of virtual users that would slow its own offered load down whenever the server stalls. This makes the run coordinated-omission-free: each request’s latency is measured from its planned arrival instant, so a server that pauses cannot hide the queue it built up behind a handful of fast replies.

A run has two phases: a discarded warmup window (caches fill, pools warm, the JIT of a compared server settles) followed by the sustained measurement window at the class’s offered-load floor. Latencies are accumulated into per-operation HDR V2 histograms, which are base64-encoded verbatim into the measurement record in results.json. Because the full histogram is embedded (not just a handful of pre-computed percentiles) any consumer can re-derive every percentile and re-check every threshold from the committed artefact. The class verdict itself is then recomputed by the verdict pipeline from those records: the runner never writes a verdict it cannot reproduce from the data.

The measured corpus is seeded strictly through the public write path (create EHR, commit composition) and never a database backdoor, so what the run reads is exactly what the server’s own write path produced. The database’s maintenance debt is settled deterministically, and identically for every system under test, outside the measured windows, so neither a stale-statistics plan nor an autovacuum firing mid-window ends up in the record.

Reproducing it

The measured run is a stage of the conformance pipeline. Selecting a class seeds the matching scale corpus, plays the open-loop schedule against the composed server, and merges the measurement records into results.json:

# seed the class corpus, run the open-loop schedule, merge the record
CONF_PERF_CLASS=POC bash scripts/conformance.sh

# hold the same offered load for longer — the hours ladder is 1 (the case's
# normative window and the default), 2, 4, 6, 8, or 12
CONF_PERF_CLASS=POC CONF_PERF_HOURS=8 bash scripts/conformance.sh

There is deliberately no shortened run: the measurement record always covers at least the case’s normative window, so nothing sub-normative can ever be mistaken for a measured result. A longer hold is a stricter demonstration of the same class, not a different one. The record carries the actual warmup and window it held, and the verdict machinery re-derives everything from the embedded histograms. Every run seeds a freshly composed server from empty; there is no seed reuse, so no run ever measures another run’s leftovers.

The runner can also be driven directly against a running server, which is how you point it at a deployment the pipeline does not compose. Its perf subcommand takes the catalogue root, the ixit topology file (whose environment block is mandatory: a measurement without the deployment described is not a measurement), the results.json to merge into, and the class:

veredictum perf --root <catalogue-root> --ixit <ixit.json> \
                --results <results.json> --class POC --hours 8

Stress testing — the second instrument

Beside the class runs sits a deliberately different instrument: a step-load stress test. Where a class run holds a real-life, population-anchored rate for at least an hour, the stress test climbs a geometric ladder of short, intense load steps (about two minutes each, doubling the offered rate every step) until the system leaves the stress envelope, then bisects between the last stable rate and the breached one to locate the boundary precisely, the point performance engineering calls the knee of the latency-throughput curve. The headline it finds is the maximum sustainable throughput: the highest offered rate held inside the latency budget (the same idea TPC benchmarks report as maximum qualified throughput).

veredictum stress --root <catalogue-root> --ixit <ixit.json> \
                  --out <stress.json> --corpus-class POC

The two instruments never blur. A stress report earns no class, never touches results.json, and carries no class vocabulary at all; the class ladder belongs to the measured class runs, and the stress chart shows one thing only: where the system breaks. Their envelopes differ on purpose too: a conformance class demands a zero error budget, while a stress step allows the small error tolerance standard load testing works with, because the instrument’s job is to find the knee rather than to certify anything. Every load step embeds its own re-checkable histograms and its own resource telemetry (the same per-container CPU/memory/I/O series the measured runs record, so a breached step shows where it saturated), a breached step is reported with the exact envelope violation, and a step where the load generator topped out before the server is flagged as such rather than counted against the system.

The optimization probe

Between the two verdict-bearing instruments sits a third, purely diagnostic one: the AQL probe. It seeds the same class corpus fresh, fires the measurement machinery’s own AQL set repeatedly, and records each query’s wire-latency percentiles alongside the database-side cost per SQL statement, so an optimization is argued from attributed evidence on a realistically seeded database, never from a hunch on an empty one. Its report is exploration evidence for the optimization loop: it earns nothing and never touches the conformance record.

veredictum aql-probe --root <catalogue-root> --ixit <ixit.json> \
                     --out <aql-probe.json>

The published assets on this page are rendered from the committed results.json by the runner’s perf-assets subcommand (wrapped by scripts/render/perf-assets.sh); the docs CI job re-renders and diffs them, so a hand-edited or stale asset fails the build.

The storage benchmark harness

A fourth instrument measures the storage layer directly, below the wire:

STORAGE_BENCH_CLASS=poc cargo bench -p ferroehr --bench storage

It seeds a corpus through the ordinary write path, then times the storage layer’s hot paths one at a time: a composition commit, a supersession, the supersession that states the version it replaces, a point read by version uid and one by versioned-object uid, the version at an instant, the revision history, an AQL CONTAINS chain over one EHR and over the whole population, archive and restore of an EHR, and one retention prune.

Beside each operation’s wall-clock it records what the database did for it. The per-relation tuple counters (inserted, updated, updated in place, dead, live) and the relation sizes come from pg_stat_user_tables, the buffer hits and misses from pg_stat_database, and the write-ahead-log bytes from the WAL position either side of the measurement. One representative commit is probed on its own, and the population query is explained with EXPLAIN (ANALYZE, BUFFERS, WAL) inside a transaction that is rolled back, so the plan shape and its index choices land in the record too.

Everything runs through the service and the public storage API, and the relations are discovered from the catalogue, so no table name appears anywhere in the harness and the same file measures a rewritten schema. That is the point: a storage change is argued from a before-and-after pair taken with one instrument. It earns no class and touches no conformance artifact; the record’s shape is documented in docs/benchmarks/storage/README.md. Records are local by default, and the committed baseline is what this page renders.

Running it against a committed baseline

A dispatch-only workflow (.github/workflows/storage-bench.yml) runs the same benchmark on a GitHub-hosted runner, against a PostgreSQL 18 service with its durability left on, and compares every operation with the record committed for that schema generation. The dispatch picks the corpus class and the tolerance. The run summary lists each operation with its baseline p50 and p99, the measured pair, the delta and a verdict, and a p50 or p99 that rose further than the tolerance fails the run. Both machines are named in the summary, because a hosted runner is shared hardware and a latency measured there is read against the box it came from.

That lane is exploration, never a conformance record. It earns no performance class, publishes no number, and writes nothing back to the repository: the fresh record leaves as a run artifact, and the committed baseline under docs/benchmarks/storage/<generation>/ moves only in a pull request that says which machine measured it. A generation with no committed record is reported as such and the run passes; a run whose class differs from the baseline’s shows the deltas and judges nothing, since the two measured different corpora.

Schema generation generation-1, class s

OperationIterationsp50p95p99WALBuffer hits
create_first_version1 2003.6 ms5.3 ms5.3 ms47 MiB1 008 028
supersede87011.2 ms14.4 ms14.4 ms37.5 MiB30 795 283
supersede_if_match85011.4 ms14.5 ms14.5 ms36.5 MiB29 699 039
by_version_uid13 640298.6 µs374.6 µs374.6 µs77.3 KiB250 604
latest_by_versioned_object_uid8 550479.9 µs1 ms1 ms456 B121 845
version_at_time7 260597.3 µs640 µs640 µs136 B122 318
revision_history25 230193.9 µs216.6 µs216.6 µs88 B671 513
contains_one_ehr2 8501.7 ms1.8 ms1.8 ms368 B766 648
contains_population3 4401.1 ms1.1 ms1.1 ms0 B1 294 649
archive_ehr9023.3 ms30.3 ms30.3 ms252.7 MiB6 969 118
restore_ehr16024.9 ms69.1 ms69.1 ms400.5 MiB9 276 746
retention_prune25 650166.7 µs185.7 µs185.7 µs104 B35 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.

RelationLive rowsDead rowsUpdatesHOT updatesSize
ehr.audit7 1880001.9 MiB
ehr.contribution6 9880001.3 MiB
ehr.ehr100000112 KiB
ehr.event_outbox6 9880003.9 MiB
ehr.node296 059000271.8 MiB
ehr.template_ref100032 KiB
ehr.template_store1000136 KiB
ehr.vo_archive000040 KiB
ehr.vo_version7 08802 330030.1 MiB

Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit 6ebfdb32b304 on 8 cores against PostgreSQL 18.6.

Schema generation generation-2, class poc

OperationIterationsp50p95p99WALBuffer hits
create_first_version5308.5 ms9.1 ms9.1 ms24.2 MiB395 240
supersede47010.3 ms11.1 ms11.1 ms21.6 MiB905 767
supersede_if_match54010 ms11.2 ms11.2 ms24.1 MiB1 070 922
by_version_uid8 290519.5 µs538.3 µs538.3 µs168 B213 581
latest_by_versioned_object_uid8 630534.3 µs553.9 µs553.9 µs120 B204 101
version_at_time4 290990.6 µs996 µs996 µs0 B135 299
revision_history15 730300.1 µs312.9 µs312.9 µs0 B437 101
contains_one_ehr1 1404 ms4.2 ms4.2 ms128 B605 955
contains_population2 2702.1 ms2.2 ms2.2 ms0 B620 677
flat_2_sibling_leaves21021.9 ms22.7 ms22.7 ms112 B3 639 915
flat_3_sibling_leaves19025.5 ms26.7 ms26.7 ms88 B3 422 117
streaming_2_sibling_leaves8705.9 ms6.2 ms6.2 ms0 B2 524 699
streaming_3_sibling_leaves8006.2 ms6.3 ms6.3 ms0 B2 580 569
contains_exists5808.4 ms8.5 ms8.5 ms0 B6 860 724
node_predicate1 2404 ms4.2 ms4.2 ms0 B2 130 540
where_magnitude11046.4 ms47.3 ms47.3 ms0 B3 399 817
where_coded20023.4 ms25.8 ms25.8 ms0 B3 495 572
where_date_time1 0704.5 ms4.6 ms4.6 ms0 B1 884 349
where_text21023.2 ms24.4 ms24.4 ms0 B3 628 755
wide_projection_one_ehr10053.2 ms55.3 ms55.3 ms56 B464 725
wide_projection_population18029.2 ms32.9 ms32.9 ms0 B3 761 922
select_observation7606.5 ms6.8 ms6.8 ms0 B1 804 991
select_composition_one_ehr8605.5 ms6.7 ms6.7 ms0 B474 289
aql_version_at_time1 2204 ms4.2 ms4.2 ms0 B1 827 733
archive_ehr3091.4 ms101.2 ms101.2 ms52.9 MiB1 038 199
restore_ehr30106.1 ms108.4 ms108.4 ms52.7 MiB1 121 364
retention_prune23 420211.1 µs236.3 µs236.3 µs8.3 KiB33 082

One representative commit wrote 305 KiB of WAL and touched 3 903 buffers. The population CONTAINS statement plans as Nested Loop and reads 43 224 shared blocks.

RelationLive rowsDead rowsUpdatesHOT updatesSize
clinical.commit_audit2 324000632 KiB
clinical.contribution2 284000584 KiB
clinical.ehr2000056 KiB
clinical.event_outbox2 2840001.4 MiB
clinical.node_cold000088 KiB
clinical.node_hot100 33200081.4 MiB
clinical.template_ref200032 KiB
clinical.template_store2000192 KiB
clinical.version_cold0000120 KiB
clinical.version_hot2 3040008.8 MiB
clinical.vo_head1 020603 9483 948312 KiB

Measured over 20 EHRs and 300 compositions of template Vital signs at commit c82f0992d308 on 4 cores against PostgreSQL 18.6.

Schema generation generation-2, class s

OperationIterationsp50p95p99WALBuffer hits
create_first_version5109.7 ms9.9 ms9.9 ms110.9 MiB470 378
supersede5009.9 ms10.4 ms10.4 ms22.8 MiB1 034 920
supersede_if_match54010 ms10.4 ms10.4 ms24.4 MiB1 151 542
by_version_uid8 700488.6 µs493.7 µs493.7 µs2.8 KiB222 174
latest_by_versioned_object_uid9 360489.2 µs512.2 µs512.2 µs128 B218 149
version_at_time4 490946.8 µs992.3 µs992.3 µs56 B146 116
revision_history16 450287.8 µs300.8 µs300.8 µs0 B453 104
contains_one_ehr1 2403.6 ms3.7 ms3.7 ms136 B658 178
contains_population1 7403 ms3 ms3 ms0 B1 020 037
flat_2_sibling_leaves6092.3 ms95.9 ms95.9 ms16.4 KiB33 018 094
flat_3_sibling_leaves6095.9 ms97.8 ms97.8 ms0 B33 030 979
streaming_2_sibling_leaves6607.6 ms7.9 ms7.9 ms0 B2 398 276
streaming_3_sibling_leaves6308 ms8.1 ms8.1 ms0 B2 137 922
contains_exists17030.2 ms34.2 ms34.2 ms0 B11 007 303
node_predicate9305.4 ms5.5 ms5.5 ms0 B2 127 292
where_magnitude50120.1 ms121.4 ms121.4 ms0 B5 504 663
where_coded7206.9 ms7.2 ms7.2 ms0 B2 269 954
where_date_time8206.1 ms6.2 ms6.2 ms0 B1 917 884
where_text7306.9 ms7 ms7 ms0 B2 293 234
wide_projection_one_ehr50114.2 ms115.6 ms115.6 ms0 B1 977 924
wide_projection_population38013.3 ms13.7 ms13.7 ms88 B2 326 270
select_observation6207.9 ms8 ms8 ms0 B1 607 057
select_composition_one_ehr6008.2 ms8.5 ms8.5 ms0 B683 661
aql_version_at_time8006.2 ms6.3 ms6.3 ms0 B6 498 839
archive_ehr20173 ms181.1 ms181.1 ms68 MiB1 320 254
restore_ehr20204.5 ms207.9 ms207.9 ms60.6 MiB1 298 376
retention_prune23 980207.7 µs209 µs209 µs2.1 MiB73 258

One representative commit wrote 297.2 KiB of WAL and touched 960 buffers. The population CONTAINS statement plans as Nested Loop and reads 42 257 shared blocks.

RelationLive rowsDead rowsUpdatesHOT updatesSize
clinical.commit_audit5 4860001.5 MiB
clinical.contribution5 286000960 KiB
clinical.ehr100000152 KiB
clinical.event_outbox5 2860002.9 MiB
clinical.node_cold0000232 KiB
clinical.node_hot252 462000167.6 MiB
clinical.template_ref200032 KiB
clinical.template_store2000192 KiB
clinical.version_cold0000144 KiB
clinical.version_hot5 3347820016.3 MiB
clinical.vo_head3 940134 4304 430936 KiB

Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit c82f0992d308 on 4 cores against PostgreSQL 18.6.

Schema generation generation-3, class poc

OperationIterationsp50p95p99WALBuffer hits
create_first_version8405.8 ms14.6 ms14.6 ms34.8 MiB648 612
supersede61011.4 ms18.9 ms18.9 ms23 MiB13 048 343
supersede_if_match64012.3 ms21.6 ms21.6 ms28 MiB19 143 656
by_version_uid9 920418.8 µs439.7 µs439.7 µs472 B245 293
latest_by_versioned_object_uid11 710404.2 µs428.5 µs428.5 µs0 B301 532
version_at_time4 940874.4 µs886.1 µs886.1 µs0 B151 435
revision_history18 710206.6 µs248.5 µs248.5 µs0 B502 662
contains_one_ehr1 4103.5 ms3.6 ms3.6 ms7 KiB859 578
contains_population2 6901.8 ms1.9 ms1.9 ms8.1 KiB824 801
flat_2_sibling_leaves23019.4 ms20 ms20 ms42.4 KiB5 239 470
flat_3_sibling_leaves21023.4 ms23.8 ms23.8 ms0 B4 934 788
streaming_2_sibling_leaves9805.1 ms5.3 ms5.3 ms3.6 KiB2 960 620
streaming_3_sibling_leaves9005.5 ms5.7 ms5.7 ms0 B3 007 660
contains_exists47010.5 ms11.1 ms11.1 ms0 B8 638 704
node_predicate1 3803.6 ms3.7 ms3.7 ms0 B2 969 000
where_magnitude11047.7 ms48.7 ms48.7 ms0 B5 012 010
where_coded23022.1 ms23.1 ms23.1 ms0 B5 209 927
where_date_time1 2104.1 ms4.2 ms4.2 ms0 B2 292 883
where_text23022.4 ms23.3 ms23.3 ms0 B5 210 914
wide_projection_one_ehr12042.1 ms42.9 ms42.9 ms0 B526 091
wide_projection_population19027.3 ms28.6 ms28.6 ms0 B5 064 088
select_observation8505.7 ms5.8 ms5.8 ms0 B2 121 054
select_composition_one_ehr9904.8 ms5 ms5 ms0 B551 999
aql_version_at_time1 4503.4 ms3.6 ms3.6 ms0 B2 565 200
archive_ehr5051.3 ms54.2 ms54.2 ms87.7 MiB1 896 225
restore_ehr5064.2 ms65.2 ms65.2 ms77.1 MiB1 945 710
retention_prune40 630118.4 µs121.5 µs121.5 µs120 B50 499

One representative commit wrote 532.7 KiB of WAL and touched 1 322 buffers. The population CONTAINS statement plans as Nested Loop and reads 17 067 shared blocks.

RelationLive rowsDead rowsUpdatesHOT updatesSize
clinical.commit_audit3 130000872 KiB
clinical.contribution3 090000640 KiB
clinical.ehr2000064 KiB
clinical.event_outbox3 0900001.8 MiB
clinical.node_cold000088 KiB
clinical.node_hot134 99000089.3 MiB
clinical.template_ref200032 KiB
clinical.template_store2000192 KiB
clinical.version_cold0000136 KiB
clinical.version_hot3 11000012.7 MiB
clinical.vo_head1 458486 3326 332504 KiB

Measured over 20 EHRs and 300 compositions of template Vital signs at commit 59b91af928a4 on 4 cores against PostgreSQL 18.6.

Schema generation generation-3, class s

OperationIterationsp50p95p99WALBuffer hits
create_first_version5607.6 ms8.6 ms8.6 ms21.6 MiB475 947
supersede52012.9 ms16.4 ms16.4 ms21.6 MiB10 171 802
supersede_if_match52013 ms16.7 ms16.7 ms20.1 MiB9 750 360
by_version_uid9 210480 µs495 µs495 µs2.2 KiB226 321
latest_by_versioned_object_uid9 340477.9 µs486.9 µs486.9 µs464 B224 344
version_at_time4 510943.2 µs1 ms1 ms0 B140 807
revision_history17 290268.1 µs271.6 µs271.6 µs0 B471 470
contains_one_ehr1 2503.6 ms3.9 ms3.9 ms0 B695 743
contains_population1 6803 ms3.1 ms3.1 ms15.8 KiB1 115 539
flat_2_sibling_leaves14033.9 ms38.2 ms38.2 ms16.2 KiB4 120 479
flat_3_sibling_leaves14037.9 ms39 ms39 ms232 B4 152 775
streaming_2_sibling_leaves5808.5 ms9.3 ms9.3 ms0 B2 007 543
streaming_3_sibling_leaves5808.7 ms8.8 ms8.8 ms0 B2 152 855
contains_exists20025.6 ms26.5 ms26.5 ms0 B9 295 830
node_predicate8306 ms6.1 ms6.1 ms0 B2 174 425
where_magnitude50105.2 ms106 ms106 ms0 B4 729 616
where_coded7806.2 ms6.6 ms6.6 ms0 B1 888 942
where_date_time7506.8 ms7.1 ms7.1 ms0 B2 001 044
where_text8106.2 ms6.3 ms6.3 ms0 B1 943 864
wide_projection_one_ehr50110 ms111.3 ms111.3 ms0 B1 950 208
wide_projection_population41012.4 ms12.5 ms12.5 ms0 B2 101 466
select_observation5608.7 ms9 ms9 ms0 B1 619 828
select_composition_one_ehr6008.4 ms8.7 ms8.7 ms0 B685 119
aql_version_at_time7106.4 ms7.2 ms7.2 ms0 B5 900 107
archive_ehr20138.1 ms141 ms141 ms65.3 MiB1 558 971
restore_ehr20170.1 ms173.7 ms173.7 ms59.2 MiB1 516 172
retention_prune27 760177.4 µs180.1 µs180.1 µs1.2 MiB71 274

One representative commit wrote 107.4 KiB of WAL and touched 3 784 buffers. The population CONTAINS statement plans as Nested Loop and reads 49 471 shared blocks.

RelationLive rowsDead rowsUpdatesHOT updatesSize
clinical.commit_audit5 4840001.5 MiB
clinical.contribution5 2840001.1 MiB
clinical.ehr10000096 KiB
clinical.event_outbox5 2840003 MiB
clinical.node_cold0000232 KiB
clinical.node_hot254 612000114.1 MiB
clinical.template_ref200032 KiB
clinical.template_store2000192 KiB
clinical.version_cold0000160 KiB
clinical.version_hot5 38400018.1 MiB
clinical.vo_head3 9901044 9584 958968 KiB

Measured over 100 EHRs and 3 000 compositions of template Vital signs at commit 59b91af928a4 on 4 cores against PostgreSQL 18.6.

The latest measured run

The per-operation percentiles below are re-derived at build time from the committed HDR V2 histograms for the proof-of-concept class:

What the run cost the machine

Alongside the latencies, every measured run records its resource telemetry: CPU and resident memory for the server and database containers separately, plus block-device and network I/O, sampled at a fixed interval across the whole window with the warmup shaded. These numbers are capacity-planning context; they never influence whether a class is earned.

The database volume’s on-disk size is probed at four anchors: before the scale seed (the empty baseline), after it, after the standing-ward seed, and after the measured window drained. The first two give the storage cost per committed composition; the last two give the sustained load’s write amplification. An anchor that could not be probed is honestly absent rather than guessed:

Both charts render only from a committed measurement record; nothing on this page is ever mocked.

ClassCorpusOffered-load floorp99 budgetError budgetMeasured sustainedVerdict
POCcnf.scale.10k2/s≤ 1000 ms02.0/sEARNED
Scnf.scale.100k15/s≤ 1000 ms0—not measured
Lcnf.scale.1m150/s≤ 1000 ms0—not measured
Rcnf.scale.10m1500/s≤ 1000 ms0—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).

OperationRequestsErrorsp50 (ms)p90 (ms)p99 (ms)
adhoc_query7920293864
admin_contribution_report600159176269
analytics_query2306276197
archetype_adl2_list600131828
composition_commit280050106214
composition_commit_flat70425353
composition_delete40283535
composition_read15830243366
composition_read_current8960273657
composition_read_flat70172525
composition_revision_history8890142131
composition_update6804667239
composition_version_read300283549
contribution_commit48054138187
contribution_read820202860
directory_create120254086
directory_read8030172440
directory_update120283442
ehr_create120283233
ehr_extract_export7920149182268
ehr_read720182537
ehr_status_read240162325
ehr_status_update240213655
party_create60222828
party_read60162424
party_relationship_create60263030
party_relationship_read60111717
party_update60152727
readonly_write_denied70304646
smart_configuration_read70152424
stored_query_execute18004155103
system_options70101818
tags_put300243341
tags_read300172638
tdd_import70356060
template_adl2_list610131954
template_example60030102261
template_get60064134240
template_list600538090
terminology_query230192673
unauthenticated_probe70182323
ward_query1800283871

Resources (measured context, never a verdict input) — sampled every 10 s; CPU/RSS derived over the measured phase:

ContainerCPU meanCPU peakRSS peak
sut ferroehr-cnf-ferroehr-13.6%7.7%218 MB
db ferroehr-cnf-ferroehr-postgres-15.9%21.0%1.5 GB

Disk anchors: empty 133 MB → after scale seed 13 GB (≈ 13 KB / composition over 1,000,000 committed) → after ward seed 13 GB → after window 13 GB.

Benchmarks

FerroEHR measures performance with the same instrument family that measures conformance: Veredictum, the separate CNF 2.0 runner described on Conformance. There is no separate benchmark harness: every published number is measured by a committed, re-runnable instrument, reported in both directions, and regenerated from committed artefacts. There is no chart in this project that you cannot regenerate yourself with one command.

The three instruments

  • Measured class runs (the runner’s perf subcommand): 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 stress subcommand): 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-probe subcommand): diagnosis. The instrument’s AQL set fired repeatedly against a freshly seeded corpus, with wire-latency percentiles and the database-side cost attributed per SQL statement. The optimization loop’s entry point; exploration evidence only.

What the workload simulates

All three instruments drive the same hospital simulation: clinical journeys (admissions, shift vitals, medication rounds, laboratory results arriving asynchronously, chart reviews, AQL ward dashboards, corrections, discharges) expanded from a committed journey catalogue onto an open-loop arrival schedule, with payloads built from official openEHR CKM templates vendored with provenance. Every stage is its own planned arrival instant, so latency is measured from the planned time (coordinated-omission-corrected) and a stalled server cannot hide. The full workload story lives in Performance.

Running the instruments

# the measured class run
veredictum perf --root <perf-root> --ixit <ixit.json> --class POC --results <measured.json>

# the step-load stress ladder (fresh compose + seed, then the climb)
veredictum stress --root <catalogue-root> --ixit <ixit.json> --out <stress.json>

# the AQL optimization probe
veredictum aql-probe --root <catalogue-root> --ixit <ixit.json> --out <aql-probe.json>

Every instrument seeds a freshly composed, empty server through the public API and the stack is torn down afterwards; there is no seed reuse, so no run ever measures another run’s leftovers. Committed records land under docs/conformance/ferroehr/; the published charts regenerate from them (scripts/render/perf-assets.sh) and are diff-guarded in CI.

Measurement rules

The measurement methodology is enforced by construction:

  • The same runner measures every run against the same committed catalogue and the same ladder, each on its own freshly composed stack with the committed party statement.
  • Database maintenance is settled deterministically before every measured window, so no run pays another’s maintenance debt or trips an autovacuum mid-measurement.
  • Rate limiting is off for measurement runs, because the instruments deliberately offer load past the knee; both instruments refuse to write a record if the server answered any throttled request.
  • Configuration differences are explicit and labelled, never quiet. Any setting raised or lowered for a measurement is a visible part of the composed posture, which is why the postures live in committed compose files rather than in a shell variable someone may or may not have exported.

Rust crates

FerroEHR’s openEHR specification layer is published on crates.io as nine standalone Rust crates, usable without the CDR. They are the same crates the server itself is built on: the spec types are generated deterministically from the official openEHR machine-readable artifacts (BMM/XSD/OpenAPI), and the engines (ADL, AQL, Simplified Formats, serialization runtimes) are hand-written against the vendored specification text.

CrateImplementsWhat it gives you
openehr-baseBASE 1.2.0 + 1.3.0Foundation + base types (identification, intervals, ISO-8601 types)
openehr-rmRM 1.1.0 + 1.2.0The Reference Model: COMPOSITION, data structures/types, change control, generated invariant validation, the static RM attribute model
openehr-amAM 1.4.0 + 2.4.0The Archetype Object Model, both majors side by side
openehr-adlADL 2.4.0ADL2/cADL/ODIN parser, AOM2 validation, flattener, OPT2, ADL 1.4→2 conversion
openehr-termTERM 3.1.0Terminology model + the embedded official openEHR terminology (five languages: en, es, ja, pt, zh)
openehr-langLANG 1.0.0 + 1.1.0The BMM meta-model and its P_BMM schema form, plus hand-written ODIN, BEL and Expression-Language readers
openehr-queryQUERY 1.1.0AQL lexer, parser, typed AST with source spans, visitors, parameter binding, and canonical printer; the federation feature for the FROM ENDPOINT directive
openehr-itsITS-JSON, ITS-XML, ITS-REST 1.1.0Canonical JSON + XML codecs, the generated ITS-REST contract (axum server router, client, and an operation matcher for intermediaries), OPT 1.4 and AOM2 archetype XML
openehr-sdtITS-REST 1.1.0 Simplified Formats + SMART App LaunchSimplified Formats (FLAT/STRUCTURED/Web Template), a canonical PARTY built from a party Web Template, template-independent RM-instance validation, the SMART on openEHR scope grammar
[dependencies]
openehr-rm = "0.0.98"
openehr-its = "0.0.98"

All nine are edition 2024 with an MSRV of Rust 1.98, and all nine inherit the workspace lint table, including unsafe_code = "forbid", which no attribute anywhere in the crate can relax. There is no unsafe block in the published specification layer.

Generations: reaching more than one specification version

A crate generated from more than one openEHR generation exposes each as a version-named module: openehr_rm::v1_1 and openehr_rm::v1_2, openehr_base::v1_2 / v1_3, openehr_am::v1_4 / v2_4, openehr_lang::v1_0 / v1_1, openehr_term::v3_1. The crate’s prelude re-exports the current generation, so ordinary code needs no module path; an older generation is reached by naming its module in full:

#![allow(unused)]
fn main() {
// The current generation (RM 1.2.0), via the crate prelude:
use openehr_rm::prelude::Composition;

// The released generation (RM 1.1.0), via its own module path. Import
// renaming is not used anywhere in this project; where both generations
// appear in one file, give one of them a type alias:
type Rm110Composition = openehr_rm::v1_1::composition::composition::Composition;
}

Each generation mirrors its specification’s own package structure, so the path after the generation module reads the same in both. Every generation module also carries its own prelude; no name from one generation is ever mixed into another.

openehr-lang is the one crate whose generations also differ in what they contain, because upstream publishes two BMM meta-models side by side. Its v1_1 generation carries them as sibling specification units: the stable, tool-implemented BMM v2.x model (bmm, its persistence form bmm_persistence, and the beom expression model) is on the generation’s prelude, while the paused BMM3 model (bmm3) is reachable only by full module path. They cannot be merged (a set of class names, BmmClass, BmmModel and BmmPackage among them, occurs in both units with materially different shapes) so the prelude carries the stable units and the choice stays explicit at the use site. The older v1_0 generation is what its release actually defines: the BMM model plus an ODIN reader, with no BEL or Expression-Language notation.

Versioning

The package version is the crates’ own independent SemVer line: it tracks this implementation’s code and moves freely with fixes and improvements, never with the vendored openEHR specification versions. While the line is 0.0.x, expect breaking changes between releases, which always ship in lockstep across all nine crates.

The implemented specification version is therefore a separate datum, per generation. Each generated crate emits a Generation enum that is the only authority for it: one variant per generation module, Default marking the current one, each variant carrying its specification version as a const fn, and Display/FromStr round-tripping the module token:

#![allow(unused)]
fn main() {
assert_eq!(openehr_rm::Generation::default().spec_version(), "1.2.0");
assert_eq!(openehr_rm::Generation::V1_1.spec_version(), "1.1.0");
assert_eq!(openehr_rm::Generation::default().as_str(), "v1_2");
}

There is deliberately no crate-level SPEC_VERSION constant in the generated crates: a single constant would contradict a caller using a non-current generation. Exactly four crates implement one specification each and do expose one: openehr_its::SPEC_VERSION, openehr_sdt::SPEC_VERSION, openehr_query::SPEC_VERSION, openehr_adl::SPEC_VERSION.

Taking only the part of openehr-its and openehr-sdt you need

openehr-its layers its features so a browser or embedded consumer does not have to compile an HTTP server to read a template. The parsing spine is json → xml → opt14, each pulling in the one below it. Beside the spine sit schema-validation (validation against the compiled-in ITS-JSON RM schema, its only jsonschema user) and three ITS-REST features. rest is the generated contract (DTOs, parameter structs, route tables and the ApiError type, plus the routes::lookup operation matcher) over serde and http, with no HTTP engine. rest-server adds the per-group server traits, their success enums and a generated router(api) per group, which is what brings in axum. rest-client adds a generated client per API group over a runtime that sends through reqwest: each operation answers one outcome variant per status the OpenAPI documents, and an undocumented status is an error. A consumer that calls a CDR takes rest-client and never compiles axum. The default feature full is all seven, so an existing dependency line keeps the crate it had. With default-features = false and no feature at all, openehr-its compiles empty.

openehr-sdt sits on top: its flat feature pulls openehr-its with opt14 and adds the Simplified Formats and RM-instance validation, and cache adds the async Web Template cache (the only moka user). Its default full is both. opt14 and flat build for wasm32-unknown-unknown, so a browser consumer can call flat::webtemplate::builder::build_web_template or read an OPT 1.4 template:

[dependencies]
openehr-its = { version = "0.0.98", default-features = false, features = ["opt14"] }
openehr-sdt = { version = "0.0.98", default-features = false, features = ["flat"] }

With default-features = false and no feature at all, openehr-sdt compiles to the SMART App Launch scope grammar alone: std-only, with no dependency of any kind, so a REST client can parse scope strings with the grammar the CDR enforces instead of carrying a second one.

Releases

The nine crates publish as the last leg of the release pipeline, gated on a human: the leg runs in a protected environment with a required reviewer, so the run pauses there and nothing reaches crates.io without an explicit approval. A separate dispatch lane covers a publish between releases and the recovery path when the release leg fails.

Both lanes authenticate with crates.io Trusted Publishing (OIDC, no long-lived token anywhere) and publish the crates one at a time in dependency order, treating “already exists on the index” as done, so a run interrupted halfway can be re-run to finish the set. The lane then reads the registry back and refuses to report success unless all nine resolve at the same version, because while the line is 0.0.x a straggler makes its siblings’ internal requirements unresolvable for every consumer.

Information for integrators (CRA Annex II point 8(f))

The crates are libraries for building into other software. The Cyber Resilience Act (Regulation (EU) 2024/2847) has a product intended for integration into other products accompanied by “the information necessary for the integrator to comply with the essential cybersecurity requirements set out in Annex I and the documentation requirements set out in Annex VII” (Annex II point 8(f)). This section is that information for the nine crates. The rest of the information Annex II lists for FerroEHR is on CRA information and instructions to the user; the manufacturer and the reporting channels there apply to the crates too.

  • Reporting a vulnerability in a crate: to Cadasto B.V., through GitHub private vulnerability reporting or info@cadasto.com, as SECURITY.md describes.
  • Hearing of a fixed vulnerability: every fixed vulnerability that reaches a published crate gets a GitHub security advisory on the repository, naming the affected and the fixed crate versions. An advisory that names a published crate also reaches the GitHub Advisory Database in OSV format. Watch the advisories; nothing in the crates contacts anyone.
  • Support period: only the newest published version of all nine is supported. Cadasto B.V. commits to keep publishing security fixes for at least five years after each version (CRA Art 13(8)), and the fix ships only in the newest published version, never as a backport (Art 13(10)) (SECURITY.md § Supported versions). While the line is 0.0.x, the version that carries a fix may also carry a breaking change (see Versioning), so plan for the work of taking the newest version when you set the support period of your own product.
  • Software bill of materials: each release publishes a CycloneDX SBOM per crate, listing its normal and build dependencies, attested against the .crate archive crates.io serves (the SBOMs). Annex I Part II(1) has you draw up an SBOM of your own product “covering at the very least the top-level dependencies”; the crate SBOMs give you what each crate pulls in below that.
  • Where the crates come from: crates.io Trusted Publishing authenticates the upload, and records no build provenance. The source of truth is this repository at the release tag (the openehr-* crates).

What the crates do for the security of your product, and what they leave to it:

  • Memory safety. unsafe_code = "forbid" holds in all nine crates; there is no unsafe block in them.
  • Parsers. The canonical JSON and XML readers, the AQL parser, the ADL parser, the OPT 1.4 reader, the simplified formats and the identifier types each have a libFuzzer harness run on a nightly campaign, and a crash is fixed in the crate. Fuzzing finds crashes and does not prove their absence. Bound the size of what you hand a parser, and the time you let it run, in your own product: the FerroEHR server does this with its request body limits and timeouts.
  • The REST contract. The rest-server router of openehr-its dispatches each request to the server traits you implement and authenticates no one. Authentication, authorisation, rate limiting, TLS and an access log are layers your product adds. The rest-client engine sends over rustls TLS, never follows a redirect, and takes a reqwest::ClientBuilder you configure with your trust roots and proxies.
  • The SMART scope grammar in openehr-sdt parses scope strings. It decides no access; enforcing a scope is your product’s.
  • No storage, no network of their own. The crates write nothing to disk; openehr-lang reads the BMM schema files from the directory your code names. They open no connection except the requests your code sends through rest-client.

For the documentation of Annex VII: the crates are third-party components of your product, which CRA Art. 13(5) has you integrate with due diligence. Your technical documentation names the crate versions you build (the Cargo.lock records them) and your risk assessment covers how your product uses them. The FerroEHR CRA risk assessment assesses the FerroEHR release and covers the crates only where a row says so; it is not an assessment of your product.

Licensing

The nine crates fall into two groups.

The six generated-or-wire crates (openehr-base, openehr-rm, openehr-am, openehr-lang, openehr-term, openehr-its) and the AQL parser openehr-query are Apache-2.0, the licence of the openEHR machine-readable artifacts they are generated from, so any Rust project can use them, in proprietary and hosted products included. They embed material derived from those artifacts (generated types carrying specification documentation text, codecs and a REST contract generated from the XSD, OpenAPI and BMM files), which is Apache-2.0 as well, and their files name the openEHR Foundation as a second copyright holder. openehr-its also carries the hand-written runtimes, canonical JSON entry points and wire-validation dispatcher the generated code cannot ship without, and it packages the vendored ITS-JSON schema as bytes, so its README carries that file’s attribution (upstream repository, the exact vendored commit, and the licence) inside the package, where it travels with any redistribution.

openehr-query is hand-written from openEHR’s Apache-2.0 AQL grammar and ships only its Rust sources, the README and LICENSE-APACHE-2.0.

The two hand-written engines, openehr-adl and openehr-sdt, are under the Business Source License 1.1, the licence of the FerroEHR application. Each declares BUSL-1.1 and ships only its own Rust sources, the README and its LICENSE with the parameters. Non-production use of these two is free, production use is free for Non-Commercial Purposes, and any other production use, hosting for third parties or distribution for a fee needs a commercial licence, exactly as for the application.

openehr-term carries a third term, because it embeds a different kind of openEHR material: the official terminology XML (the five language bundles, the external-terminology index, and the property/unit data) is CC-BY-SA 3.0, redistributed verbatim with attribution. Its manifest declares Apache-2.0 AND CC-BY-SA-3.0 and the package ships both license texts, so the declaration a consumer reads names every license the crate’s own bytes are under. If you redistribute the crate, the terminology data travels under CC-BY-SA 3.0.

Versions 0.0.56 and earlier were published under the MIT terms in force at the time (MIT AND Apache-2.0 for the crates that embed openEHR material) and keep them; 0.0.57 was never published; 0.0.58 and 0.0.59 are Apache-2.0 for all eight crates of that time. From 0.0.60 openehr-query and openehr-adl are BUSL-1.1, and openehr-its was published as BUSL-1.1 AND Apache-2.0 from 0.0.60 to 0.0.67; its 0.0.68 was never published, and from 0.0.69 it is Apache-2.0 again. Its hand-written Simplified Formats, RM-instance validation and scope grammar moved into the new openehr-sdt, first published alone at 0.0.68 under BUSL-1.1 against the 0.0.67 siblings and in lockstep from 0.0.69. openehr-query is Apache-2.0 again from 0.0.116; its 0.0.60 to 0.0.115 keep BUSL-1.1. Published versions keep the licence they were published with. The full picture, including the vendored material that never reaches a published package, is in Licensing.

The openEHR specifications themselves are © the openEHR Foundation.

Contributing

FerroEHR is source-available (the Business Source License 1.1 for the application, Apache 2.0 for the five generated openehr-* model crates, and vendored third-party material under its upstream terms; see Licensing & legal) and welcomes contributions. This chapter is a short orientation for anyone who wants to file an issue, report a vulnerability, or open a pull request; the authoritative documents live in the repository and are linked below. You keep your copyright, and there is no separate agreement to sign; the terms a contribution lands under are set out in CONTRIBUTING.md § Licensing of contributions. Why FerroEHR exists explains what the project offers the organisations that run it and build on it, and what it asks in return.

A bug report with a reproducing request, a conformance case for behaviour nothing covers yet, a specification ambiguity you had to resolve in your own integration, a documentation correction, or a measurement from your own hardware all count.

Where to start

The three governing documents are kept in the repository root:

  • CONTRIBUTING — the practical rules for setup, the required checks, and pull requests.
  • Code of conduct — the Contributor Covenant (v2.1) the community follows.
  • Security policy — how to report a vulnerability privately.

Setting up

The Rust toolchain is pinned by the repository’s rust-toolchain.toml (stable 1.99.1), so rustup installs the right version automatically on your first build. The declared minimum supported version is Rust 1.98 and CI verifies it independently with cargo hack, so do not reach for a language feature newer than that. The edition is 2024.

Two extra tools are needed for the full test suite:

  • A PostgreSQL 18 server for the database-backed tests. The shared test harness starts (or re-adopts) one reusable container if Docker is running; otherwise point it at a server you already run with FERROEHR_TEST_PG_URL (its role must be able to CREATE DATABASE). See From source → Running the tests.
  • xmllint (from libxml2), used by the canonical-XML parity tests.

Install the shared git hooks once with bash scripts/install-hooks.sh.

The checks every pull request must pass

CI runs the same set of gates locally and on every pull request; none of them are advisory:

cargo build --workspace
cargo nextest run --workspace          # unit + integration (real PostgreSQL 18)
cargo fmt --all --check

cargo clippy --workspace --all-targets --all-features -- -D warnings

# rustdoc lints + doctests (the rustdoc lint table is inert without a doc run)
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --all-features --no-deps \
  --document-private-items
cargo test --workspace --doc

cargo deny check                       # subsumes cargo-audit: same RustSec DB, plus yanked/licenses/bans/sources
cargo machete                          # unused dependencies
cargo hack check --rust-version --workspace   # the declared MSRV really builds
bash scripts/checks/codegen-drift.sh    # generated layer matches the vendored specs

Beyond those, a family of small single-purpose scripts under scripts/checks/ runs on every pull request: comment and doc-comment style, default values declared inline in their struct’s Default impl, HTTP statuses compared as types rather than as numbers, licensing declarations and SPDX headers, no Python anywhere in the tooling, and the documentation-claim gates for this site. Each is a plain bash script you can run yourself, and the failure message names what to fix.

CI adds a few gates that need more than a checkout: a container smoke test that composes the built server image against the database image, the browser end-to-end battery for the viewer (bash scripts/ui-e2e.sh), the Helm chart render and boot lanes, and the changelog, crate-version, and attribution guards. Viewer-only work has its own local battery; see the repository’s CONTRIBUTING.md.

Important

Two rules are absolute. Never hand-edit a generated file: anything under a // @generated … DO NOT EDIT header 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 at main. Branch names are <type>/<slug> with the conventional-commit types (feat/…, fix/…, docs/…, chore/…, and so on), and commit subjects use the same types.
  • Keep changes focused, and describe what changed and why. For anything that touches openEHR behaviour, cite the relevant specification section.
  • Behaviour changes come with tests. Snapshot changes must be reviewed, not blindly accepted.
  • Any user-visible change (the REST surface, AQL, validation, configuration, the CLI, or the deployment artifacts) adds a changelog fragment under changelog.d/ (its README.md gives the format) and updates the matching page of this documentation, both in the same pull request. CI guards enforce both.

Personal data

The server keeps clinical content, the identities it belongs to and the map between them in three separate schemas, reachable by separate database roles. A change can move that boundary without meaning to, so four rules hold everywhere in the repository.

  • Synthetic data only: tests, fixtures, seeds, examples and screenshots use invented values. A real name, national identifier, address, phone number, email or date of birth never goes into the repository, an issue, or a pull request body.
  • No identifiers in telemetry: logs, traces, metric labels and Debug output carry record identifiers (an EHR id, a version uid, a template id) and shapes, never the content of a subject’s data. A Debug impl 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/flamegraph endpoint; see Operations → Profiling.

  • A code path in isolation: the criterion benches carry a pprof profiler, so any bench emits a flamegraph under --profile-time:

    cargo bench -p ferroehr --bench aql -- --profile-time 10
    # → target/criterion/<bench>/profile/flamegraph.svg
    
  • Async attribution (a sampled stack under tokio often blames the executor’s poll loop; a span flame blames the instrumented operation): set management.flame_file = "/tmp/ferroehr.folded"; the tracing-flame layer 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 end
    

    On macOS it uses dtrace, which needs elevated permissions: run with sudo cargo flamegraph … or grant your terminal Developer-Tools access; on Linux you may need perf installed and kernel.perf_event_paranoid ≤ 2.

Reporting issues and vulnerabilities

Use the GitHub issue tracker for bugs and feature requests.

Warning

Do not open a public issue for a suspected security vulnerability. Report it privately through GitHub’s private vulnerability reporting (“Report a vulnerability” on the repository’s Security tab). Because the server handles PHI-class data by design, reports about data exposure through the API, AQL, telemetry, or the audit trail are in scope even when they look like “just configuration”. Coordinated disclosure is preferred; please allow a reasonable window for a fix before publishing details.

Licensing & legal

The licence policy of the FerroHEALTH products is written once, in the FerroHEALTH book: what the Business Source License 1.1 allows without asking anyone, when you need a commercial licence, the Change Date to the Apache License 2.0, how to arrange a commercial licence, and the FerroHEALTH trademarks. Read it at Licensing and trademarks. LICENSE in this repository is the authority for FerroEHR and names the Licensor, the Licensed Work, the Additional Use Grant and the Change Date.

This page keeps what is particular to FerroEHR: installing a commercial licence in the server, which FerroEHR crates carry which licence, the third-party material this repository and its images ship, the machine-readable licensing, and FerroEHR’s lineage. It is a summary for evaluators and deployers, not legal advice.

Installing a commercial licence

A commercial licence is a signed token file the licensor issues to you. Place it where the server can read it and point [licence] file (or FERROEHR__LICENCE__FILE) at it; GET /management/status then reports licence.use = "commercial" with your organisation as licensee. Without a token the same field reports the non-commercial grant every build embeds. The server behaves identically under either; identifiers it mints carry a few bits derived from the licence id in place of random bits, so a record states which grant it was written under, and nothing else.

A commercial licence is arranged with Cadasto B.V., the Licensor: write to info@cadasto.com or use https://www.cadasto.com/contact/. Technical questions go to the maintainer named in MAINTAINERS.md.

FerroEHR’s own code: the Business Source License 1.1

Everything written for this project (the server and application crates, the code generator and tooling, the viewer, and the two hand-written specification engines openehr-adl and openehr-sdt) is licensed under the Business Source License 1.1, SPDX identifier BUSL-1.1. The Licensor is Cadasto B.V. The copyright holder is stated as Cadasto B.V., identically in LICENSE, in REUSE.toml, and in every first-party file header; a CI gate compares those sources with this page and with the README.md of each hand-written engine crate, so they cannot drift apart. Ruben Talstra remains the maintainer, which is a credit rather than a holder statement.

FerroEHR is source-available. The Business Source License 1.1 is not an OSI-approved open-source licence, and this project does not claim that it is. Each version becomes available under the Apache License 2.0, its Change License, four years after that version is published.

What the change does not affect. Releases v3.0.0 through v4.0.17 stay under the MIT terms they were published with, and so do the openehr-* crate versions 0.0.56 and earlier on crates.io (MIT AND Apache-2.0 for the crates that embed openEHR-derived material); openehr-query, openehr-adl and openehr-its 0.0.58 and 0.0.59 stay Apache-2.0, and openehr-its 0.0.60 to 0.0.67 stay BUSL-1.1 AND Apache-2.0, and openehr-query 0.0.60 to 0.0.115 stay BUSL-1.1. Relicensing changes future versions only. LICENSES/MIT.txt reproduces that grant as those versions carry it, Ruben Talstra’s copyright line included, because a published grant is a record of what was handed out and is not rewritten afterwards.

The conformance instrument is no longer part of this repository. It is Veredictum, an independent project under Apache-2.0, and the conformance pipeline here consumes it at a pinned version. The vendored test corpora this repository still carries keep their upstream terms exactly as the table below states.

The published openehr-* crates

Six of the nine published openehr-* spec crates are Apache-2.0, not BUSL-1.1, so any Rust project can use them, in proprietary and hosted products included, with no commercial licence involved. openehr-base, openehr-rm, openehr-am, openehr-lang and openehr-term are the generated openEHR model, published on crates.io under the licence of the openEHR machine-readable artifacts they are generated from. They embed openEHR-derived material (specification documentation text in the generated doc comments), which is Apache-2.0 as well; their generated files name the openEHR Foundation as a second copyright holder. openehr-term also embeds the official openEHR terminology XML, which is CC-BY-SA 3.0 (see the table below) and is redistributed verbatim with attribution, so it declares Apache-2.0 AND CC-BY-SA-3.0 and ships both texts.

openehr-its, the ITS wire layer, is the sixth. Its canonical XML codecs, _type dispatch, REST contract and archetype XML codecs are generated from the Apache-2.0 openEHR XSD, OpenAPI and BMM artifacts, and it embeds the vendored ITS-JSON schema. The hand-written runtimes, canonical JSON entry points and wire-validation dispatcher are Apache-2.0 with it, because the generated code cannot ship without them. From 0.0.69 it declares Apache-2.0, and every file names the openEHR Foundation as the second copyright holder.

openehr-query, the AQL parser hand-written from openEHR’s Apache-2.0 grammar, declares Apache-2.0 from 0.0.116 and names the openEHR Foundation as the second copyright holder in every file.

The two hand-written engines, openehr-adl (the ADL engine) and openehr-sdt (the Simplified Formats, RM-instance validation and the SMART scope grammar), are the project’s own engineering and carry the Business Source License 1.1 like the application, each with its own LICENSE naming the crate as the Licensed Work and each declaring BUSL-1.1. See Rust crates.

Vendored third-party material

The repository vendors external material verbatim: machine-readable specification artifacts the code generator consumes, the openEHR specification text used as the conformance oracle, and real-world clinical models and fixtures used as test corpora. Each family keeps its upstream license:

MaterialSourceLicense
openEHR machine-readable artifacts (BMM meta-models, XML Schemas, OpenAPI documents, JSON Schemas)the openEHR specifications-ITS-* repositoriesApache-2.0
The normative ADL, cADL, ODIN, BEL and Expression-Language ANTLR grammarsopenEHR/adl-antlr, openEHR/openEHR-antlr4Apache-2.0
openEHR specification text (the conformance reference)the openEHR specifications-* repositoriesCC-BY-SA 3.0
The AQL grammar and the computable terminology assets (the terminology XML the server embeds, and its schemas)specifications-QUERY, specifications-TERMCC-BY-SA 3.0
Clinical models (archetypes and templates) from the openEHR Clinical Knowledge Managerckm.openehr.orgper-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-archetypesno stated licence — see below
The ADL 2 validator-regression library (the openehr-adl corpus)openEHR/adl-archetypes, a different subtreemixed: 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, vitasystemsApache-2.0
Three ISO 13606 / rejected-extract BMM reference models inside the archie corpusoffered by their authors under MPL 1.1 / GPL 2.0 / LGPL 2.1taken under MPL 1.1 — see the election below
One terminology schema file, PropertyUnitData.xsdADL Designer / ADL2-tools, via the openEHR TERM assetsAGPL-3.0-only — see the contradiction below
The self-hosted KaTeX stylesheet and fonts this documentation site renders maths withKaTeX contributorsMIT
The Citation File Format 1.2.0 JSON Schema (validates CITATION.cff in CI)citation-file-format/citation-file-formatCC-BY 4.0
EU legal acts vendored for the compliance citations (GDPR, ePrivacy Directive, EHDS, NIS2, CRA, MDR), under docs/law/eu/the EU Publications Office (EUR-Lex)LicenseRef-EUR-Lex-Reuse (Commission Decision 2011/833/EU); the consolidated texts additionally CC-BY 4.0
EDPB Guidelines 01/2025 on pseudonymisation and 2/2023 on the technical scope of Art. 5(3) of the ePrivacy DirectiveEuropean Data Protection BoardLicenseRef-EDPB-Reuse
Dutch acts and decrees (UAVG, Wabvpz, BW Boek 7, the Telecommunicatiewet, the logging-retention decree, Begz), under docs/law/nl/wetten.overheid.nlLicenseRef-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.deLicenseRef-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’s PROVENANCE.md, so no GPL or LGPL obligation attaches to anything here.
  • One upstream contradiction, not resolved by us. crates/openehr-term/assets/schema/PropertyUnitData.xsd carries an ADL Designer / ADL2-tools header offering it under the GNU Affero General Public License, inside an upstream repository whose own LICENSE is 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’s include list, 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 — plus LicenseRef-openEHR-unstated, which is not a licence but the record that one tree carries none.
  • REUSE.toml declares, 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-FileCopyrightText line and an SPDX-License-Identifier line stating the same position REUSE.toml declares for it, so a file copied out of this repository takes its licensing along. Rust files of the five generated spec crates, of openehr-its and of openehr-query state Apache-2.0, and every other first-party Rust, shell, SQL and YAML file, the openehr-adl and openehr-sdt sources included, states BUSL-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 lint proves 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 in LICENSES/, and the licenses named on this page ever stop agreeing. A license cannot enter the tree without this chapter acquiring it.
  • Two header checks fail the build if a first-party file loses its header or states a license other than the one declared for its path: one for Rust, one for shell, SQL and YAML.
  • One check fails if the copyright holder is stated differently in LICENSE, REUSE.toml, the file headers, this page, or an engine crate’s README.md.
  • One check refuses copyleft license text inside this project’s own source, which is a conflict no reviewer reliably catches by eye.

What none of them can check is whether the prose on this page is correct. No tool can judge that, which is why any change to what the repository redistributes updates this chapter in the same pull request.

The CC-BY-SA specification text and clinical models are redistributed verbatim, with attribution: they are reference and test material, not part of the compiled server. The FerroEHR binary you deploy is built from this project’s own BUSL-1.1 code, plus the Apache-2.0 machine-readable inputs the generated crates carry, plus the CC-BY-SA 3.0 openEHR terminology bundle openehr-term compiles in (five languages, the external-terminology index, and the property/unit data). No copyleft obligation beyond those attribution-and-share-alike terms on the verbatim data attaches to anything shipped.

Rust dependencies

All third-party Rust crates are pinned in the workspace manifest and license-gated in CI with cargo deny, which checks licenses, security advisories, bans, and sources on every change. The allow-list is permissive (MIT, Apache-2.0, the BSD family, ISC, Zlib, BSL-1.0, Unicode-3.0, CC0-1.0, MIT-0, CDLA-Permissive-2.0) and deliberately admits two file-scoped weak copyleft licenses: MPL 2.0, and CDDL 1.0 as a single crate-scoped exception for the flamegraph renderer the profiling instruments use. Obligations under both attach to those crates’ own files, which are consumed unmodified. No strong copyleft (GPL, LGPL, AGPL, SSPL) is admitted, and a new dependency carrying one fails the build.

Trademarks and lineage

The trademarks of the FerroHEALTH family are on Licensing and trademarks in the FerroHEALTH book.

  • openEHR® is the registered trademark of the openEHR Foundation. FerroEHR is an independent implementation of the openEHR specifications and is not affiliated with or endorsed by the openEHR Foundation.
  • FerroEHR began as a fork of EHRbase, developed by vitasystems GmbH and the Peter L. Reichertz Institute, and records that lineage in the labelled import commit at the root of its history. EHRbase itself remains Apache-2.0; no code from it is present in this tree, and it is consulted as prior art only. FerroEHR is not affiliated with or endorsed by the EHRbase project.

Questions

If you need a clarification for a compliance review, open a GitHub discussion or issue; provenance questions can usually be answered by pointing at the exact PROVENANCE.md and upstream pin.

Compliance overview

FerroEHR is software. It is not a controller, not a processor and not a certified organisation, so nothing on this page says that a deployment complies with anything. What this page does say is which technical controls the product ships today, which are planned and tracked in public, and which obligations stay with the organisation that runs it.

The page is written for three readers. A privacy officer wants the legal sources and the split of duties. A hospital CISO wants the controls and their status. A developer wants to know where the boundary between clinical and identifying data runs. Each of those three readings takes about ten minutes.

It is also written in two layers, because openEHR is not a national standard and FerroEHR is published for every country that runs it. The GDPR, the EDPB guidelines and the EHDS apply to every EU deployment and come first. After them come the national sections, one per jurisdiction, each on top of that same EU layer. The Netherlands is filled in first because that is where the project’s own deployments are, not because it is the default; the national law section says how to add another.

The documentation that belongs to the FerroHEALTH family or to its manufacturer, Cadasto B.V., lives in the FerroHEALTH book at https://ferrohealth.eu/docs/: the manufacturer, the security policy, the post-market procedure, the manufacturer side of the CRA, the intended purpose and the shared responsibility of the EHR system FerroEHR forms with FerroBRIDGE, licensing, and how the products fit together. This book keeps FerroEHR’s own: the pages below.

What FerroEHR claims, and what it does not

FerroEHR aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture, and an EHDS conformity self-assessment is on its roadmap.

It holds no certification, no declaration of conformity and no third-party assessment. No page on this site will tell you that FerroEHR is “GDPR compliant”, “NEN 7510 certified” or “EHDS conformant”, because none of those statements would be true of a piece of software on its own.

What the project does instead is publish the record. A shipped control has a feature page in this book and an issue in the tracker that delivered it. A planned control has an open issue and appears here as planned, with its number. The control matrix is generated from the tracker, so a control cannot sit on this site as “planned” after it has shipped, or as “shipped” before it has.

Warning

The legal texts linked here change, and some of them are not yet in force. The EHDS obligations phase in over several years on the dates its own final provisions carry, and NEN republishes its standards on its own cycle (NEN 7510 was reissued in December 2024). Check each publisher directly before you rely on a statement here: EUR-Lex for EU law, wetten.overheid.nl for Dutch law, gesetze-im-internet.de and recht.bund.de for German federal law, Fedlex for Swiss federal law, the EDPB for guidelines, and NEN for the 7510 family. This page is a summary for evaluators and deployers, not legal advice.

The exact texts these pages were written against are vendored in the repository under docs/law/, each at a named consolidation with its digest and licence, and that directory’s README maps every act to the pages citing it. A statement here resolves to those bytes, and a publisher’s later amendment is a re-pin there, not a silent change of meaning.

The pseudonymisation boundary

A clinical record is identifying as soon as the record and the person can be put back together by whoever holds the database. Separating the two, and controlling who may rejoin them, is what GDPR Art. 4(5) calls pseudonymisation, and it is the control the EDPB Guidelines 01/2025 expect a supplier to describe rather than assert.

openEHR anticipated this. The Reference Model’s PARTY_SELF and Referring to the Patient from the EHR section names three schemes for pointing at the record subject, and calls the one that never sets external_ref anywhere in the EHR “the most secure approach”, because the link between record and patient is then held outside the EHR. The second scheme sets it once, in EHR_STATUS.subject, whose own class description says the association “may be done elsewhere for security reasons”. The reference itself is a PARTY_REF, described by the specification as an “identifier for parties in a demographic or identity service”, so it carries a namespace, a party type and an id and no demographic content of its own. The specification leaves the choice of scheme to the implementation. Where the schemas, the database roles and the resolve path live is FerroEHR’s own design, and no openEHR spec governs it.

What ships today

Clinical content and demographic parties live in separate PostgreSQL schemas, ehr and demographic, each with its own archival tier and its own runtime database roles. ferroehr_clinical and ferroehr_party, and a read-only twin of each, are NOINHERIT, hold explicit grants on one domain only, and carry an explicit revoke on the other and on linkage. The server refuses to start if that does not hold: a self-check enumerates every table, view, sequence and function in each domain and names the role and the object it can reach. The database refuses the mix as well, in both directions, so a code path that missed the split fails as a write error rather than leaking quietly.

The separation of schemas is unconditional. Pointing the demographic and linkage pools at their own DSNs ([storage.party] url and [storage.linkage] url) makes it a separation of credentials too, which is what stops one leaked connection string from reaching more than one domain.

The controls that apply across both domains are the same: role- and attribute-based authorization, per-EHR access settings, the domain-separated database roles, and the audit trail. Both sides carry openEHR’s own provenance, because every write commits a contribution and its audit in the same transaction, which is the versioning discipline the Change Control Package defines.

The map that rejoins the two is separated as well. linkage is a third schema under a fifth role, barred from both domains it joins and both of them from it, holding identifiers and a validity period and no attribute. The one crossing runs in the application over two pools and writes an access record. On the clinical side the subject reference is an opaque pseudonym once [privacy] subject_namespaces is declared, enforced by the write path and by a database trigger, and the identifier scanner refuses a national identifier anywhere in a clinical body.

flowchart LR
    client["API client"] --> server["FerroEHR server"]
    server -->|ferroehr_clinical| ehr[("clinical schema:<br/>versions and nodes")]
    server -->|ferroehr_party| demo[("party schema:<br/>parties and sealed identifiers")]
    server -->|ferroehr_linkage| link[("linkage schema:<br/>party to EHR resolve map")]
    server -->|audit writer| audit[("audit schema:<br/>ATNA record repository")]

Secondary use

Research data leaves through FerroBRIDGE, which loads the OMOP CDM in batches over AQL and pseudonymises for research; the CDR keeps no read model of its own for it (#3160 closed as superseded). The secondary-use page records the batch contract, the marks that take an EHR out of a batch, and the data permit on the access record.

Cross-domain cohort queries shipped with v4.2.0 (#3159): a cohort question that spans both domains is answered through POST /query/cohort, each step on its own credential with identifiers only crossing between them, and a result set serving fewer distinct EHRs than the configured threshold is withheld and marked suppressed. Secondary use reads the primary store under those controls; size your access control, purpose limitation and risk assessment on that. The threat model states the residual risk at each boundary, and the DPIA page carries the risk register and the shipped controls by issue number.

The deployment profile

A deployment declares what it may hold with the top-level deployment_profile key (configuration). production refuses to start while a separation is missing and not accepted by name: separated credentials, separated clusters, a declared pseudonym namespace, an audit trail with a durable sink, schema preparation on its own credential. sandbox, the default, must not hold real personal data and says so on the banner, in the log and on GET /rest/status. The profile is FerroEHR’s own posture. GDPR Art. 4(5) asks that the additional information be “kept separately and … subject to technical and organisational measures”, not that it sit on a separate server, so one cluster with separated schemas and roles is a defensible reading; two clusters close the bridges no grant can, the superuser, an instance-wide point-in-time recovery, a single compromise, and the profile exists so that choice is made deliberately.

GDPR

Regulation (EU) 2016/679 places its duties on the controller and the processor. A CDR can only supply the technical measures those duties are met with. These are the articles a repository actually touches.

What the article asks forWhat FerroEHR shipsTrackerWhat the deploying organisation must do
Art. 4(5) pseudonymisation: identifying data kept separately, under technical measuresClinical, demographic and linkage data in three schemas under non-overlapping NOINHERIT roles, enforced by grants, by a boot-time self-check in both directions and by a check on every table; separated credentials and clusters are asserted by the production deployment profile. The patient-to-EHR cross-reference lives outside the CDR by default, in an IHE patient identity source asked over PIXm ([ehr_index]), which mints the pseudonym; with the opt-in provider = "local" it lives in the linkage schema under its own role. FerroEHR never resolves a pseudonym back to a personshipped, #3153, #3158, #3226, #3380Run the production profile with the separations it asserts, run the identity source under its own controls, and hold any additional information the CDR never sees to the same standard
Art. 5(1)(f) integrity and confidentialityTLS 1.3 with optional mutual authentication, authentication and authorization, per-version digest signing, a tamper-evident audit chainshippedTerminate 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 needA 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 listshipped, #3346Choose 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 complianceAn audit trail of every access, retrievable over ITI-81, plus openEHR’s own contribution and audit chain on every writeshippedKeep the records, define retention, be able to produce them
Art. 9 special categories of dataObject-level EHR_ACCESS settings, RBAC, ABAC, and the domain-separated database rolesshippedEstablish 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 electronicallyEvery 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 requestshippedStart 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 processingThe 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 searchshippedSupply 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 statementA 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 datashippedDecide what is inaccurate or incomplete, and reconcile the correction with the record-keeping duty your national law imposes
Art. 17(1) erasure, subject to the grounds in 17(3)(b) to (d)DELETE {base}/admin/ehr/{ehr_id} (physical deletion) removes, in one transaction, the EHR and everything it owns over both storage tiers: compositions, EHR_STATUS, item tags, folder memberships, contributions, every historical version, the restriction and retention marks and the pending change events. The cross-reference row naming the subject is erased in the linkage domain, the externalized multimedia blobs no surviving version references are deleted from the object store, and an erasure tombstone on the change-event stream tells each consumer to delete what it derived (Art. 19). What stays is the audit trail naming the ehr_id, because Art. 17(3)(b) withholds erasure where processing is necessary for compliance with a legal obligation and the access-logging periods are that obligationshipped, #3347Decide 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 processingA restriction register at two grains, a whole EHR or one versioned object in it: a marked object stays in storage and every path that would process it stops, the point read, the versioned read and the revision history with 403, AQL at every scope, the EHR Extract, the event stream and any write. The register keeps the request and stamps it lifted, so the sequence 18(3) turns on survives. On a write the restriction is the last gate: content validation answers 422 and the preconditions outside the commit transaction answer 409, because the mark is read inside that transaction so a concurrent lift is serialised with the write, so a refusal status names the gate that stopped the write rather than the state of the markshipped, #3324Record 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 requestEvery 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 trailshippedSend 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 feasibleCanonical openEHR JSON and XML, the simplified FLAT and STRUCTURED formats, and the EHR Extract, which another openEHR system imports directlyshippedCheck 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 authorityshipped, #3325Weigh 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 defaultDeny-by-default authorization, an EHR_ACCESS default that can be set to restricted, audit on by default. The two defaults that favour compatibility, the open per-EHR access default and the open audit fail mode, stop a production deployment booting: each is a named gap (open_ehr_access_default, audit_fails_open) that the deployment either closes or accepts by name in deployment_accepts, which is then stated on every boot and on /management/statusshipped, #3323Choose the restrictive settings, or record the decision to keep a compatibility default and carry it in deployment_accepts
Art. 28(3)(e) to (h) what a processor’s contract must let it do: assist with the rights, assist with Arts. 32 to 36, delete or return the data at the end of service, and make audit information availableFor a vendor operating a deployment: the rights operations in the rows above for (e), the trail and the controls documented in this book for (f), EHR Extract export beside admin physical deletion for (g), and GET /ferroehr/rest/extensions/v1/admin/config with the trail as the evidence (h) asks forshippedConclude the contract Art. 28(3) requires with whoever operates the deployment; no software supplies it. Cadasto B.V. is the manufacturer of each release: for a self-hosted deployment it processes no personal data on your behalf and is not your processor; where Cadasto B.V. hosts the deployment for you it is your processor, and the contract is with it (shared responsibility). Either way, the one service a deployment reaches by default is Cadasto B.V.’s collector for the usage report, which Cadasto B.V. receives as controller for its own purposes
Art. 30 records of processing activities, and 30(1)(f) the envisaged time limits for erasure of the different categories of dataThe effective configuration as a redacted JSON tree at GET /ferroehr/rest/extensions/v1/admin/config, this book as a description of what the software does, and the retention register as the machine-readable answer to the time limits per category 30(1)(f) asks forshipped, #3346Write and maintain the record itself; the software cannot know your purposes or recipients
Art. 32 security of processingThe controls listed in Security and the residual risk in the threat modelshippedAssess 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 patientsThe trail records reads, writes and refusals per patient and per agent, so whose records an incident reached, and how many, is countable from itshippedAssess 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 assessmentA 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 checklistshipped, #3161Run 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 wayCohort queries across the pseudonymisation boundary answer a research question as an aggregate, withheld when fewer distinct EHRs than the configured threshold matchshipped, #3159. Pseudonymisation for research happens in FerroBRIDGE, which loads the research database; the CDR gates what leaves with the restriction and objection marks and records the purpose and the data permit of every batch (Secondary use), #3379Decide whether the purpose can be met without identification and take that route where it can

EDPB Guidelines 01/2025 on pseudonymisation

The EDPB guidelines ask for something more specific than “we pseudonymise”: a named pseudonymisation domain, a stated attacker, and additional information kept where that attacker cannot reach it.

What the guidelines ask forWhat FerroEHR shipsTrackerWhat the deploying organisation must do
A pseudonymisation domain stated explicitlyThe clinical and demographic domains are separate schemas with their own roles, and the server refuses to boot if a role reaches acrossshipped, #3153State the domain for your deployment, including the parts outside FerroEHR
The additional information held separately from the pseudonymised dataThe 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 jobshipped, #3158, #3157Hold 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 credentialThe threat model names actors, boundaries and the risk surviving each controlshippedExtend it with the actors your environment adds: operators, backups, the network
Resolution of a pseudonym recorded and controlledEvery 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 valuesshipped, #3155, #3235, #3159Restrict 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.

ChapterWhat FerroEHR shipsTrackerWhat the deploying organisation must do
Chapter II, primary use, including the patient’s access to their data and to a record of who accessed itAn access trail of every read, write and refusal, searchable by patient and by agent, read for one patient through a subject-scoped grant, with an emergency mark on an access declared in the patient’s vital interests (Art. 11(5)), and the Art. 8 restriction of access by health professionals, under which restricted data reads as absent unless the configured emergency role declares an emergencyshippedBuild the patient-facing access route on the subject-scoped grant and authenticate the person. Record a restriction on the person’s instruction through the admin route, configure authz.rbac.emergency_access_role, and have callers declare an agreed emergency code when they override it
Chapter III, EHR systems: a European interoperability software component and a European logging software component, with published technical documentationThe EHDS readiness page maps every Annex II requirement to a status with its evidence, the technical documentation page maps the Annex III elements to the documentation tree, and the access trail records the logging component’s elements (audit)readiness shipped, #3168, #3169, #3170, #3171; the interoperability component is FerroBRIDGE’s, and the exchange format waits on implementing acts under Article 15(1)Configure the deployment as the instructions for use say. The conformity assessment and the EU declaration are Cadasto B.V.’s as manufacturer of each release; a self-hosted deployment of an unmodified release does not make you the manufacturer
Chapter IV, secondary use, including Art. 66(3) (data in pseudonymised format, the means to reverse it held only by the access body or a trusted third party)The batch surface FerroBRIDGE loads the OMOP CDM from, with the restriction and objection marks taking an EHR out of every batch and the purpose and data permit on every access record; cohort queries across the pseudonymisation boundary with small-cell suppression. Pseudonymisation for research happens in FerroBRIDGE; the CDR gates the datashipped, #3159, #3379Deal with the health data access body; a CDR is not a data-holder’s permit process. Have the secondary-use caller declare its purpose and permit

National law

Everything above this line applies to every EU deployment. Everything below it is one country’s law on top of it, and a deployment reads only its own section plus the EU layer.

Three jurisdictions are filled in today: the Netherlands, Germany and Switzerland, each vendored under docs/law/ and read against the text. The product side of the split is plural too: the write-path identifier scanner ships a named rule per national identifier — Finland, the United Kingdom, the Netherlands, Norway and Sweden — each transcribing the checksum its own issuing register publishes, and a deployment selects the ones its content can carry. Denmark and Belgium are named there too, with the reason each is deliberately absent.

Adding a jurisdiction takes three things, and none of them is a change to how the scanner works: the national acts that sit on top of the GDPR, as a section in the shape of the Dutch one below (provision, what the product ships, tracker status, what the organisation must do); the national security and logging standards, in the shape of the NEN section; and, where the country issues a personal identifier with a published algorithm, a rule in app/ferroehr/src/privacy/detect.rs citing the register that defines it. Open a regulation request with the official source and the provisions that reach a repository, and the project vendors the text and records a status per provision, the way the acts below are handled. A checksum transcribed from a secondary source is refused, because a rule that guesses tells an operator their data was scanned when it was not.

The Netherlands: UAVG and Wabvpz

Two Dutch acts sit on top of the GDPR for a care provider. The UAVG is the national implementation act; the Wabvpz governs the burgerservicenummer in care and the patient’s electronic access to their record.

ProvisionWhat FerroEHR shipsTrackerWhat the deploying organisation must do
UAVG Art. 30, exceptions for health dataAccess control at the record and the attribute level, and an audit trail of who used itshippedEstablish that your processing falls inside the exception, per role and per purpose
UAVG Art. 46, processing a national identification numberA 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 valueshipped, #3155Hold 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 providersNothing specific: FerroEHR performs no BSN verification and consults no indexnot plannedVerify identity and the BSN in your own systems before data reaches the CDR
Wabvpz Art. 15d, electronic access and copy for the patientThe full record over the openEHR REST API, and EHR Extract export for a whole recordshippedBuild the patient-facing route and authenticate the patient
Wabvpz Art. 15e, a record of who made data available and who consulted itThe ATNA trail records reads, writes and refusals with the agent, the patient, the action and the outcome, and answers a per-patient searchshippedTurn 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.

StandardWhat FerroEHR shipsTrackerWhat the deploying organisation must do
NEN 7510-1 and 7510-2, the management system and its controlsTechnical controls an ISMS can point at, documented per control with their residual riskshippedRun the ISMS and hold the certification; a product cannot be certified against a management-system standard
NEN 7512, the trust basis for data exchangeMutually authenticated TLS (IHE ITI-19), OAuth2 and OIDC with an enterprise identity provider, signed and verifiable releasesshippedAgree the trust basis with each counterparty and operate the certificate estate
NEN 7513, logging actions on electronic patient recordsAn 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 patientshippedMap 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.

ProvisionWhat FerroEHR shipsTrackerWhat 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, encryptionAn 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 restshipped, #3153, #3155Establish 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 possibleClinical, demographic and linkage data in separate schemas under separate roles; cohort queries that cross the boundary on identifiers only, with small-cell suppressionshipped, #3158, #3159; research pseudonyms are made in FerroBRIDGE, which loads the research database (Secondary use)Decide when the research purpose no longer needs the link and anonymise; hold the balancing test Abs. 1 asks for
BDSG § 35 Abs. 2 and 3, restriction of processing in place of erasure where erasure would harm the subject or a retention period bars itThe 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 undershipped, #3324Decide 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 scopesRole- 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 includedshippedBind 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 outrightEvery 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 dutyshippedRun 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 consentA 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 gateshipped, as versioned contentModel 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 2030The 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 includedshipped, #3346This 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 serveropenEHR archetypes and templates as the content model, terminology bindings resolved at commit time against any FHIR R4B terminology server through [terminology.server], which is the interface gematik’s terminology server exposesshippedPoint [terminology.server] url at the server gematik runs and adopt the information objects the KBV publishes; the text names no serialization, and neither does this page
SGB V § 360 Abs. 16, electronic prescription transport outside the telematics infrastructure is prohibitedNothing, and nothing planned: the CDR carries no prescription transportnot plannedRun 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 purposePer-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 backshipped, #3159, #3346; research pseudonyms are made in FerroBRIDGE, which loads the research database (Secondary use)Write the rights-and-roles concept, publish the purposes, run the thirty-year clock against the register, and answer subjects’ requests from the trail
SGB V § 290, the Krankenversichertennummer every insured person’s record carriesThe 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 controllersshipped, #3318Keep 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 secrecyOperations 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 bakedshippedBind 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.

ProvisionWhat FerroEHR shipsTrackerWhat 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 minimumDeny-by-default authorization, tenancy that fails closed, audit on by default, and a production deployment profile that refuses to start while a separation is missingshippedChoose 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 detectionRole- 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 includedshippedRun 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 rolesThe trail records every operation including reads and refusals with the agent, the patient, the action, the outcome and the time, in a dedicated audit schema retrievable by an admin caller only, with forwarding sinks (syslog, IHE ITI-20) that put a copy outside the system; [audit.store] retention_days sets the period. The separation Abs. 5 asks for is the deployment’s DSN choice, and the server reports on it: the audit pool’s own cluster is read at boot like every other domain’s, so an audit database sharing a cluster with a pseudonymisation domain is a shared_cluster gap on /management/status rather than an invisible co-locationshipped for the trail and the posture reading; the separation itself is the deployment’s, #3398Forward 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 exemptionThe effective configuration as a redacted JSON tree at GET /ferroehr/rest/extensions/v1/admin/config, records of processing pre-filled with what the software does, and the retention and security-measure inputs Art. 12 Abs. 2 lit. e and f ask forshippedWrite 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 dataThe DPIA page with the processing description, the data categories per schema, the roles, the retention, a risk register and the shipped controls by issueshipped, #3161Run 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 measuresThe 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-checkshippedDetect, 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 professionalThe 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 recalledshipped, #3346Identify 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 reuseCanonical openEHR JSON and XML, the simplified FLAT and STRUCTURED formats, and the EHR Extract, all published open formatsshippedDecide 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 meanwhileClinical, 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 dueshipped, #3158, #3159, #3346; research pseudonyms are made in FerroBRIDGE, which loads the research database (Secondary use)Set the retention the cantonal health law requires, decide when anonymisation is possible, and hold the research ground
EPDG Art. 9 and 10, EPDV Art. 1 to 4, 10 and 12, EPDV-EDI Anhang 5, the EPD: three confidentiality levels and grantable access rights, emergency access with notification of the patient, every processing logged and the logs kept ten years, EPD data stored separately and encrypted, data stores in Switzerland, and the IHE profiles a community speaks (ATNA with Record Audit Event ITI-20 and Authenticate Node ITI-19, CH:ATC with Retrieve ATNA Audit Event ITI-81, IUA, XUA, XDS, MHD)What a community integration consumes: IHE ATNA audit events over ITI-20 with ITI-19 mutual TLS and ITI-81 retrieval, the record in published formats, a self-hosted deployment with no dependency outside the operator’s control, and the retention register for Art. 10’s twenty-year destruction, with the per-object hold Abs. 2 lit. b asks forshipped, #3346; an access declared with an emergency purpose is marked in the access log, but FerroEHR lifts no restriction for it and notifies no one; it encrypts national identifiers only, so encryption at rest is the database’s and the disk’sThese 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 fromThe 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 activeshipped, #3319Treat the number as opaque, and keep the AHV number out of clinical content

Where to go next

  • Control matrix: the machine-generated status of every declared control, straight from the tracker.
  • Shared responsibility: what a FerroEHR deployment sends out by default, and where FerroEHR documents its side of each obligation; the tables, obligation by obligation, are in the FerroHEALTH book.
  • Complaints, incidents and vulnerabilities: how to report about FerroEHR, FerroEHR’s registers, and what happens when a release is withdrawn.
  • Intended purpose: FerroEHR’s part of the manufacturer’s statement: its essential functions, the data it is designed to process, the environment it assumes and the misuse it foresees.
  • Cyber Resilience Act: FerroEHR’s CRA documents and product facts, and the class I question.
  • CRA risk assessment: the cybersecurity risks, and how each point of CRA Annex I applies, with its evidence and its open work.
  • Hazard log: the patient-safety hazards of the access log, each with its control and the test that shows it.
  • Claims review: every public claim read against EHDS Art. 28, release by release.
  • Information sheet and instructions for use: what accompanies each release: the manufacturer, the version, the data categories, the standards, an EU deployment’s configuration, maintenance and the known limitations. They are the EHR system’s, FerroEHR with FerroBRIDGE, and live in the FerroHEALTH book; each release attaches the revision it pins with its own versions appended.
  • Security: how each control is configured.
  • Threat model: what survives each control.
  • Audit trail: what is recorded, in which formats, and how to read it back.
  • Data protection impact assessment: the technical description, the risk register, and the shipped controls by issue number.
  • Records of processing: an Art. 30 template pre-filled with what the software does.
  • Go-live checklist: what to verify before a deployment holds real patient data.

Shared responsibility

Which obligation under EU and national health-data law the software can carry, and which stays with the organisation that deploys it, is drawn once for the EHR system FerroEHR and FerroBRIDGE form together. The tables, one per law (the GDPR, the EHDS, the CRA, and the national sections for the Netherlands, Germany and Switzerland), and the account of when Cadasto B.V. is also your processor, are published in the FerroHEALTH book: The EHDS EHR system: shared responsibility.

This page keeps what is particular to a FerroEHR deployment.

What a FerroEHR deployment sends out by default

The one thing a FerroEHR deployment sends out by default is the usage report: a random instance id, the version, the licence grant type and coarse performance figures, with no patient data. Cadasto B.V. receives it as controller for its own purposes, and its collector also stores the IP address each report comes from. The usage report page says how to turn it off.

Where FerroEHR documents its side

The middle column of each table in the FerroHEALTH book, what the software provides, points at FerroEHR’s own pages:

None of these pages tells you whether your deployment satisfies an obligation. That depends on your legal basis, your organisation, your infrastructure and your operating practice, none of which a supplier can see.

Retention, restriction and objection

Three marks sit beside the clinical content, and none of them is an openEHR concept. The specifications say what a repository must keep and how versions relate to each other; they say nothing about a period after which data should go, about limiting the processing of a record that stays, or about a patient who does not want their data used for research. The law says all three, so FerroEHR carries them as its own extension and this page is where they are written down.

Nothing here deletes anything. A record in an openEHR repository is indelible by design — a deletion commits a new version whose content is removed, and the history stays — so a retention period in FerroEHR produces a list, and acting on that list is the controller’s decision, taken record by record and carried out through physical deletion.

The retention register

Two tables and a view in the clinical domain, all driven through the admin API.

retention_policy is the register: one row per content category per jurisdiction, carrying the period, what the period is measured from, and the legal citation the period rests on.

ColumnMeaning
kindThe content category: COMPOSITION, EHR_STATUS, FOLDER, or EHR for the whole record
categoryOptional: the EHDS category the period is keyed on, named as [audit.categories] names it: a priority category by its code (Patient-Summaries, Electronic-Prescriptions, Electronic-Dispensations, Medical-Imaging, Laboratory-Reports, Discharge-Reports) or as <system>|<code>, a declared national category as its <system>|<code>, or none; stored as the <system>|<code> token; empty keys the period on every object of the kind
originOptional: the origin the period is keyed on, the id of an origin [origins] registers; empty keys the period on every origin
jurisdictionISO 3166-1 alpha-2, the same key the identifier scanner rules use
periodHow long content of that category is kept
anchorWhat the period is measured from: last_commit, death, or majority
sourceThe citation the period comes from, quoted as the act spells it

retention_anchor is the per-EHR half: the jurisdiction whose periods apply, the anchor instant once the deployment knows it, and any hold that suspends disposal for the whole record with the reason it was placed.

retention_due joins the two and lists what has run out. Each row names the EHR, the kind, the priority category and the origin where the period is keyed on one, the citation, when the period expired, and two counts: the objects that are due, and the objects exempted by a per-object hold.

Ship the register empty and declare the periods your organisation is subject to. The periods are not the software’s to choose — they follow from the law the deployment runs under, and two of the provisions in the compliance overview ask for exactly this register: GDPR Art. 30(1)(f) wants the envisaged time limits for erasure per category of data in the record of processing activities, and Swiss DSG Art. 25 Abs. 2 lit. d gives the subject a right to be told the retention period or the criteria that fix it.

Declaring a period

PUT /ferroehr/rest/extensions/v1/admin/retention/policy
Content-Type: application/json

{
  "kind": "EHR",
  "jurisdiction": "CH",
  "period": "20 years",
  "anchor": "last_commit",
  "source": "EPDV Art. 10 Abs. 1 lit. d"
}

204 on success, replacing any earlier period for that kind, category, origin and jurisdiction. An unknown kind or category, an unregistered origin, an unknown anchor rule or a non-positive period is 400. GET /ferroehr/rest/extensions/v1/admin/retention/policy reads the whole register back, which is what an access answer and an audit both cite.

Keying a period on a priority category

EHDS Annex II 3.4 asks the storing components to support retention periods “that take into account the origins and categories of electronic health data”. Add "category": "Laboratory-Reports" to the body above and the period covers only the objects your [audit.categories] map places in that category. The server mirrors the map into the clinical domain at every boot, so the due list reads the map in force. An object’s category comes from its latest version that carries content: the template id first, then the root archetype id; every kind other than COMPOSITION reads as none. An object the map cannot classify is listed under the longest period configured for its kind, so a missing map entry can only keep content longer. A category row with no object is left out of the due list, and a period without a category lists every object of its kind.

Keying a period on an origin

Every version records its origins at commit: each FEEDER_AUDIT originating-system id the body carries (RM common, FEEDER_AUDIT_DETAILS), or, for a body that carries none, the origin of the client that committed it. The ids a sending system writes are free text, so you declare the origins your deployment recognises in [origins], each by the id its data carry and with the clients whose direct commits come from it:

[[origins.registered]]
id = "lab.example"          # the FEEDER_AUDIT system_id the lab's feed writes
clients = ["lab-gateway"]   # a Basic user, or an access token's client_id (else azp)

A body without a FEEDER_AUDIT committed by lab-gateway is then stamped lab.example; a direct commit by any other caller keeps this server’s system_id. Add "origin": "lab.example" to a period and it covers only the objects whose latest version carrying content came from that origin. A period names only a registered id, so a misspelt origin is refused with 400 instead of keying a period that reaches nothing. A period without an origin lists every object of its kind, and an origin row with no object is left out of the due list. Ids compare exactly. Origin and category combine: a period keyed on both lists the objects of that category from that origin.

Anchoring an EHR and holding it

PUT /ferroehr/rest/extensions/v1/admin/retention/anchor
Content-Type: application/json

{
  "ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
  "jurisdiction": "CH",
  "anchored_at": "2026-01-31T00:00:00Z"
}

Leave anchored_at out until the anchor event is known; nothing is due without it. Add hold_at and hold_ground together to suspend disposal for the whole record: a hold with no stated reason is refused with 400, because a hold nobody can account for is not a record of anything.

A hold at object grain is a separate route, and it is the shape Swiss EPDV Art. 10 Abs. 2 lit. b asks for, where the patient may ask that named data be exempted from the destruction the same article otherwise requires:

POST /ferroehr/rest/extensions/v1/admin/retention/hold
Content-Type: application/json

{ "vo_id": "df58b2ee-30bd-4b2c-9b7d-3a0f8e5c6d21", "held": true }

Reading what is due

GET /ferroehr/rest/extensions/v1/admin/retention/due?limit=100

Oldest first. Every row carries objects_due and objects_held, so a controller acting on the list disposes of the first and leaves the second where they are. An EHR under a whole-record hold does not appear at all.

Restriction of processing

GDPR Art. 4(3) defines restriction as marking stored data to limit its future processing, and Art. 18(2) says what is left once the mark is set: storage, and nothing else without the subject’s consent or one of the named exceptions. FerroEHR implements that literally, at two grains — a whole EHR, or one versioned object inside it.

A restricted object stays in storage untouched, and every path that would process it stops:

  • a point read, a versioned read and a revision history answer 403 with a body naming the restriction — distinct from the authorization 403, because the same caller with the same rights is refused until the restriction is lifted;
  • AQL results omit it, at every scope, so naming the ehr_id does not make the content answerable;
  • an EHR Extract omits a restricted object, and refuses outright for a restricted EHR;
  • the change-event stream neither emits nor delivers an event about it;
  • a write to it is refused, and nothing already stored changes.

The restriction refusal is the last gate on a write. The checks that need no transaction run first: a body the template refuses is 422, and a write to an EHR whose is_modifiable is false, or a second directory for one EHR, is 409. Only the commit transaction itself reads the mark and answers 403. The order is deliberate. Validation stays outside the transaction so the transaction is short, and the mark is read inside it so a concurrent lift either commits before the write and is seen, or waits for it. A malformed write to a restricted object therefore answers 422 rather than 403: the request is refused either way and nothing stored changes, but the status names the gate that stopped the write, not whether the restriction is in force. GET /ferroehr/rest/extensions/v1/admin/restriction?ehr_id=… answers that.

POST /ferroehr/rest/extensions/v1/admin/restriction
Content-Type: application/json

{
  "ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
  "vo_id": "df58b2ee-30bd-4b2c-9b7d-3a0f8e5c6d21",
  "ground": "gdpr-18-1-a",
  "note": "accuracy contested on 2026-09-15"
}

Omit vo_id to restrict the whole record. ground is one of the four Art. 18(1) points — gdpr-18-1-a through gdpr-18-1-d — or national for a member-state rule that goes beyond them, which is how German BDSG § 35 Abs. 2 and Abs. 3 are carried.

POST /ferroehr/rest/extensions/v1/admin/restriction/lift with the same grain lifts it. The register keeps the request and stamps it lifted rather than removing it, because Art. 18(3) makes the sequence itself the obligation: the subject is informed before the restriction is lifted, and a register that forgot the request could not show that anyone was. GET /ferroehr/rest/extensions/v1/admin/restriction?ehr_id=… reads the whole sequence back. Lifting a whole-record restriction leaves an object-scoped one standing on its own.

Objection to research processing

GDPR Art. 21(6) gives the subject a right to object to processing for scientific or historical research or statistical purposes under Art. 89(1), unless the processing is necessary for a task carried out for reasons of public interest. That is narrower than Art. 18: it reaches research, not care.

While an objection stands, the EHR is absent from every full-population AQL query, from every export, and from the change-event stream — the three surfaces a secondary-use consumer reads this repository through. A query that names the ehr_id and a read of the record by the treating clinician are untouched.

POST /ferroehr/rest/extensions/v1/admin/research-objection
Content-Type: application/json

{ "ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398", "objected": true }

Send "ground": "…" beside "objected": true to record the public-interest ground the article admits for an override: the EHR returns to the research population and the ground says on whose authority. Send "objected": false to withdraw the objection entirely; a withdrawal carries no ground, because there is nothing left to override.

The audit trail, and the access-log ceiling

Setting and lifting any of these marks is an access record of its own, naming the EHR, the object where the act was object-scoped, and which register moved. These are the acts a supervisory authority asks about after the fact, so they are in the trail beside the reads and writes.

The trail has a retention question of its own, and it runs the other way: the law sets a floor below which an access log may not be reaped (three years from each date of access in every EU Member State under EHDS Art. 9(2), five years in the Netherlands, one year in Switzerland), and one national rule sets a ceiling. SGB V § 309 Abs. 1 asks the controllers of a German telematics-infrastructure application to keep access logs for the three-year limitation period, and Abs. 3 requires deletion without delay once it has run. That provision binds the controllers § 307 names, which no software can infer, so the deployment declares it:

[audit.store]
retention_years = 3
sgb_v_309_controller = true

With the declaration in place, the server refuses at boot any horizon above the ceiling, including retention_days = 0 (keep forever), and refuses a configuration whose floors and ceilings contradict each other outright, rather than silently preferring one.

Germany is a Member State, so the EHDS floor of three years applies there as well. Both rules are three calendar years, which is why the horizon above is stated in years: retention_years = 3 meets the floor and the ceiling exactly, and the reaper deletes what is older than the same calendar date three years ago. A day count cannot: three calendar years span 1095 or 1096 days, so a floor in days has to be 1096 and a ceiling 1095, and with sgb_v_309_controller = true the server refuses every retention_days value and names retention_years = 3 as the fix. Retention of the trail itself is described in Audit.

What this page does not claim

The registers record decisions and make them checkable. They do not make a deployment compliant with any of the provisions named above: the periods, the grounds, the notifications, the weighing of an objection and the decision to dispose of a record are all the controller’s, and the shared responsibility page says which side of the line each one falls on.

Secondary use

Research data leaves FerroEHR through FerroBRIDGE, which loads the OMOP Common Data Model in batches over AQL. FerroEHR holds the record and the marks that decide what may leave; FerroBRIDGE runs the batch, pseudonymises for research and maps to OMOP. Every edge between the two is a published specification: FerroBRIDGE reads through the openEHR REST API, and FerroEHR keeps no outbox or read model for it.

This page is the contract between the two: what FerroBRIDGE’s batch asks, what FerroEHR answers, and what each side records.

The batch query

FerroBRIDGE’s etl run sends one AQL query per batch through POST /query/aql (ITS-REST 1.1.0, execute_ad_hoc_query):

SELECT e/ehr_id/value AS ehr_id, v/uid/value AS version_uid,
       vo/uid/value AS versioned_object_uid, c AS composition
FROM EHR e CONTAINS VERSIONED_OBJECT vo
     CONTAINS VERSION v[LATEST_VERSION] CONTAINS COMPOSITION c
WHERE v/commit_audit/time_committed/value > $since
ORDER BY v/commit_audit/time_committed/value, v/uid/value
  • $since is the instant of the last row the previous run loaded (etl run --since). The answer holds every composition whose latest version was committed after it: a new composition, and a new version of an older one.
  • The order is the commit instant, then the version uid. Every version of one contribution carries the same time_committed (RM common master06-change_control_package.adoc §Contributions), so the instant alone leaves ties. Under LATEST_VERSION each versioned object answers one version, so the uid makes the order total, the same order as time_committed, vo_id and version number.
  • Paging uses the offset and fetch members of the request. With a total order, consecutive windows concatenate to the whole answer, with no row skipped or repeated.
  • The plan runs the time_committed predicate as a range scan on a BRIN index over the commit instant, so a batch reads the versions committed after $since, not the whole store.

What leaves the batch

The batch is a population query: it names no ehr_id. So the marks on Retention, restriction and objection take an EHR out of it:

  • an EHR or object under a restriction of processing (GDPR Art. 18);
  • an EHR under a research objection (GDPR Art. 21(6)), unless the controller recorded the public-interest override;
  • an EHR whose EHR_STATUS.is_queryable is false (RM ehr master04 §EHR Status).

The rows carry what the query projects. The query above projects the ehr_id, the uids and the composition, and no identifier of the subject: the subject’s identifiers live in EHR_STATUS and in the demographic domain, which the batch does not read.

The integration test secondary_use_batch in app/ferroehr/tests/it/ holds this contract. It commits versions before and after an instant, restricts one EHR and records an objection on another. It then checks that the batch answers exactly the later versions of the unmarked EHR, in order, that its offset windows make up the whole answer, and that no row carries the subject’s identifier.

Removing what was erased

ITS-REST 1.1.0 defines no change notification, so FerroBRIDGE learns of an erasure by asking. Each run reconciles every composition its watermarks name against GET /ehr/{ehr_id}/composition/{versioned_object_uid}. An answer that the composition is gone removes its rows from the CDM. A restriction or an objection set after a row was loaded takes the EHR out of later batches; removing rows already loaded is FerroBRIDGE’s side of the contract.

The access record

Every batch, export and cohort query is recorded in the access log with the purpose of use the caller declared ([audit] purpose_header) and the data permit it acts under ([audit] permit_header, default x-data-permit). A health data access body issues the permit (EHDS Art. 68). The record keeps the permit’s identifier, 1 to 128 characters of letters, digits and . _ : / # -. A value of any other form is recorded as absent, so a header cannot carry free text or personal data into the trail. The permit joins the tamper chain’s digest with the rest of the record.

A deployment that serves secondary use sets both headers on FerroBRIDGE’s requests, and agrees the purpose codes in [audit] purpose_codes.

Where pseudonymisation happens

FerroBRIDGE pseudonymises for research, as the trusted party between the holder and the researcher (EDPB Guidelines 01/2025 on pseudonymisation, §§ 116 to 120 and Example 5). FerroEHR gates the data: the marks above decide what leaves, the access record says who took it under which permit, and cohort queries answer an aggregate question without releasing the rows.

What this page does not claim

FerroEHR is not a health data access body and runs no secure processing environment. Issuing a data permit, checking the request against it, and releasing data to a researcher are the access body’s work (EHDS Chapter IV). FerroEHR records the permit the caller names; it does not check that the permit exists or covers the data.

Control matrix

FerroEHR is software. It is not a controller, not a processor and not a certified organisation, so this page makes no compliance claim on anyone’s behalf. It lists the technical controls the product ships or plans, and the article or clause each one is designed to support. Whether a deployment satisfies a legal obligation depends on how the deploying organisation runs it.

Every row comes from the tracker. A control is declared on the issue that delivers it, as a line in the issue body:

Control: <legal source> <article or clause>

The short name resolves to an official publisher URL from a registry inside the generator, so a legal citation on this page is never free text. An issue may declare several controls, one per line. The Control column carries the issue title, unless the body also states the control on its own line, which the generator prefers so that an issue titled as the defect it fixed does not read its defect as the control:

Control-text: <the control, stated as what the product does>

How this page is built

scripts/render/control-matrix.sh queries the tracker with the GitHub CLI, joins each declared control to its legal source and to its current state, and writes this file. The site deploy renders it from the tracker on every push to main, which is what keeps a shipped control from sitting here as “planned”. A CI job renders it for every pull request and fails the build when the pull request does not carry the rows it changes.

  • Shipped: the issue is closed as completed. The merged pull request that closed it is linked in the last column. The one pull request that closes a control regenerates this page as it will read after the merge, so the page never lags a shipped control.
  • Planned: the issue is open. Whether work has started is the issue’s column on the public roadmap board, which this page does not copy: a status that lives in two places disagrees the day one of them moves.
  • Not planned: the issue was closed without the control being built. The row stays visible so the record does not quietly lose it.

The page carries no generation timestamp and no build commit. Both change on every run or every push while the tracker has not moved, which would make the CI staleness check fail on days when nothing was wrong. When this page was last regenerated, and from which commit, is the file’s own git history.

Controls

Legal sourceApplies toArticle or clauseControlIssueStatusClosing PR
GDPREUArt. 15(1)A natural person cannot obtain their own access log: the only retrieval is admin-gated over the whole repository#3240Shipped#3263
GDPREUArt. 17(1)Physical deletion of a party removes its externalised multimedia blobs#3180Shipped#3201
GDPREUArt. 18A 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#3324Shipped#3410
GDPREUArt. 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#3325Shipped#3410
GDPREUArt. 25(1)Contributor rules for personal data handling and a PR guard for privacy-boundary changes#3167Shipped#3172
GDPREUArt. 25(2)Refuse identifying data on the clinical side and constrain the subject reference to a pseudonym namespace#3154Shipped#3187
GDPREUArt. 25(2)The compliance layer assumes the Netherlands; it must be jurisdiction-pluggable#3185Shipped#3187
GDPREUArt. 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#3237Shipped#3251
GDPREUArt. 25(2)No database constraint holds the subject-reference shape: the CHECK #3154 promised was never built#3241Shipped#3265
GDPREUArt. 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#3346Shipped#3410
GDPREUArt. 32(1)Deploy artifacts provision the demographic role split#3179Shipped#3193
GDPREUArt. 32(1)Row-level security on demographic.national_identifier#3219Shipped#3223
GDPREUArt. 32(1)Schema preparation runs on its own credential, never the clinical runtime role#3224Shipped#3229
GDPREUArt. 32(1)A declared deployment profile: production refuses the postures it cannot prove, research says so in red#3226Shipped#3265
GDPREUArt. 32(1)(a)Split demographic parties into a demographic schema with a non-overlapping runtime role#3153Shipped#3182
GDPREUArt. 32(1)(a)National identifiers in the demographic schema: encrypted storage, keyed lookup, audited resolution#3155Shipped#3189
GDPREUArt. 32(1)(c)Separate encryption keys and per-schema backup handling for the clinical and demographic domains#3157Shipped#3198
GDPREUArt. 32(1)(c)The linkage schema is backed up and its boundary is probed#3220Shipped—
GDPREUArt. 32(1)(d)The storage-parity sweep and node rebuild cover the demographic domain#3178Shipped#3184
GDPREUArt. 32(1)(d)The two-DSN credential separation is exercised by test#3222Shipped#3231
GDPREUArt. 4(5)Split demographic parties into a demographic schema with a non-overlapping runtime role#3153Shipped#3182
GDPREUArt. 4(5)Refuse identifying data on the clinical side and constrain the subject reference to a pseudonym namespace#3154Shipped#3187
GDPREUArt. 4(5)Separate encryption keys and per-schema backup handling for the clinical and demographic domains#3157Shipped#3198
GDPREUArt. 4(5)Linkage service: the party to EHR resolve map as its own schema and role#3158Shipped—
GDPREUArt. 4(5)The subject pseudonym is opt-in: nothing mints it, and an empty subject_namespaces is silent#3232Shipped#3266
GDPREUArt. 4(5)The patient-to-EHR cross-reference in an IHE patient identity source asked over PIXm by default, the in-instance map an explicit opt-in, and no identity logic in the CDR#3380Shipped#4113
GDPREUArt. 5(1)(b)Cross-domain cohort queries with a demographic predicate and a clinical selection#3159Shipped#3275
GDPREUArt. 5(1)(e)Audit retention has no jurisdictional floor: retention_days=1 erases the access log daily while the chain still verifies#3242Shipped#3266
GDPREUArt. 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#3346Shipped#3410
GDPREUArt. 89(1)Secondary use leaves through FerroBRIDGE to OMOP in batches over AQL, with the restriction, objection and retention marks kept in the CDR#3379Shipped#4122
EHDSEUAnnex II 3.2European logging software component: map EHDS logging requirements onto the access event model and ATNA trail#3170Shipped#3205
EHDSEUAnnex II 3.2Auditing off is one info-level line, and fail_mode=open is the silent default: make both visible where an operator looks#3238Shipped#3260
EHDSEUAnnex II 3.2The split clinical role holds no grant on the audit schema, so a separated deployment cannot write its own access log#3267Shipped#3270
EHDSEUAnnex II 3.2(a)The access log records no accessing organisation (EHDS Annex II 3.2(a))#3204Shipped#3213
EHDSEUArt. 9A natural person cannot obtain their own access log: the only retrieval is admin-gated over the whole repository#3240Shipped#3263
EHDSEUChapter IVSecondary use leaves through FerroBRIDGE to OMOP in batches over AQL, with the restriction, objection and retention marks kept in the CDR#3379Shipped#4122
EDPB 01/2025EUpseudonymisation domainSplit demographic parties into a demographic schema with a non-overlapping runtime role#3153Shipped#3182
EDPB 01/2025EUpseudonymisation domainLinkage service: the party to EHR resolve map as its own schema and role#3158Shipped—
EDPB 01/2025EUpseudonymisation domainCross-domain cohort queries with a demographic predicate and a clinical selection#3159Shipped#3275
UAVGNLArt. 46Refuse identifying data on the clinical side and constrain the subject reference to a pseudonym namespace#3154Shipped#3187
UAVGNLArt. 46National identifiers in the demographic schema: encrypted storage, keyed lookup, audited resolution#3155Shipped#3189
WabvpzNLArt. 15eA natural person cannot obtain their own access log: the only retrieval is admin-gated over the whole repository#3240Shipped#3263
NEN 7513NLactor roleThe access record carries no actor role or authorisation basis, which NEN 7513 requires and the RBAC layer already holds#3239Shipped#3262
NEN 7513NLevent contentPer-domain access logging for reads and queries aligned with NEN 7513 and EHDS Art. 9#3156Shipped#3188
NEN 7513NLevent contentDomain-level access events discard their EmitOutcome, so fail_mode=closed does not cover them#3235Shipped#3246
NEN 7513NLretentionAudit retention has no jurisdictional floor: retention_days=1 erases the access log daily while the chain still verifies#3242Shipped#3266
BDSGDE§ 35 Abs. 2 and 3A 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#3324Shipped#3410
SGB VDE§ 309 Abs. 3A 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#3346Shipped#3410
GDNGDE§ 6 Abs. 1A 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#3346Shipped#3410
DSGCHArt. 25 Abs. 2 lit. dA 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#3346Shipped#3410
EPDVCHArt. 10A 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#3346Shipped#3410

The short names above resolve to these publishers. The linked text is the authority; nothing on this page restates it.

EU and INT apply to every deployment. A two-letter country code is national law or a national standard, and applies to a deployment in that country: FerroEHR is an openEHR CDR, openEHR is not a Dutch standard, and a deployment elsewhere answers to its own equivalents rather than to these. Adding a jurisdiction is a registry entry plus the controls that cite it.

Cyber Resilience Act

The manufacturer’s side of the Cyber Resilience Act (CRA, Regulation (EU) 2024/2847) is the same for every FerroHEALTH product: who the manufacturer is, which duties apply from when, the reporting of actively exploited vulnerabilities and severe incidents, the support-period policy, how conformity is assessed through the EHDS, a deployment Cadasto B.V. hosts as a service, who the CRA binds in a self-hosted deployment, and the questions for counsel. That account is published in the FerroHEALTH book: The CRA.

Each FerroHEALTH product is a product with digital elements of its own, with its own risk assessment and its own Annex II information. This page keeps FerroEHR’s.

Warning

No conformity assessment has been carried out, no EU declaration of conformity exists and FerroEHR carries no CE marking. The CRA text this page cites is vendored at docs/law/eu/cra/text.html (EUR-Lex).

The manufacturer and the product

Cadasto B.V. is the manufacturer of each tagged FerroEHR release (The manufacturer). Each tagged release is one product: the ferroehr binaries, the ferroehr, ferroehr-viewer and ferroehr-postgres container images, the Helm chart and the source archive. The nine openehr-* crates published on crates.io are products of their own; the information for their integrators is on Rust crates.

Vulnerabilities in FerroEHR are reported as SECURITY.md says.

FerroEHR’s CRA documents

  • CRA risk assessment: the cybersecurity risks of FerroEHR, and how each point of CRA Annex I applies to it, with its evidence and its open work.
  • CRA information and instructions to the user: the Annex II information for FerroEHR, point by point. The notes of each GitHub release link the copy of that page frozen for the release, and the release lane refuses to publish a release whose notes do not.
  • Technical documentation: the single set for the EHDS and the CRA (CRA Art. 31(3) as EHDS Art. 104(2) replaces it): the EHDS Annex III elements of the EHR system in the FerroHEALTH book, at the revision each release pins, and FerroEHR’s CRA Annex VII points in the repository.
  • Hazard log: the patient-safety hazards of the access log, the EHDS part of the risk assessment.

FerroEHR’s product facts under the CRA

  • Not free and open-source software. FerroEHR is published under the Business Source License 1.1, whose Additional Use Grant allows production use for non-commercial purposes only, so it is not free and open-source software in the sense of CRA Art. 3(48) (licensing).
  • Only the newest release is supported. Every earlier release stops receiving fixes when a new one is published, and the remedy is to upgrade, which is free. Cadasto B.V. commits to keep publishing security fixes for at least five years after each release (Art 13(8)) and ships them only in the newest release (Art 13(10)). The end month of that commitment is printed in each release’s notes, and the statement is SECURITY.md § Supported versions. The reasoning is in the technical documentation (cra-annex-vii-4-support-period.md).
  • The conformity route. Cadasto B.V. declares FerroEHR, with FerroBRIDGE, as an EHR system under the EHDS, so the CRA requirements are assessed through the EHDS procedure (CRA Art. 32(5a), inserted by EHDS Art. 104(3)). The status per EHDS requirement is on EHDS readiness.

Default product, or important product of class I

Cadasto B.V. reads FerroEHR as a default product: its core functionality is storing, versioning and querying health records (intended purpose), and none of the categories of CRA Annex III names that function. The competing reading is Annex III class I point 1, “identity management systems and privileged access management software and hardware, including authentication and access control readers”: the technical description in Implementing Regulation (EU) 2025/2392 Annex I covers products “that provide mechanisms for authentication or authorisation”, and FerroEHR authenticates and authorises every request to the records it holds. Class I point 7, security information and event management, describes products that “collect data from multiple sources”; FerroEHR’s access log records FerroEHR’s own accesses.

For a class I product whose manufacturer has not applied harmonised standards, common specifications or a certification scheme in full, CRA Art. 32(2) requires a third-party procedure. This is a question for counsel, and nothing in this repository answers it.

CRA information and instructions to the user

The Cyber Resilience Act (CRA, Regulation (EU) 2024/2847) has each product “accompanied by the information and instructions to the user set out in Annex II, in paper or electronic form” (CRA Art. 13(18)). This page carries that information for FerroEHR, point by point in the order of Annex II. Each section quotes what the point asks and gives FerroEHR’s information for it, with the source the information is kept in.

Annex II opens: “At minimum, the product with digital elements shall be accompanied by:”. The product is each tagged FerroEHR release, as Cadasto B.V. places it on the market: the ferroehr binaries, the ferroehr, ferroehr-viewer and ferroehr-postgres container images, the Helm chart and the source archive (the manufacturer and the product).

Warning

The quotations are from the Official Journal text vendored in the repository at docs/law/eu/cra/text.html (EUR-Lex). No conformity assessment has been carried out, no EU declaration of conformity exists and FerroEHR carries no CE marking. This page gives the information Annex II lists; it does not say that FerroEHR, or a deployment of it, meets the Regulation.

Which copy to read

This book is frozen with every release, so the copy of this page that belongs to release vX.Y.Z is https://ferroehr.eu/docs/vX.Y.Z/compliance/cra-user-information.html. The notes of each GitHub release link that copy, and the release lane refuses to publish a release whose notes do not. The copy at /docs/dev/ describes the code on main, which is not a release.

CRA Art. 13(18) has information provided online kept “accessible, user-friendly and available online for at least 10 years after the product with digital elements has been placed on the market or for the support period, whichever is longer”. A frozen book version is never rebuilt or deleted, and each release tag, which carries the page’s source, is protected against deletion (SECURITY.md).

The information is written in English. Art. 13(18) asks for “a language which can be easily understood by users and market surveillance authorities”. No translation is published; a market surveillance authority that needs one asks the single point of contact below.

1. The manufacturer

“the name, registered trade name or registered trademark of the manufacturer, and the postal address, the email address or other digital contact as well as, where available, the website at which the manufacturer can be contacted;”

ManufacturerCadasto B.V.
Postal addressComeniusstraat 2d, 1817 MS Alkmaar, The Netherlands
Emailinfo@cadasto.com
Websitehttps://www.cadasto.com/contact/

The source of these details is app/ferroehr/src/manufacturer.rs, the one place they are written in the code. The running server reads them from there for its startup banner, ferroehr --version and GET /management/info, the viewer shows them on every screen, and the container images carry them in the eu.ferroehr.image.manufacturer label (the manufacturer and the deployment report). The information sheet repeats them. Cadasto B.V. is the manufacturer of every FerroHEALTH product, and the FerroHEALTH book describes it once for all of them: The manufacturer.

2. The single point of contact for vulnerabilities

“the single point of contact where information about vulnerabilities of the product with digital elements can be reported and received, and where the manufacturer’s policy on coordinated vulnerability disclosure can be found;”

  • Report a vulnerability to info@cadasto.com, with “FerroEHR vulnerability” in the subject, or through GitHub private vulnerability reporting. Both reach the same people and the same procedure. Do not open a public issue.
  • Receive information about vulnerabilities from the GitHub security advisories of the repository, published with the release that fixes each one and readable by machine through GET https://api.github.com/repos/FerroHEALTH/FerroEHR/security-advisories.
  • The coordinated vulnerability disclosure policy is the manufacturer’s, shared by every FerroHEALTH product: the response times, the agreed disclosure date, the safe harbour, the credit and what each advisory carries are on Security in the FerroHEALTH book. What is particular to FerroEHR, the reporting routes and the scope, is SECURITY.md § Reporting a vulnerability, also published as security.txt under RFC 9116.

3. Name, type and unique identification

“name and type and any additional information enabling the unique identification of the product with digital elements;”

  • Name: FerroEHR.
  • Type: software. A clinical data repository server that stores, versions and queries openEHR health records over the openEHR REST API and AQL.
  • Version: the release tag vX.Y.Z. The running server prints it in ferroehr --version and returns it as server_version on GET /ferroehr/rest/status; ferroehr report adds the commit and the build date (ferroehr report).
  • The artefacts of a version: the release assets carry a checksum and a signed build provenance each, and the container images are identified by digest, with the version and the commit in their org.opencontainers.image.version and org.opencontainers.image.revision labels (verifying releases).
  • Release date: the date on the release’s heading in the changelog, ## [X.Y.Z] - YYYY-MM-DD (information sheet, (b)).

4. Intended purpose, security environment, essential functionalities and security properties

“the intended purpose of the product with digital elements, including the security environment provided by the manufacturer, as well as the product’s essential functionalities and information about the security properties;”

  • Intended purpose: FerroEHR stores, versions and queries the structured health records of one healthcare provider, serves them to that provider’s clinical applications through the openEHR REST API and AQL, and records every access to them. FerroEHR’s part of the statement, with the data it is designed to process, is the intended purpose; the statement for the EHR system FerroEHR forms with FerroBRIDGE, with its users, is in the FerroHEALTH book (intended purpose).
  • Security environment: what FerroEHR relies on and does not provide itself: TLS termination, an OpenID Connect identity provider, protection of the database, a hardened platform and off-box copies of the access log (the security environment FerroEHR assumes).
  • Essential functionalities: listed in the intended-purpose statement (what FerroEHR is for).
  • Security properties: each point of CRA Annex I Part I(2), with how FerroEHR implements it and the evidence, is in the CRA risk assessment. The controls themselves are described on Security, and the risk that remains at each trust boundary in the threat model.

5. Circumstances that may lead to significant cybersecurity risks

“any known or foreseeable circumstance, related to the use of the product with digital elements in accordance with its intended purpose or under conditions of reasonably foreseeable misuse, which may lead to significant cybersecurity risks;”

  • Use in accordance with the intended purpose: the risk register of the CRA risk assessment rates each risk with the shipped controls, and the limitations you should know name the ones that matter before you rely on FerroEHR, among them the shipped defaults that favour a first boot and the reach of anyone with a database connection.
  • Reasonably foreseeable misuse: the foreseeable misuse table lists each case (real patient data under the sandbox profile, authentication off, a plaintext listener, several organisations in one instance, a release run after a newer one is published, and others), what FerroEHR does against it, and what remains.
  • What is not defended against: the threat model lists it.

6. The EU declaration of conformity

“where applicable, the internet address at which the EU declaration of conformity can be accessed;”

No EU declaration of conformity has been drawn up, so there is no address to give. The declaration and the CE marking apply from 11 December 2027 (CRA Art. 71(2)). For FerroEHR, which Cadasto B.V. declares with FerroBRIDGE as an EHR system under the EHDS, the CRA asks for one declaration covering both acts (CRA Art. 28(3)). When it exists, it is published as an asset of each GitHub release it covers and as a page of the book frozen for that release, and this section gives its address. The record of the declaration is Annex III 6 of the EHR system’s technical documentation in the FerroHEALTH book.

7. Technical security support and the end of the support period

“the type of technical security support offered by the manufacturer and the end-date of the support period during which users can expect vulnerabilities to be handled and to receive security updates;”

  • Only the newest release is supported. When a new release is published, every earlier release stops receiving fixes: there are no maintenance branches, no long-term-support line and no backports. If you are not on the newest release, upgrade; upgrading is free. Under the Cyber Resilience Act, Cadasto B.V. commits to keep publishing security fixes for at least five years after each release (Art 13(8)) and ships them only in the newest release (Art 13(10)).
  • The end date, month and year, is the end of that five-year commitment for a release: the date until which Cadasto B.V. keeps shipping fixed releases. It is printed in the “Support period” section of the release’s notes, and the running server states it on its boot banner, in ferroehr --version, under support in GET /management/info and as support.status in GET /management/status (how long the version you pinned is supported). A release itself is supported only while it is the newest.
  • The type of support: Cadasto B.V. handles vulnerabilities in FerroEHR, and the security update ships in the newest release, as a security-only patch where that is technically feasible. Every fixed vulnerability gets a GitHub security advisory with its severity, the affected and fixed versions, and what to do.
  • The source: SECURITY.md § Supported versions, which also covers the Helm chart and the openehr-* crates, and the reasoning in the technical documentation (cra-annex-vii-4-support-period.md).

8. Detailed instructions

“detailed instructions or an internet address referring to such detailed instructions and information on:”

The detailed instructions are the instructions for use of the EHR system, which the FerroHEALTH book carries and which ride each GitHub release as ferroehr-vX.Y.Z-instructions-for-use.md, at the revision the release pins and with its versions appended. Each point below names the part that answers it.

8(a) Secure use from commissioning on

“the necessary measures during initial commissioning and throughout the lifetime of the product with digital elements to ensure its secure use;”

8(b) How changes to the product can affect the security of data

“how changes to the product with digital elements can affect the security of data;”

  • Configuration. The shipped defaults favour a first boot: the sandbox profile, TLS off, the open per-EHR access default and the access log failing open. The production profile refuses to start while one of the separations it checks is open and not accepted by name, and a gap you accept by name is stated on every boot and on GET /management/status (deployment_profile). A configuration change that opens such a gap shows there.
  • Integrations. Every outbound integration is off by default. Turning one on sends data to another system, and the outbound data flows table says what each one sends and where.
  • Upgrades. A release can carry database migrations. They are append-only, and a rolling upgrade stays compatible with the previous schema for the window in which both versions run; take a backup first (upgrades).
  • Changes to the source. A binary or image built from modified source is not a Cadasto B.V. release: its build provenance and signatures do not verify, and the risk assessment of the release does not describe it. Whether an organisation that modifies the source and puts the result into service becomes a manufacturer itself is a question for counsel (the CRA, the manufacturer side).

8(c) Installing security-relevant updates

“how security-relevant updates can be installed;”

Subscribe to the release feed (https://github.com/FerroHEALTH/FerroEHR/releases.atom) and poll the security advisories; the server does not look for newer releases itself. Verify the new release (verifying releases), take a backup, then deploy it by digest and let it apply its migrations (upgrades). On Kubernetes that is a helm upgrade to the chart version that ships the new release (Kubernetes & Helm, upgrades).

8(d) Secure decommissioning, and removing user data

“the secure decommissioning of the product with digital elements, including information on how user data can be securely removed;”

Decommissioning takes an instance out of service: what to export and keep first, ferroehr db erase with its dry run and confirmation, which removes the multimedia blobs and every schema with all data and settings, and what the erase cannot reach and you remove yourself (backups, WAL archives, replicas, the chart’s Secrets and claims, the database roles, configuration files, the data held by systems the instance sent to, and the physical media). The command and the page are the deliverables of #3642. To start again from an empty instance of the same release, follow returning to the original state.

8(e) Turning off automatic installation of security updates

“how the default setting enabling the automatic installation of security updates, as required by Part I, point (2)(c), of Annex I, can be turned off;”

FerroEHR installs no update by itself, so there is no such default setting to turn off. Annex I Part I(2)(c) asks for automatic security updates “where applicable”, and the risk assessment records why the limb does not apply to FerroEHR: it is a clinical server run under its operator’s change control, an upgrade can carry a database migration the operator schedules and backs up for, an update installed on the manufacturer’s schedule would restart the service at a moment no clinician or operator chose, and a channel into the deployment for the manufacturer would be an inbound path into a system holding special-category data (the justification). The operator installs each update as described in 8(c).

8(f) Information for an integrator

“where the product with digital elements is intended for integration into other products with digital elements, the information necessary for the integrator to comply with the essential cybersecurity requirements set out in Annex I and the documentation requirements set out in Annex VII.”

The FerroEHR server, its images and its chart are not intended for integration into another product with digital elements. Other software connects to a running instance over the openEHR REST API, as FerroBRIDGE does.

The nine openehr-* crates published on crates.io are libraries intended for integration into other products, and each is a product of its own. The information for their integrators is on Rust crates § Information for integrators.

9. The software bill of materials

“If the manufacturer decides to make available the software bill of materials to the user, information on where the software bill of materials can be accessed.”

Cadasto B.V. makes the software bills of materials available. Each release publishes a CycloneDX SBOM per server binary, an SPDX SBOM of the source tree, an SBOM on each container image, a CycloneDX SBOM per openehr-* crate and an SBOM attested on the Helm chart. Where each one is and how to verify it is in the SBOMs, one per published artefact.

Complaints, incidents and vulnerabilities

Cadasto B.V. is the manufacturer of every FerroHEALTH product, FerroEHR included, and runs one post-market procedure for all of them: complaints, serious incidents under the EHDS, actively exploited vulnerabilities and severe incidents under the CRA, corrective action, withdrawal and recall, security advisories, vulnerabilities in integrated components, cooperation with the authorities and the cessation of operations. That procedure, with its quotations from the regulations, is published in the FerroHEALTH book: Post-market. The manufacturer’s name, address and single point of contact are on The manufacturer.

This page keeps what is particular to FerroEHR.

Reporting about FerroEHR

  • A vulnerability, including one you have seen exploited: report it privately through GitHub private vulnerability reporting on the FerroEHR repository, or by email to info@cadasto.com with “FerroEHR vulnerability” in the subject, as SECURITY.md says. If you have seen it exploited, say so.
  • Anything that harmed a person, or could have: write to info@cadasto.com with “FerroEHR incident” in the subject.
  • Any other complaint: write to info@cadasto.com with “FerroEHR complaint” in the subject, or open a GitHub issue when the report can be public.

Say which version you run (ferroehr --version, or the image tag) and how it is deployed. Never send patient data: describe the case, or build a synthetic one. A deployment attaches the JSON document ferroehr report writes: it names the version, commit, features, specification pins, migration level, deployment posture and redacted configuration, and carries no credential and no patient data (the command).

FerroEHR’s registers

The registers of EHDS Art. 30(1)(o) and the upstream register of CRA Art. 13(6) are kept per product. FerroEHR’s are public, in its repository:

  • docs/registers/complaints.tsv: every complaint about FerroEHR, whatever the channel, with the versions it concerns, how it was classified, the record of the work on it and its outcome.
  • docs/registers/non-conforming-versions.tsv: every finding that released FerroEHR versions do not conform, with the requirement missed, whether it was a serious incident or notified under the CRA, the corrective action, and when the authorities and the users were told.
  • docs/registers/upstream-reports.tsv: every vulnerability reported to the maintainer of a component FerroEHR integrates, with the channel, the upstream reference, whether a fix was shared and the outcome.

No row names the person who complained or carries patient data, and the upstream register names components, never people. A vulnerability enters the registers when its advisory is published.

Possible serious incidents in FerroEHR

The manufacturer treats these FerroEHR events as possible serious incidents under EHDS Art. 2(2)(r): a committed version lost or attributed to the wrong EHR; a read or an AQL result that returns another patient’s data; data under a restriction of processing served on a path that should refuse it; an access the audit trail did not record; a signature verification that accepts a tampered version.

A withdrawn FerroEHR release

A published FerroEHR release is immutable, so a non-conforming version is never changed or deleted. The fix ships in a new patch release. A withdrawn version’s floating image tags (<major>.<minor> and latest of the server, viewer and PostgreSQL images) move to the correcting release, and the version is listed as unsupported in SECURITY.md § Supported versions. Its own version tag, its image digests and its chart version stay published, so a deployment pinned to any of them keeps running the withdrawn version until the deployment moves.

Security advisories for FerroEHR are GitHub security advisories on the FerroEHR repository, and the ### Security entry of its changelog carries each advisory’s GHSA- identifier.

The maintainers’ step-by-step checklist for FerroEHR, with the release tooling it runs, is docs/post-market.md. The technical documentation readiness page shows what of the documentation an authority may ask for exists today.

Intended purpose

Cadasto B.V. states the intended purpose of the EHR system that FerroEHR and FerroBRIDGE form together, under the EHDS (Regulation (EU) 2025/327) and the Cyber Resilience Act (CRA, Regulation (EU) 2024/2847), once for both products: the statement and its version, who uses the system, how it is operated (self-hosted, or hosted by Cadasto B.V.), and what the intended purpose excludes, the medical-device purposes of the MDR among them. That statement is published in the FerroHEALTH book: The EHDS EHR system: intended purpose.

This page keeps FerroEHR’s part of it: its essential functions, the data it is designed to process, the environment it runs in and relies on, and the use and misuse that can be foreseen for it (CRA Annex II point 4, EHDS Annex III 1(b)). The CRA risk assessment, the hazard log and the claims review are written against it.

Warning

The quotations are from the Official Journal texts vendored in the repository under docs/law/eu/. This page says what the manufacturer intends. It does not say that FerroEHR, or a deployment of it, meets either regulation: no conformity assessment has been carried out and no EU declaration of conformity exists.

What FerroEHR is for

FerroEHR stores, versions and queries the structured health records of one healthcare provider, and serves them to the software that provider’s health professionals and patients use. It holds each record as openEHR compositions with their full version history, and serves them through the openEHR REST API (ITS-REST Release-1.1.0) and the Archetype Query Language (AQL 1.1). It has no clinical user interface of its own: health professionals reach it through the clinical applications the provider connects to it.

FerroEHR records every access to the records it holds in its access log, the European logging software component of EHDS Art. 2(2)(o).

Its essential functions, in the sense of CRA Annex II point 4:

  • storing EHRs, compositions, EHR status and folders with an append-only version history and contribution-atomic commits (storage);
  • answering AQL queries over the stored records (querying);
  • validating every write against the operational template it claims and the Reference Model invariants (templates and validation);
  • keeping the identity of the record subject apart from the clinical record, in a separate demographic domain and a separate linkage domain (the pseudonymisation boundary);
  • authenticating every caller and deciding every request (authentication, authorization);
  • recording every access, refusals included, and serving the log back through IHE ITI-81 (audit trail);
  • restriction of processing, the research objection and the retention register (retention, restriction and objection);
  • physical deletion, whole-repository dump and load, and EHR Extract export (admin APIs).

The security properties that go with these functions are on the Security page; the risk that survives each control is in the threat model.

The data FerroEHR is designed to process

EHDS Annex III 1(b) and Art. 38(2)(d) ask for “the categories of personal electronic health data that the EHR system has been designed to process”.

Clinical content, in the clinical domain: whatever the operational templates the deploying organisation loads describe. This includes the priority categories of EHDS Art. 14(1): “(a) patient summaries; (b) electronic prescriptions; (c) electronic dispensations; (d) medical imaging studies and related imaging reports; (e) medical test results, including laboratory and other diagnostic results and related reports; and (f) discharge reports”, and any category a Member State adds in national law. Which of them an instance holds depends on the templates it loads, and the deployment declares that in the [audit.categories] map, which classifies every access record. FerroEHR ships no such map. Images are held as DV_MULTIMEDIA attachments, optionally offloaded to an S3-compatible store (S3 multimedia), or as references to an imaging archive; FerroEHR implements no DICOM image transfer.

Identities, in the demographic domain: parties (persons, organisations, roles) and their identifiers, with national identifiers optionally sealed under AES-256-GCM (the sealed identifier).

The link between the two, in the linkage domain: which party an EHR belongs to.

The access log: who accessed which record, when, from which origin and in which priority category. It names health professionals and patients.

Operating data: the configuration, metrics, traces and logs, which carry no identified data (telemetry), and the daily usage report, which carries instance facts and coarse performance aggregates and no patient data.

The operational environment

Each organisation gets its own FerroEHR instance, whether it runs FerroEHR itself or Cadasto B.V. hosts it; the two operating models are described in the FerroHEALTH book. The public sandbox at sandbox.ferroehr.eu is a demonstration holding demo data, wiped nightly.

  • One instance per organisation. FerroEHR is single-tenant: several organisations are served by several instances, each with its own database and its own database roles (one instance, one organisation).
  • Runtime. The distroless, non-root container image on Kubernetes through the Helm chart, on Docker Compose, or the binary built from source, on Linux (amd64 or arm64) (installation).
  • Database. A PostgreSQL 18 cluster the deploying organisation provisions; the Helm chart deploys none.
  • Profile. deployment_profile = "production", which refuses to start while a separation is open and not accepted by name (go-live checklist). The shipped default is sandbox, which must not hold real personal data and says so on the banner, in the log and on GET /rest/status.
  • Expected time in use. Longer than five years, which is why Cadasto B.V. commits to keep publishing security fixes for at least five years after each release, shipped only in the newest release (SECURITY.md).

The security environment FerroEHR assumes

These are the parts of the environment the manufacturer designs FerroEHR to rely on and does not provide itself (CRA Annex II point 4). The threat model names each as a trusted or semi-trusted actor.

  • TLS termination. Every connection to the API is encrypted, either by the server’s own listener ([server.tls], TLS 1.3 by default, mutual TLS available) or by an ingress or proxy in front of it. The shipped default leaves [server.tls] unset; the hop behind a proxy is the deploying organisation’s, and the server cannot see whether it is encrypted (B1 network and protocol, server configuration).
  • An identity provider. Users and their credentials live in the deploying organisation’s OpenID Connect provider, which issues the tokens FerroEHR verifies. FerroEHR issues no credentials and manages no identity lifecycle, and a compromised identity provider defeats every control below it (enterprise identity providers, authentication). Basic authentication against a configured list is for service accounts and development.
  • Database protection. The database is reached only by FerroEHR’s own credentials, one per domain and each least-privilege, over a network path restricted to the server, with TLS to the database, encryption at rest at the volume layer, and backups taken per domain. Anyone with a database connection is past every API control (B6 the database, database roles, TLS and database security, backup and recovery).
  • The platform. A hardened cluster or host, operators the organisation trusts, and network policy around the instance (cluster hardening: the cluster, images, the workload, network and policy, secrets, detection and response).
  • Off-box copies. A forwarding sink for the access log that the server’s identity cannot rewrite, and a monitored backup (B7 the audit trail).

Reasonably foreseeable use and misuse

The CRA distinguishes “reasonably foreseeable use”, use that “is not necessarily the intended purpose” but “is likely to result from reasonably foreseeable human behaviour or technical operations or interactions” (Art. 3(24)), from “reasonably foreseeable misuse”, use “not in accordance with its intended purpose, but which may result from reasonably foreseeable human behaviour or interaction with other systems” (Art. 3(25)).

Foreseeable uses the manufacturer accepts: a teaching or research instance holding synthetic or pseudonymised data; population queries over a pseudonymised cohort, which FerroEHR serves with small-cell suppression (cohort queries); the store behind a patient-facing service of the provider; a test system for an application vendor.

Foreseeable misuse, and what FerroEHR does about it:

MisuseWhat FerroEHR doesWhat remains
Real patient data in an instance running the shipped sandbox profilestates the profile on the banner, in the log, on GET /rest/status and in the viewernothing refuses it; the Helm chart and the Compose files declare sandbox explicitly, and the chart’s values say what a deployment holding patient data sets
The quickstart Compose file in production, with its development user and RBAC offthe README and the Compose file call these development defaults; production refuses RBAC off unless rbac_off is accepted by namenothing refuses it under sandbox; the go-live checklist is the control
auth.enabled = false on a reachable networkproduction refuses it at boot unless auth_off is accepted by name (#3638)an accepted gap is stated on every boot; the sandbox profile does not refuse it
A plaintext listener on a routable address with no TLS ingress in frontproduction refuses it at boot unless plaintext_listener is accepted by name; a loopback bind is exempt (#3638); a separate management listener (management.port), which binds every interface in plain HTTP, opens the same gap (#3668)a deployment behind a TLS-terminating ingress accepts plaintext_listener, and nothing checks that the ingress exists or that the management port is unreachable off the host
Several organisations in one instanceno tenant model exists to misconfigure; one instance per organisation is documentednothing in the software can detect it (B5)
Real patient data on the public sandboxthe sandbox is labelled as demo data and wiped nightlynothing refuses it
The access log switched off, or failing open, in productionproduction refuses audit_off and audit_fails_open unless each is accepted by namean accepted gap is stated on every boot, and the organisation owns it
The access log shipped over plaintext syslogthe syslog feed sends over RFC 5425 TLS only, through ihe-atna (#3887)none for the feed; the repository’s own TLS configuration is the deployment’s
Change events published to a plaintext amqp:// broker in productionproduction refuses an enabled change-event outbox on an amqp:// URL without tls = true unless plaintext_broker is accepted by name (#3676)an accepted gap is stated on every boot; nothing checks that the broker path stays on a trusted segment
Reads and writes straight against the databasethe domain roles limit what each credential reachessuch access bypasses authorisation and the access log (B6)
Structural validation taken for clinical correctnessvalidation checks structure, invariants and terminology bindings onlyclinical governance is the organisation’s (threat model)
openEHR conformance results, or these pages, taken for regulatory conformitythe compliance overview says FerroEHR holds no certification or declarationnone
A release run after a newer one is publishedonly the newest release is supported (SECURITY.md), and the release feed and advisories announce each new one; the end month of the five-year commitment is in every release’s notes and the running server states it on the boot banner, in ferroehr --version, on GET /management/info and GET /management/status, and logs a warning at boot and daily once it has passed (#3639)nothing refuses to run; the server does not look for newer releases

What the intended purpose excludes

The exclusions that hold for the whole EHR system, the medical-device purposes of the MDR first, are in the FerroHEALTH statement. For FerroEHR they mean:

  • No diagnosis or advice. FerroEHR stores and returns records as written; it computes no diagnosis, score, alert or treatment advice.
  • Hosting for several organisations. One instance serves one organisation.
  • Identity management. FerroEHR verifies identities that another system issues.
  • The interoperability component. Exchange in the European electronic health record exchange format is FerroBRIDGE’s part of the EHR system.

CRA risk assessment

This page is the cybersecurity risk assessment Cadasto B.V., the manufacturer of each tagged FerroEHR release, keeps under the Cyber Resilience Act (Regulation (EU) 2024/2847). CRA Art. 13(2) has the manufacturer “undertake an assessment of the cybersecurity risks associated with a product with digital elements and take the outcome of that assessment into account during the planning, design, development, production, delivery and maintenance phases”. Art. 13(3) says what the assessment holds: “an analysis of cybersecurity risks based on the intended purpose and reasonably foreseeable use, as well as the conditions of use”, and an indication of “whether and, if so in what manner, the security requirements set out in Part I, point (2), of Annex I are applicable to the relevant product with digital elements and how those requirements are implemented”, and of how Part I point (1) and the vulnerability handling of Part II are applied. Where a requirement does not apply, Art. 13(4) asks for “a clear justification to that effect” in the technical documentation. The assessment is point 3 of the technical documentation of CRA Annex VII.

The page is versioned with the release: this book is frozen with every release at /docs/vX.Y.Z/, so each release’s documentation carries the assessment that applied to it. The statuses below are those of revision 2 of the assessment, 2026-10-07 (the table at the end lists the revisions).

Warning

Every quotation is from the Official Journal text vendored in the repository at docs/law/eu/cra/text.html (EUR-Lex); none of its three corrigenda touches Article 13 or the Annexes cited here. EHDS Art. 104 amends CRA Art. 13(4) and inserts Art. 32(5a); those changes are read from docs/law/eu/ehds/text.html. No conformity assessment has been carried out and no EU declaration of conformity exists. A status on this page describes the evidence the repository holds, never conformity.

Scope and dates

  • The products assessed. Each tagged release: the ferroehr binaries, the ferroehr, ferroehr-viewer and ferroehr-postgres images, the Helm chart and the source archive. The nine openehr-* crates published on crates.io under Apache-2.0 are products of their own; this page covers them only where a row says so.
  • Who the requirements bind. Annex I Part I sets properties of the product, which the manufacturer ensures (CRA Art. 6(a), Art. 13(1)). Part II sets the manufacturer’s processes; its chapeau reads “Manufacturers of products with digital elements shall:”. A deploying organisation that runs an unmodified release is the addressee of neither.
  • When. Annex I, Art. 13 and Art. 32 apply from 11 December 2027; Art. 14 from 11 September 2026 (CRA Art. 71(2)). The assessment runs now so that the gaps are closed before that date.
  • The EHDS route. Cadasto B.V. declares FerroEHR with FerroBRIDGE as an EHR system. For such a product, CRA Art. 32(5a), inserted by EHDS Art. 104(3), routes the Annex I conformity through the EHDS Chapter III procedure, and CRA Art. 13(4) as EHDS Art. 104(1) replaces it lets this assessment “be part of the risk assessment required by those Union legal acts”. The hazard log is that EHDS-side assessment for the logging component, and the technical documentation carries both.

The basis: intended purpose, use and environment

The intended-purpose statement is the basis Art. 13(3) asks for. In one paragraph: FerroEHR stores, versions and queries the structured health records of one healthcare provider, serves them to that provider’s clinical applications through the openEHR REST API and AQL, and records every access. It runs one instance per organisation, self-hosted by the organisation or hosted by Cadasto B.V. as a service, on a PostgreSQL 18 cluster provisioned for that instance, behind TLS, with users in the organisation’s identity provider. Its foreseeable misuse (the sandbox profile holding real data, authentication off, a plaintext listener, several organisations in one instance) is listed on that page with what the product does against each.

The assets are those of the threat model: the clinical payload, the demographic parties and their identifiers, the EHR-to-subject link, the access log, the version history, the signing keys, the credentials, instance separation and availability.

The expected time in use is longer than five years, so Cadasto B.V. commits to keep publishing security fixes for at least five years after each release, shipped only in the newest release, which is the one release supported (CRA Art. 13(8) and (10); SECURITY.md carries the reasoning, which is Annex VII point 4).

How the risks were analysed

CRA Art. 3(37) defines a cybersecurity risk as “the potential for loss or disruption caused by an incident”, “expressed as a combination of the magnitude of such loss or disruption and the likelihood of occurrence of the incident”. The analysis starts from the threat model, which walks eight trust boundaries (B1 to B8) and states the control and the residual risk at each. The register below rates each risk on the intended purpose and the shipped controls: the magnitude by the asset it reaches, the likelihood by what an actor needs to cause it. The ratings are the manufacturer’s judgement, on three levels (high, medium, low).

#RiskMagnitudeLikelihood with the shipped controlsControlsAnnex I Part I(2)
C1An unauthenticated caller reads or writes clinical datahighlow under the intended purpose; high when auth.enabled = false is misused under sandbox or accepted by name under productionauthentication on by default with a boot refusal when no mechanism is configured; production refuses auth.enabled = false unless auth_off is accepted by name; per-request authorisation(b), (d)
C2An authenticated caller reaches EHRs outside their remithighmedium: the shipped EHR_ACCESS default is open, which production refuses unless accepted by nameRBAC, ABAC, per-EHR EHR_ACCESS; production refuses the role gate off (rbac_off) and an open EHR_ACCESS default (open_ehr_access_default) unless each is accepted by name; every access recorded(d), (l)
C3Data in transit is read or altered on the networkhighlow behind a TLS ingress or with the server’s TLS; high on a plaintext listenerrustls TLS 1.3 and mutual TLS, off by default; production refuses a plaintext main listener on a routable address, a separate plain-HTTP management listener, and an AMQP integration on a plaintext amqp:// broker, unless plaintext_listener or plaintext_broker is accepted by name; the syslog audit feed sends over TLS only (ihe-atna); a boot warning on a plaintext routable bind(e)
C4Someone with a database connection reads, rewrites or deletes data, bypassing the APIhighdepends on the deploying organisation’s database protectionleast-privilege roles per domain; append-only versions; version signatures verified on read; the hash-chained access log(e), (f)
C5An access happens and the log does not show it, or the log is alteredhighlow with fail_mode = "closed" and an off-box sink; medium with the shipped fail_mode = "open"refusals always recorded; production refuses audit off and audit failing open unless accepted; the hash chain(f), (l)
C6A national identifier or identifying content leaks into the clinical record, telemetry or a reporthighlowthe identifier scanner in strict mode; log sanitising; reports with no identifiers(e), (g)
C7A malformed or hostile payload crashes or corrupts the servermediumlowmemory-safe Rust with unsafe forbidden; overflow checks in release; fuzzed parsers; caught panics(j), (k)
C8The service is made unavailablehigh (clinical availability)medium: an authorised caller holding resources is not defended againstadmission limit with 503 shedding; rate limits; body, header and statement limits; replicas and a disruption budget(h)
C9A vulnerable component ships in a releasehighmediumcargo deny advisories gate; image scan before tagging; weekly re-scan; Dependabot; VEX for argued findings(a)
C10A tampered artefact reaches a deploymenthighlowSLSA Build L3 provenance; SBOM attestations; signed tags; immutable releases; a signed chart(f)
C11Data stays recoverable after the organisation removes itmediummedium: the erase removes every schema and blob, and backups, WAL, replicas and the media stay the organisation’s to removephysical delete of EHRs; ferroehr db erase for all data and settings; the decommissioning page(m)
C12An outbound integration sends more than its purpose needs, or to the wrong placemediumlow: every integration is off by defaultthe flows table below(g), (i)

Annex I Part I(1): the level of cybersecurity

“Products with digital elements shall be designed, developed and produced in such a way that they ensure an appropriate level of cybersecurity based on the risks.” The manufacturer applies it through the register above: each risk has a control, and each control is traced to a point of Part I(2) below. The text names no measures, so the level is set against the risks of a clinical data repository holding special-category data, where disclosure cannot be undone and unavailability is a patient-safety matter.

Annex I Part I(2), point by point

The chapeau reads: “On the basis of the cybersecurity risk assessment referred to in Article 13(2) and where applicable, products with digital elements shall:”. Each entry gives the requirement, whether it applies, how FerroEHR implements it, the evidence, the status (met, partial or gap, describing the evidence only) and the open work.

(a) No known exploitable vulnerabilities

“be made available on the market without known exploitable vulnerabilities”

  • Applies: yes.
  • How: cargo deny fails the build on any RustSec advisory and on yanked crates, with each accepted advisory dated and reasoned in deny.toml. Each of the three images is scanned by Trivy by its pushed digest, on both platforms, before it is tagged, so only a passing digest gets a tag, and the published images are re-scanned weekly. Every shipped binary, in the release tarballs and in the images, is built with cargo auditable, so it carries its Rust dependency list and an image scan sees the crate graph. Every release also scans each image and server binary with no severity floor and unfixed findings included, joins every finding with its OpenVEX statement, and is refused, before any image is tagged, while a finding has none; the joined record and the raw reports are release assets. The Rust advisory statements are checked against the resolved dependency graph, and an operating-system statement that argues from what a binary loads carries that linkage as data, which the release lane reads off both Rust binaries on both architectures and refuses a contradiction of (#3694).
  • Evidence: deny.toml; trivy.yaml; .github/workflows/scan-and-tag.yml; .github/workflows/image-scan.yml; security/vex/; scripts/checks/vex-reachability.sh; scripts/checks/vex-coverage.sh; scripts/checks/vex-linkage.sh; .github/workflows/release.yml (vulnerability-record); the exploitability record.
  • Status: partial. Every finding of the full scan carries a judgement or the release does not ship, and the scan reads the Rust dependencies of the server and viewer binaries from the binaries themselves (#3674, #3675). The CRA does not define “known”. Eight OS findings are judged affected and have no upstream fix: CVE-2026-8674 (a long resolver search domain) in all three images; three libxml2 and two glibc JISX0213 findings in the ferroehr-postgres image that a database login reaches through PostgreSQL’s XML functions, which FerroEHR itself never calls (#3673); and the krb5 CVE-2026-107778 in ferroehr-postgres, reached only through a GSSAPI configuration FerroEHR does not ship. Each carries an action statement in security/vex/. Re-checked for 4.3.9 on 2026-10-15: the seven earlier statements are still affected, CVE-2026-107778 joined them, and #3708 is open, so the status stays partial.
  • Open: the eight affected findings (#3708), until Debian ships fixes that a rebuild picks up; each such rebuild is a security-only patch with its advisory.

(b) Secure by default, and reset to the original state

“be made available on the market with a secure by default configuration, unless otherwise agreed between manufacturer and business user in relation to a tailor-made product with digital elements, including the possibility to reset the product to its original state”

  • Applies: yes. The tailor-made carve-out does not: a public release is not a tailor-made product agreed with a business user.
  • How: authentication on, with a boot refusal when no mechanism is configured; RBAC on; the admin and management surfaces off; every integration off; Swagger UI private; the rate limiter on; version signing on; the identifier scanner strict. Weak settings are refused at boot: an alg: none token, a symmetric algorithm, a non-HTTPS issuer, an Argon2id hash below the floor. The chart runs non-root with a read-only root filesystem, all capabilities dropped, RuntimeDefault seccomp and an ingress NetworkPolicy, and its validation fails a render that loses any of them.
  • Evidence: app/ferroehr/assets/ferroehr.default.toml; app/ferroehr/src/config/auth.rs; app/ferroehr/src/config/deployment.rs; deploy/helm/ferroehr/values.yaml (config.deployment_profile); docker-compose.yml; deploy/helm/validate.sh; scripts/deploy-probes/reset.sh; the workload; returning to the original state.
  • Why sandbox is the default: the binary, the chart and the Compose files all ship and declare deployment_profile = "sandbox". A production default would refuse to start until the deployment has separate database credentials per domain, a declared subject namespace, a closed audit fail mode and a schema-preparation credential of its own, none of which a first boot, an evaluation or the quickstart has. An installation that cannot start invites accepting every gap by name only to make it start, which records a choice nobody made deliberately. A sandbox makes no such choice for the operator and stays loud instead: it names every open separation on the boot banner, in the log and on /management/status, and GET /ferroehr/rest/status reports the profile, so a sandbox cannot pass for a production instance. It also means no existing deployment changes behaviour by upgrading. The instructions for a deployment holding patient data are to set production, which refuses every open separation that is not accepted by name.
  • Which shipped defaults a production holder keeps: these are secure as shipped and production raises nothing about them: authentication on, RBAC on, the admin API and every management endpoint off, Swagger UI private, the rate limiter on, version signing on in digest mode with read-time verification strict, the identifier scanner strict over every shipped rule, and the audit trail on with the local store and retention_days = 0, which the server refuses at boot where a jurisdiction in force sets a ceiling. These must change, and production refuses them: authz.rbac.ehr_access_default = "open", audit.fail_mode = "open", an empty privacy.subject_namespaces, one database credential for every domain, and db.migrate = "apply" on the runtime credential. Three may be accepted by name when the deployment closes them outside the server: plaintext_listener, when a TLS-terminating ingress fronts the port, plaintext_broker, when the path to the AMQP broker stays on a trusted segment, and shared_cluster, when one PostgreSQL cluster holds the domains in separate schemas under separate roles. The usage report is on by default; it carries no health data (outbound data flows).
  • Reset: the book documents returning an instance to its original state, configuration and database, for Docker Compose, Kubernetes and a single binary. The deployment probe harnesses run the Compose and the Helm procedures and read the outcome back: the database volume or the database is gone, an EHR written before the reset is not found, the reinstalled server migrates the empty database (P-K8S-RESET-*), and the Compose server reports the shipped configuration (P-RESET-*).
  • Status: partial. The shipped profile is sandbox, so a deployment holding patient data is secure only after it sets production and closes the five refused defaults above. The single-binary reset is documented and not exercised by a probe; its database step is the one the Helm probe runs.
  • Open: none for the reset.

(c) Security updates, automatic updates, notification and postponement

“ensure that vulnerabilities can be addressed through security updates, including, where applicable, through automatic security updates that are installed within an appropriate timeframe enabled as a default setting, with a clear and easy-to-use opt-out mechanism, through the notification of available updates to users, and the option to temporarily postpone them”

  • Applies: the security-update limb, yes. The automatic-update limb, no: see the justification. The notification limb, yes: the sentence does not place it under “where applicable”, and this assessment reads it as applying. Postponement is inherent, because the deploying organisation decides when to upgrade.
  • How: a fix ships in the next tagged release, as a security-only patch unless a recorded reason makes that infeasible; there are no backports (CRA Art. 13(10)). Users learn of it through the GitHub release, its notes, the security advisory and the ### Security changelog section; the release feed (https://github.com/FerroHEALTH/FerroEHR/releases.atom) and the advisory listing (GET https://api.github.com/repos/FerroHEALTH/FerroEHR/security-advisories) are the subscription route SECURITY.md names. The running server states the end month of the commitment for its release (five years from the month of its release date) on the boot banner, in ferroehr --version, on GET /management/info and on GET /management/status as support.status, and once that month has passed it logs a warning at boot and once a day (Art. 13(19)).
  • No in-product update check. The server does not contact anyone to learn of a newer release. The point names “the notification of available updates to users” and is silent on whether the product itself must deliver it. FerroEHR has no end-user interface of its own: its users are the deploying organisation’s operators and the applications that call its API, and an update reaches a deployment only through the operators’ change control. The notice goes to them through the feed and the advisory listing. A check from the server would add an outbound connection from every instance holding special-category data, would fail wherever the organisation blocks egress (as the chart’s egress policy lets it), and would reach the operators only as a log line.
  • Evidence: SECURITY.md; app/ferroehr/src/support.rs; app/ferroehr/src/banner.rs (banner_states_the_support_period); how long a version is supported; upgrades.
  • Status: met for the security-update and notification limbs, with the notice delivered outside the product as stated above.
  • Open: none.

(d) Protection from unauthorised access, and reporting it

“ensure protection from unauthorised access by appropriate control mechanisms, including but not limited to authentication, identity or access management systems, and report on possible unauthorised access”

  • Applies: yes. The list is open (“including but not limited to”); the duty is appropriate mechanisms.
  • How: Basic (Argon2id) and bearer authentication through the ferrohealth-smart crate: asymmetric signature algorithms only, the RFC 9068 profile by default, and each issuer’s metadata and keys read at boot. Every route carries a SMART on openEHR scope requirement, and a patient/ grant reaches only the EHR of the token’s launch context. Then RBAC, Cedar or remote ABAC, and per-EHR EHR_ACCESS; an ABAC block that is enabled but cannot be built aborts boot. A restricted EHR_ACCESS default holds with the role gate off, and is refused at boot with authentication off. With a patient claim configured, the ABAC patient scope applies to the EHR row and to every versioned object on every query plan and on the direct EHR, composition and status routes. An EHR that records no subject is refused to a patient caller, and so is one whose subject id another namespace also holds, unless [authz.abac] patient_namespace names the namespace the claim is issued in. Every refused access (401, 403) is recorded in the access log and counted in the auth_failures metric.
  • Evidence: app/ferroehr-rest/src/extensions/access/; app/ferroehr-rest/src/system_log/middleware.rs; app/ferroehr/src/telemetry/metrics.rs; authentication, authorization; app/ferroehr/src/config/deployment.rs (production_refuses_auth_off_unless_accepted_by_name, production_refuses_rbac_off_and_a_restricted_default_stays_closed); deployment profile.
  • Status: met. Reporting is met. production refuses auth.enabled = false at boot unless auth_off is accepted by name, and [authz.rbac] enabled = false unless rbac_off is; an accepted gap is stated on every boot. The shipped sandbox profile accepts both, which (b) records.
  • Open: none beyond (b).

(e) Confidentiality of stored, transmitted and processed data

“protect the confidentiality of stored, transmitted or otherwise processed data, personal or other, such as by encrypting relevant data at rest or in transit by state of the art mechanisms, and by using other technical means”

  • Applies: yes. The means are a menu (“such as”); application-level encryption at rest of the clinical payload is not used, with the justification.
  • How: in transit, rustls with TLS 1.3 by default and mutual TLS, or a TLS-terminating ingress; integrations offer TLS (amqps, syslog over TLS, HTTPS-only S3 by default); a renewed server certificate is served without a restart, on SIGHUP and every [server.tls] reload_interval_s. At rest, protected national identifiers sealed under AES-256-GCM with a keyed lookup digest, per party version, inside the party commit; a write response serves the sealed form, an import or admin load carrying a protected value in plaintext is refused, and a dump carries the sealed rows as ciphertext. Other technical means: three pseudonymisation domains on separate credentials, checked by production; secrets that never render; log sanitising.
  • Evidence: [server.tls] in ferroehr.default.toml; B1, B6, the sealed identifier, TLS and database security.
  • Status: partial. The shipped transport default is plaintext, the AMQP and syslog integrations default to plaintext when switched on, and identifier sealing is off by default. production refuses a plaintext main listener on a routable address unless plaintext_listener is accepted by name, and a loopback bind is exempt; a deployment behind a TLS-terminating ingress accepts the gap by name, because the ingress carries the encryption (app/ferroehr/src/config/deployment.rs, production_refuses_a_routable_plaintext_listener_and_exempts_loopback). The separate management listener (management.port) binds every interface in plain HTTP, so setting it opens the same gap (production_refuses_a_plain_management_listener_on_its_own_port). The syslog audit feed sends over RFC 5425 TLS only, through ihe-atna (app/ferroehr/tests/it/system_log_tls_roundtrip.rs, tls_octet_counted_frame_round_trips). An enabled change-event outbox publishing to a plain amqp:// broker opens plaintext_broker (#3676, production_refuses_a_plaintext_amqp_broker_unless_accepted_by_name). An enabled audit FHIR feed on an http:// URL opens plaintext_audit_feed, and an enabled multimedia store with allow_http opens plaintext_object_store (#3689, production_refuses_a_plaintext_audit_feed_and_object_store_unless_accepted).
  • Open: none.

(f) Integrity, and reporting corruptions

“protect the integrity of stored, transmitted or otherwise processed data, personal or other, commands, programs and configuration against any manipulation or modification not authorised by the user, and report on corruptions”

  • Applies: yes, to data, programs and configuration. The configuration limb is split with the deploying organisation, with the justification.
  • How: data: immutable versions, each signed (digest or OpenPGP) and verified on read in strict mode by default; the access log is hash-chained in the database over every column of a record, the render policy its FHIR document is rendered under is checked against its digest, and UPDATE, DELETE and TRUNCATE on it are refused. The runtime roles read the migration bookkeeping of their schema and cannot write it, and the server refuses to start while one can. Programs: SLSA Build L3 provenance and SBOM attestations, cosign on the chart, signed tags, immutable releases, a read-only root filesystem. Configuration: unknown keys and unknown FERROEHR__ variables are refused at boot.
  • Evidence: app/ferroehr/src/versioning/integrity.rs; app/ferroehr/migrations/audit/0003_tamper_chain.sql; app/ferroehr/migrations/audit/0011_chain_digest_v2.sql; app/ferroehr/src/system_log/chain_check.rs; app/ferroehr/tests/it/audit_chain.rs (the_chain_check_reports_a_tampered_record_on_the_health_surface, the_current_digest_covers_every_column, every_hashed_column_is_tamper_evident); app/ferroehr/migrations/clinical/0014_bookkeeping_read_only.sql and its party and linkage twins; app/ferroehr/src/config/strict.rs; tamper evidence; verifying releases.
  • Status: met. A signature failure on read is refused and logged. The server verifies the access-log chain one minute after boot and then every [audit.store] verify_interval_seconds (default 86400; 0 turns it off). A finding is logged at ERROR, counted in atna_audit_chain_findings with kind damage or unverifiable, and shown by the audit_chain indicator on GET /health/readiness (DEGRADED when damaged or unverifiable), which never flips readiness. Each replica runs its own check. A deployment that sets the interval to 0 reports a corruption only when an operator runs audit.verify_audit_chain().
  • Open: none.

(g) Data minimisation

“process only data, personal or other, that are adequate, relevant and limited to what is necessary in relation to the intended purpose of the product with digital elements (data minimisation)”

  • Applies: yes, measured against the intended purpose.
  • How: the clinical record carries pseudonymous subject references; the identifier scanner refuses national identifiers in clinical content; the identity, the link and the access log sit in their own domains; telemetry carries no identified data; the access-log category classification never reaches spans, logs or metrics; ferroehr report identifies no person.
  • Evidence: app/ferroehr/src/privacy/; app/ferroehr/src/telemetry/log_sanitize.rs; privacy and data minimisation; the pseudonymisation boundary.
  • Status: met for the processing the intended purpose needs; each outbound flow is justified in the flows table.

(h) Availability, also after an incident

“protect the availability of essential and basic functions, also after an incident, including through resilience and mitigation measures against denial-of-service attacks”

  • Applies: yes. The measures named are an open list.
  • How: an admission limit sheds load with 503 and Retry-After; per principal and per address rate limits, on by default; body limits, header read timeouts, HTTP/2 stream caps, statement and query timeouts and a result-row cap; panics caught; two replicas, a disruption budget and topology spread in the chart; backup and point-in-time recovery documented.
  • Evidence: app/ferroehr-rest/src/overload.rs; app/ferroehr-rest/src/rate_limit.rs; app/ferroehr-rest/src/limits.rs; request limits; backup and recovery.
  • Status: met. The residual risk is stated in the threat model: an authorised caller holding resources is not defended against, and the limiter is per process.

(i) Minimal impact on other devices and networks

“minimise the negative impact by the products themselves or connected devices on the availability of services provided by other devices or networks”

  • Applies: yes.
  • How: every outbound integration is off by default and bounded when on (retry limits, batch sizes, poll intervals, connect and request timeouts). The usage report sends at most one start report per ten minutes per instance and one daily report, each at most 64 KiB. A default-deny egress policy is available in the chart.
  • Evidence: ferroehr.default.toml; deploy/helm/ferroehr/values.yaml (networkPolicy.egress); usage report.
  • Status: met.

(j) Limited attack surface

“be designed, developed and produced to limit attack surfaces, including external interfaces”

  • Applies: yes.
  • How: optional surfaces are off by default (admin, management, SMART, multimedia, events, the terminology API); Swagger UI is private; optional integrations are compiled behind features; the runtime images are distroless nonroot with no shell; the chart installs an ingress NetworkPolicy.
  • Evidence: docker/Dockerfile; docker/viewer/Dockerfile; operational surfaces.
  • Status: met.

(k) Exploitation mitigation

“be designed, developed and produced to reduce the impact of an incident using appropriate exploitation mitigation mechanisms and techniques”

  • Applies: yes.
  • How: Rust with unsafe_code = "forbid" across the workspace and overflow-checks = true in release builds; a non-root UID, no shell, a read-only root filesystem, all capabilities dropped, RuntimeDefault seccomp and user namespaces; database credentials split so one leaked DSN reaches one domain.
  • Evidence: Cargo.toml ([workspace.lints.rust], [profile.release]); the workload; secrets, detection and response.
  • Status: met.

(l) Recording and monitoring, with an opt-out

“provide security related information by recording and monitoring relevant internal activity, including the access to or modification of data, services or functions, with an opt-out mechanism for the user”

  • Applies: yes.
  • How: the access log records every API access, every record a read or a query served, and every refusal, in DICOM PS3.15 and FHIR AuditEvent form, with syslog and FHIR-feed sinks and ITI-81 retrieval; metrics and traces cover the rest. The opt-out is [audit] enabled and [audit.store] enabled; production refuses audit off unless accepted by name.
  • Evidence: app/ferroehr/src/system_log/; app/ferroehr-rest/src/system_log/; audit trail; observability.
  • Status: met. A deploying organisation may owe the log under the EHDS or national law, so whether it may use the opt-out is outside the CRA.

(m) Secure removal of all data and settings, and secure transfer

“provide the possibility for users to securely and easily remove on a permanent basis all data and settings and, where such data can be transferred to other products or systems, ensure that this is done in a secure manner”

  • Applies: yes.
  • How: ferroehr db erase --confirm <instance> removes all data and settings in one operation: every multimedia blob, then the five schemas with every domain, the access log, the stored settings and the instance id. Run without --confirm it erases nothing and prints what it would remove and the value that confirms it. DELETE {base}/admin/ehr/{ehr_id} and DELETE {base}/admin/ehr/all remove single EHRs with every version, the linkage row and orphaned multimedia; template and stored-query deletes exist. Transfer runs over the authenticated API and /ferroehr/rest/extensions/v1/admin/dump, over TLS when the deployment enables it.
  • Evidence: app/ferroehr/src/decommission.rs; app/ferroehr/tests/it/decommission.rs; decommissioning; physical deletion; dump and load.
  • Status: met, shipped in #3642. The decommissioning page lists what the erase cannot reach and the deploying organisation removes: backups, WAL archives and replicas, the chart’s Secrets and claims, the database roles, configuration files, and the data held by systems the instance sent to. Sanitising the physical media is the organisation’s. A single party has no physical delete over REST, because the openEHR Admin API defines none; the page says so and names the alternatives.

Requirements and limbs that do not apply

CRA Art. 13(4): “Where certain essential cybersecurity requirements are not applicable to the product with digital elements, the manufacturer shall include a clear justification to that effect in that technical documentation.”

Automatic security updates (Annex I Part I(2)(c) and Part II(7))

Part I(2)(c) asks for automatic security updates “where applicable”, and Part II(7) for distribution “where applicable for security updates, in an automatic manner”. Neither applies to FerroEHR:

  • FerroEHR is a clinical server run under its operator’s change control: the deploying organisation for a self-hosted instance, Cadasto B.V. for an instance it hosts as a service. An upgrade can carry a database migration that the operator schedules, backs up for and verifies, so in both cases the operator applies an update, never the product by itself.
  • A clinical data repository is a system whose unavailability is a patient-safety matter (threat model, asset “Availability”). An update the manufacturer installs on its own schedule would restart the service and migrate the schema at a moment no clinician or operator chose.
  • The operator controls the host, the cluster, the image registry it pulls from and the network. FerroEHR has no channel of its own into a deployment, and adding one would be an inbound path into a system holding special-category data, against (j). Where Cadasto B.V. hosts an instance, it applies updates as that instance’s operator under its own change control, not through the product.
  • Updates are distributed securely by other means: signed, immutable releases with provenance, digest-pinned images, the moving <major>.<minor> tags an organisation can follow with its own tooling, and the advisory that tells it to.

So the automatic-installation limb and the opt-out that goes with it do not apply, and CRA Annex II point 8(e) (how to turn automatic installation off) has nothing to describe. The notification limb still applies (#3639).

Application-level encryption at rest (Annex I Part I(2)(e))

Part I(2)(e) names encryption at rest as one of the means (“such as”). FerroEHR does not encrypt the clinical payload before it reaches PostgreSQL:

  • The payload is stored as canonical openEHR JSON that AQL queries inside; encrypting those fields in the application would make them unqueryable, which defeats the intended purpose.
  • Encryption at rest is provided where the data is stored: at the volume or disk layer, which the deploying organisation provisions (TLS and database security).
  • The fields whose disclosure re-identifies a person directly, the protected national identifiers, are sealed in the application (AES-256-GCM), because they are looked up by digest and never queried by content.
  • Confidentiality at rest is further carried by “other technical means”: the separated domains and credentials, and least-privilege roles.

Configuration integrity (Annex I Part I(2)(f))

Part I(2)(f) lists “configuration” among what must be protected against modification “not authorised by the user”. FerroEHR protects the configuration it reads: unknown keys and unknown FERROEHR__ variables stop the boot, secrets are read from mounted files, and the effective configuration is printed redacted on demand. It does not sign or checksum its configuration file. The file lives on the deploying organisation’s host or in its Kubernetes secrets and config maps, under that organisation’s access control, and whoever can change it there is, by the organisation’s own decision, a user authorised to configure the product. Protecting it against other modification is the platform’s job (secrets, detection and response).

Outbound data flows (Annex I Part I(2)(g))

Every connection FerroEHR opens on its own initiative, with what it carries and why. The database connections are the product’s own storage and are not listed.

FlowDefaultCarriesPurpose, against the intended purpose
Usage report to report.ferropulse.euoninstance id, version, commit, specification profile, licence grant type, deployment method, uptime, database health, PostgreSQL major version, CPU and memory ranges, and daily performance aggregates (fields)Not part of the clinical purpose: it serves the manufacturer’s view of which versions, licence types and deployment sizes run. It carries no health data and no data from a request; the collector refuses an unknown member; the body is at most 64 KiB; ferroehr usage-report --print shows it before it is sent; one switch turns it off. Cadasto B.V. decided on 2026-10-06 to keep it on by default and accepts the residual risk, after an adjudication that read ePrivacy Art. 5(3) as requiring prior consent (privacy notice)
Authorization-server metadata and keys from each [auth] issuerwhen an issuer is configuredthe issuer’s well-known URLs; no request dataverifying the tokens the provider’s identity provider issues
Token introspection ([auth] mode = "introspection")offeach bearer token presented to FerroEHR, with the client credential of [auth.introspection.client] or the token of [auth.introspection.bearer]asking the issuer whether an opaque access token is active (RFC 7662)
Patient identity source over PIXm ([ehr_index] provider = "ihe")on (the default provider)the subject identifier and its assigning authority as ITI-83 specifies, with the client credential of [ehr_index.oauth] when setresolving a subject to the pseudonym its EHR carries; the source is never asked to resolve a pseudonym back
Client-credentials token requests ([audit.fhir_feed.oauth], [authz.abac.remote.oauth], [terminology.server.oauth])offthe product’s own client credential and the requested scope; no request dataauthenticating the product to the deployment’s audit feed, policy service and terminology server
Remote policy decision point ([authz.abac.remote])offthe attributes of the request being decidedthe authorisation decision the deployment delegates to its own policy service
Terminology server ([terminology.server])offcodes and value-set references to validate, expand or subsumevalidating terminology bindings on write and expanding TERMINOLOGY() in AQL
Change events over AMQP ([events])offcommit notifications, free of PHI by defaulttelling the provider’s other systems that a record changed
Multimedia to S3 ([multimedia])offDV_MULTIMEDIA bytes above a size threshold, keyed by content hashstoring large attachments of the record outside the database
Access log to syslog ([audit.syslog]) and to a FHIR feed ([audit.fhir_feed])offaccess recordsan off-box copy of the access log, which the threat model asks for
Traces and metrics over OTLP (telemetry.otlp.endpoint)offspans and metrics, with identified data sanitised outoperating the instance

Each integration except the usage report carries only what its purpose in the provider’s deployment needs, and only to a destination the deploying organisation configures.

Annex I Part II: vulnerability handling

Part II binds Cadasto B.V. These are processes, carried out partly through the repository’s tooling; none is a property of the software a deployment runs. The manufacturer’s policy is the same for every FerroHEALTH product and is published in the FerroHEALTH book (security, post-market); the steps bound to FerroEHR’s repository are docs/post-market.md.

PointHow the manufacturer applies itStatusTracker
(1) Identify components; SBOMan SBOM per published artefact: CycloneDX of the cargo graph per binary, SPDX per image, SPDX of the repository, CycloneDX per openehr-* crate attested against its .crate archive, and CycloneDX per chart version naming its images by digest, explained in the SBOMs; vulnerabilities in VEX, deny.toml, the changelog and each release’s exploitability recordmetshipped, #3643
(2) Remediate without delay; security updates apart from functionalityacknowledgement within about 5 and assessment within about 10 business days; a fixed release within 7, 30 or 90 days of the confirmed assessment for a Critical, High or Medium vulnerability and with the next release for a Low one; a fix ships as a security-only patch unless the release notes record why that is infeasible (the FerroHEALTH security policy, linked from SECURITY.md)met as policy from this change onshipped, #3644
(3) Regular security tests and reviewsdaily fuzzing of seven parsers, CodeQL, OpenSSF Scorecard, clippy with the reliability lints, cargo deny, image scans, conformance runs, the deploy probemetnone
(4) Disclose fixed vulnerabilitiesa GitHub security advisory for every fixed vulnerability, with CVSS, affected and fixed versions and remediation, and the criteria for a delay (the FerroHEALTH security policy, SECURITY.md, docs/post-market.md); no advisory has been published yetpartial: the commitment is written, the record starts with the next fixshipped, #3645
(5) Coordinated vulnerability disclosurethe FerroHEALTH security policy, with FerroEHR’s reporting routes and scope in SECURITY.md and security.txtmetnone
(6) Share vulnerability information; a contact addressGitHub private reporting and info@cadasto.com; component vulnerabilities reported upstream and recorded in a registermet as policy from this change onshipped, #3646, #3648
(7) Distribute updates securelyprovenance, attestations, signed tags, immutable releases, digest-pinned and scanned images, crates.io Trusted Publishing; the automatic limb does not apply (above)metnone
(8) Disseminate updates without delay, free of charge, with advisoriespublic, immutable releases and moving tags; advisories under (4); whether “free of charge” holds for every commercial licence is a point for counselpartialshipped, #3645; counsel’s answer open, #3647

Security-relevant paths

These are the paths of the repository that hold an asset or a control of the register above. A release that changes any of them since the previous release is refused unless this page gains a row in its Revisions table: scripts/checks/cra-risk-assessment.sh --since <previous tag> runs in the release lane, and the same script refuses a declared path that no longer exists, so the list cannot rot. CRA Art. 13(14) asks for procedures that keep each product of a series in conformity as its design changes.

  • app/ferroehr-rest/src/extensions/access authentication and the RBAC/ABAC enforcement point
  • app/ferroehr-rest/src/smart SMART scope enforcement
  • app/ferroehr/src/config/auth.rs the authentication configuration
  • app/ferroehr/src/config/authz.rs the authorization configuration
  • app/ferroehr/src/config/deployment.rs the deployment profiles and their refusals
  • app/ferroehr/src/system_log the access log and the audit chain
  • app/ferroehr-rest/src/system_log the audit middleware
  • app/ferroehr/migrations/audit the audit store
  • app/ferroehr/src/versioning/signature version signing
  • app/ferroehr/src/versioning/integrity.rs the integrity check of stored versions
  • app/ferroehr/src/privacy the pseudonymisation boundary
  • app/ferroehr/src/decommission.rs the erase of all data and settings
  • docker/Dockerfile the server image
  • docker/viewer/Dockerfile the viewer image
  • docker/postgres/Dockerfile the database image
  • deploy/helm/ferroehr/templates the chart’s security contexts and policies
  • deploy/helm/ferroehr/values.yaml the chart’s shipped defaults

Revisions

CRA Art. 13(3) has the assessment “updated as appropriate during a support period”, and Art. 13(7) has the manufacturer update it “where applicable” as vulnerabilities and third-party information arrive. It is revised:

  • with every release whose changes touch an asset or a control in the register (the security-relevant paths, gated at release, #3649);
  • when a vulnerability or an incident shows a risk the register does not name;
  • when the intended purpose changes.
VersionDateChange
12026-10-06First assessment, from the CRA Annex I audit of #3611
22026-10-07(a): the per-release exploitability record (#3636), the viewer image scanned before it is tagged (#3674) and every release binary and image binary built with cargo auditable (#3675); (b): the declared profile in the chart and the Compose files, the reasons for sandbox and the shipped defaults a production holder keeps, and the reset procedure (#3637); Part II(1): SBOMs for the crates and the chart (#3643); the declared security-relevant paths and their release gate (#3649); (m): the erase command and the decommissioning page (#3642); (e): a plaintext amqp:// broker refused under production (#3676); (e): a plain http:// audit FHIR feed and a multimedia store with allow_http refused under production (#3689); (b): the Helm reset run by the Kubernetes probe harness (#3677)
32026-10-08Part II: the manufacturer’s vulnerability-handling and post-market policy is cited from the FerroHEALTH book, where it moved for every FerroHEALTH product; the measures are unchanged (#3701)
42026-10-08Reviewed for release 4.3.6 against the security-relevant paths changed since 4.3.5: the outbound FHIR emitter and its AMQP flow are gone with the in-tree connector (#3080), so the outbound-flow table names change events alone on AMQP; (a): the ferroehr-postgres image drops GnuPG and gosu, and every operating-system finding of the three images carries an OpenVEX judgement in security/vex/ (#3673)
52026-10-15Reviewed for release 4.3.9 against the security-relevant paths changed since 4.3.6. (d): the ABAC patient scope covers queries that read only the EHR row and every query plan, the direct routes refuse a subject-less or ambiguous EHR to a patient caller, and [authz.abac] patient_namespace names the claim’s namespace (#3771); ehr_access_default = "restricted" holds with the role gate off, is refused at boot with authentication off, and the role gate off is the rbac_off gap production refuses (#3742); the viewer’s Basic sign-in follows only a same-origin next. (e): protected national identifiers sealed per party version inside the commit, write responses serving the sealed form, plaintext protected values refused on import and admin load (#3772); the server certificate re-read every reload_interval_secs and EC keys in SEC1 or PKCS#8 (#3765, #3761); the OIDC 503 and a JWK Set parse error no longer quote the provider’s URLs or key material (#3666); the CDR and the viewer verify an identity provider against the platform trust store (#3767). (f): digest version 2 of the audit chain over every column (#3686); the runtime roles read-only on the migration bookkeeping, checked at boot in every database a pool reaches; version-signing errors carry their cause once (#3684). (a): rustls 0.23.46 for RUSTSEC-2026-0346 to -0349, and the release lane checks the linkage the OS VEX statements rest on (#3694); the seven affected findings of #3708 are unchanged. The chart: in-chart clients follow the server’s TLS (#3714), FerroBRIDGE as an opt-in, cluster-internal workload (#3713), client_auth: required refused at render (#3760), an Ingress in front of a TLS listener sending TLS to it (#3766), and the migration Job under its own hook ServiceAccount. No risk added to the register
62026-10-19Reviewed for release 5.0.0 against the security-relevant paths changed since 4.3.11: in app/ferroehr/src/privacy/detect.rs the Finnish personal identity code rule cites Valtioneuvoston asetus väestötietojärjestelmästä 128/2010, 2 §, in place of the agency’s web page (#3840); the detection and its control-character table are unchanged. No risk added to the register
72026-10-20Reviewed for release 5.1.0 against the security-relevant paths changed since 5.0.0. (d): authentication and the SMART on openEHR scope check move onto the shared ferrohealth-smart crate (#3872): only asymmetric algorithms verify a token, so the HMAC key is gone, every route carries a scope requirement, and a patient/ grant is confined to the launch context’s EHR, with the route parameter read from the full request path. The audit FHIR feed and the remote policy service authenticate with an OAuth 2.0 client-credentials token, and a policy service that cannot be reached with one fails the request closed (#3829), so the outbound-flow table names the token requests and the issuer metadata read at boot. (e): the chart mounts a database CA for sslmode=verify-full (database.caConfigMap), which the Kubernetes probe observed against four TLS clusters (#3788); a rotated mTLS client CA is re-read without a restart (#3769). No risk added to the register
82026-10-20Reviewed for release 5.2.0 against the security-relevant paths changed since 5.1.0. (d): authentication moves to ferrohealth-smart 0.3.1 (#3875): the crate now caches verified Basic credentials under a keyed hash ([auth.basic] cache_ttl_seconds, default off), refuses a bearer value outside the RFC 6750 §2.1 b64token syntax as invalid_token, and refuses a blank sub, each of which FerroEHR held itself before; [auth] scopeless_subjects is honoured under the SMART on openEHR requirement. The EHDS Art. 8 restriction of access hides restricted data from every professional route, query and export, with an emergency override by a configured role and a declared emergency purpose (#3682). (e): a party’s protected national identifiers are read back only through an admin route that also needs authz.rbac.identifier_reader_role, and every read is an access record (#3776); an EHR extract never carries a sealed identifier between deployments (#3777). No risk added to the register
92026-10-22Reviewed for release 5.3.0 against the security-relevant paths changed since 5.2.0. (l): the access log is written through FerroAUDIT’s ehds-logging and ihe-atna 0.2.4 (#3887, #3902): the record carries the token’s issuer, client and jti, the ITS-REST operation id and the outcome again, names each category by its HL7 Europe <system>|<code> token and says what the classification was read from (audit migration 0012); the syslog feed sends over RFC 5425 TLS only, so the UDP transport and its audit_syslog_udp deployment gap are gone. (d): authentication moves to ferrohealth-smart 0.4.0, and a Basic request the verified-credential cache admits writes no login record (#3895); the SMART launch route reads ehr_id with the one form decoder every query parameter now uses (#3891). (a): rustls-webpki 0.103.16 and the webpki roots 1.0.10 for RUSTSEC-2026-0381 to -0383 (GHSA-pmpg-vfwr-99qv). Both images build on Rust 1.99.1 with the builder digest re-resolved; the other paths changed only in tests. No risk added to the register
102026-10-22Reviewed for release 5.3.2 against the security-relevant paths changed since 5.3.0; the v5.3.0 and v5.3.1 tags exist but their releases never published, so 5.3.2 is the first release since 5.2.0 and revision 9 covers the rest of its changes. (a): the distroless runtime base of both images is re-resolved to ca-certificates 20250419+deb13u1 for DSA-6572-1 (#3926, GHSA-pmpg-vfwr-99qv), the only package that differs from the previous base, which removes the compromised eMudhra (emSign) intermediates from the system trust store. No risk added to the register
112026-10-24Reviewed for release 5.4.0 against the security-relevant paths changed since 5.3.3. (f): in [signing] mode = "pgp" an ECDSA or Ed448 signing key is refused at boot, so the constant-time defect of RUSTSEC-2026-0387 in crypto-bigint 0.5.5 is reached only over public keys and signatures, in verification (#3965, security/vex/rust-advisories.openvex.json); the signer takes the newest subkey that may sign, never a revoked or expired one, the primary key only when its self-signature grants signing, and no subkey while the primary key is revoked or expired (RFC 9580 §5.2.3.10, §5.2.3.13; #3967, #3969, #3971). (l): the audit drain closes its syslog connection with a TLS close_notify when it exits, so the records it sent last are not lost (#3959, ihe-atna 0.2.5). (a): the viewer image build no longer downloads the Tailwind standalone binary, one fewer third-party build input (#3932). No risk added to the register
122026-10-24Reviewed for release 5.4.1 against the security-relevant paths changed since 5.4.0. (d): under SMART an authenticated request for a method the route does not serve is answered 405 with Allow before the scope check, and a request without a credential is still 401, so the scope check runs only on served methods (#3987). (e): the CDR asks one terminology server under [terminology.server] with one client credential, mounted by the chart as secrets.terminologyClientSecret; its token endpoint URL is masked in every rendering of the grant; terminology inputs travel in a POST body, so a code no longer appears in a URL; the per-server mutual-TLS certificates and pinned trust bundles are removed, and the server is verified against the platform trust store (#3085). The chart’s NetworkPolicy wording follows the renamed key without a rule change. No risk added to the register
132026-10-25Reviewed for release 5.4.2 against the security-relevant paths changed since 5.4.1. (f): in [signing] mode = "pgp" a valid Certification Revocation of a Direct Key signature, created at or after it, cancels that signature, so neither its signing flag nor its key expiration lets the primary key sign (RFC 9580 §5.2.1.13, §5.2.3.10; #3972). (l): an access record whose patient lookup failed says unavailable instead of not-found, so a search by the patient’s identifier can tell a failed lookup from an EHR without a subject (Regulation (EU) 2025/327 Art 9(1); #3900); ihe-atna 0.2.7 answers the syslog connection’s liveness probe without a timer tick, which removes a millisecond wait per record from the audit drain (#3954). (d): ferrohealth-smart 0.5.0 refuses authorization-server metadata that repeats a member name (RFC 8259 §4; #4002). (a): utoipa-swagger-ui 10.0.2 takes the yanked zip 8.6.0 and its three extraction advisories out of the build (#3799; GHSA-vv5m-jxxh-4627, GHSA-4v9h-p7m9-rh26, GHSA-4684-h2q9-2vcp). No risk added to the register
142026-10-27Reviewed for release 5.5.0 against the security-relevant paths changed since 5.4.2. (a): the zstd bindings move to zstd 0.14.3, zstd-safe 8.2.0 and zstd-sys 2.1.2, past four RUSTSEC advisories that response compression and decompression reach (#4095, GHSA-cwhv-87pq-3c3c); ferrohealth-smart 0.7.1 masks the userinfo of a grant-table URL no parser accepts (#4038, GHSA-vphj-3f8c-5hpx); the PostgreSQL image base is re-pinned to the 2026-10-21 respin of 18.6 (#4084). (d): opaque access tokens are checked by token introspection under [auth] mode = "introspection" (#4033); [auth] ca_file trusts a private issuer CA on every platform, with its chart value (#3909); the issuer metadata file is read through the shared parse (#4165); the caller’s roles come from ferrohealth-smart and the policy decision from ferrohealth-authz, with [authz.rbac] user_role retired (#4028, #4038); Basic credentials are cached for 300 seconds by default (#4088); a cohort query and an AQL query rooted at a demographic class apply the population gate (#4247). (g): the EHR index asks an IHE patient identity source over PIXm by default and a server without one refuses to start unless [ehr_index] provider = "local" is set, so subject identifiers leave the instance only to the source the deployment names (#3380, #4242); both new flows are in the outbound data flows table, and the chart’s usageReport.enabled=false now reaches FerroTERM, FerroBRIDGE and FerroPIX (#4270). (j): the server and the viewer run on the family HTTP stack of ferrohealth-server (#4137, #4139), the server reads only the configuration file it is named (#4087), and Template Data Document import and its routes are removed (#4199). (l): the audit trail takes a forwarded client address only from a [server] trusted_proxies peer (#4025); syslog frames are delivered from ihe-atna’s spool with retries (#4029); the ITI-81 retrieval is read and answered by ihe-atna and requires a date (#4030, #4040); the access record carries the data permit (#3379, #4120) and every access to one EHR names the EHR and the user (#4192); the chain check verifies from the last intact link (#4100, #4102). (m): each access record is deleted at its own retention end (#3899). No risk added to the register
152026-10-28Reviewed for release candidate 5.6.0-rc1 against the security-relevant paths changed since 5.5.0. (h): ferrohealth-server 0.3.7 closes a plain-HTTP connection that sends no request header within [server] header_read_timeout_ms, counted from accept, and pauses accepting for up to a second after EMFILE or ENFILE, so silent connections from a client with no credentials no longer exhaust the file descriptors (#4283, GHSA-2mc2-h3jq-c5wg). (d): the patient-data gate and the SMART route table judge a route by the namespace it is served under, ITS-REST under /openehr/v1/, FHIR and IHE under /fhir/r4/ and the extensions under /extensions/v1/, so every moved route keeps its gate and its scope (#4330). (f), (l): the audit store renders each record’s AuditEvent from its columns under the render policy the record names, the chain digest covers every column, the search tokens included, in one version, each render policy and search token is checked against its digest, and audit migration 0017 refuses a store holding records of an earlier release (#4300, #4302, #4313); a deployment that declares no category map classifies through the map FerroEHR ships, and the production profile refuses one with no map at all as the no_category_map gap (#4298). (a): the server and viewer images build on the refreshed gcr.io/distroless/cc-debian13:nonroot digest. No risk added to the register
162026-10-29Reviewed for release candidate 5.6.0-rc2 against the security-relevant paths changed since 5.5.0, after revision 15 reviewed 5.6.0-rc1, which was tagged and not published. (d): the ABAC request carries category and origin resource attributes on composition, contribution and query operations, which the Cedar schema declares and a remote PDP binding may name (#4299). (l): a body without FEEDER_AUDIT committed directly by a client the [origins] register names records that client’s origin instead of the server’s system_id (#4299). (m): a retention period may be keyed on a registered origin, and the due list lists such a period over the objects of that origin (#4299). (b): the Helm chart has no default deployment profile and refuses to render until config.deployment_profile names sandbox or production, so an installation no longer runs the sandbox posture by omission (#4294). (d): a [server.tls] table that sets client_ca_file under client_auth = "none" is refused at load on ferrohealth-server 0.3.9, since such a listener asks no client certificate (#4349, GHSA-52mf-qg39-4q4j). (l): the FHIR rendering of an unclassified access record carries the BALP entity:data slice (#4353). No risk added to the register
172026-10-29Reviewed for release candidate 5.6.0-rc3 against the security-relevant paths changed since 5.6.0-rc2. (d): the authentication layer, the ABAC enforcement point and the EHR access gate answer their 401 and 403 as RFC 9457 problem details, and under the FHIR base as an OperationOutcome, with the same status and the same decision as before; the refusal names the request id as its instance (#4342). (l): FerroEHR takes ehds-logging and ihe-atna 0.3.2, and the FHIR AuditEvent names the object an access touched by its openEHR object id as entity.what.identifier (#4366); the audit store writes a drained batch, its search tokens and its token sets in one transaction, and the GIN index on search_tokens collects new entries in a 512 kB fast-update list (GIN fastupdate), set by audit migration 0019 with 0017 kept at its 5.6.0-rc2 text, so the chain and its digests are unchanged and an audit store that 5.6.0-rc2 migrated upgrades in place (#4375, #4424); the fail-closed 503 of the audit middleware is problem details (#4342). (h): ferrohealth-server 0.4.0 closes a kept-alive connection idle for [server] idle_timeout_ms (120000 by default), and [server] header_read_timeout_ms counts from the first byte of each later request head, so a silent connection is still closed after accept by the head bound and an idle one by the idle bound (#4417). (b): the chart runs FerroPIX 0.0.12 by digest with its chart 0.3.4, whose pods run without service links and in a user namespace by default (#4392). No risk added to the register

Hazard log: the logging component

This page is the clinical safety hazard log of FerroEHR’s European logging software component, the part of the EHR system that “provides logging information related to access by health professionals or other individuals to priority categories of personal electronic health data” (EHDS Art. 2(2)(o), Regulation (EU) 2025/327). EHDS Annex II 1.1 asks that the harmonised software components “achieve the performance intended by its manufacturer” and be designed so that, “during normal conditions of use, they are suitable for their intended purpose and their use does not put at risk patient safety”.

The Regulation names no method or standard for showing that, so the form of this log is FerroEHR’s own design. For each hazard it states the cause, what could happen to a patient, the control, the test that shows the control holds, and what remains. “Normal conditions of use” are those of the intended-purpose statement. The log is the EHDS-side companion of the CRA risk assessment, and it is versioned with the release like every page of this book. The performance side of Annex II 1.1, the component’s stated performance and the test that measures it on every release, is on the logging component’s intended performance.

Warning

The quotations are from the Official Journal text vendored in the repository at docs/law/eu/ehds/text.html (EUR-Lex). A control and a passing test show what the software does; they do not show that the component meets Annex II, which no conformity assessment has examined.

The component and the surface it records

The component is, in the code: the FerroHEALTH products’ shared crates ehds-logging 0.2.7 (the Annex II 3.2 access record, the Art. 14(1) categories and their classification, the Art. 9(2) retention floor and the emergency mark) and ihe-atna 0.2.7 (the BALP and DICOM renderings and the ATNA sender), from FerroAUDIT; ferroehr::system_log (the access-event model, the audit store, what each rendering says and the sinks), ferroehr-rest::system_log (the middleware that records every API access and classifies it), the ITI-81 GET /fhir/r4/AuditEvent retrieval, and the retention register in ferroehr::storage::marks. The middleware is installed outermost on the API router, around the authentication layer, so it sees every response of the openEHR REST surface, refusals included. What each record holds, field by field against Annex II 3.2, is on the audit trail page.

Test paths below are relative to the repository root. Each was read for this log; “no test” means none exists.

H1: an access is not recorded

Cause: a route the middleware does not classify; a record dropped when the emission queue is full or the store is unreachable; a domain-level access (resolving a subject to an EHR, an identifier resolution, an EHR Extract export) that skips the middleware; reads made straight against the database.

Effect: a patient, or the organisation investigating a breach, cannot learn that their record was read. Under EHDS Art. 9 a patient has a right to that information.

Control: every generated ITS-REST operation has an explicit audit classification, and an unknown operation falls back to the audited default. The extension routes that change a record carry one too: the physical delete of a demographic party is recorded as a delete of that party, as the physical EHR delete is of the EHR. A rejected access (401, 403) is always recorded. Under [audit] fail_mode = "closed" a request whose record cannot be taken is answered 503 and the domain-level operations withhold their result. An AQL execution emits one record per EHR it served. deployment_profile = "production" refuses audit switched off, audit with no durable sink and audit failing open, unless each is accepted by name.

Shown by:

  • app/ferroehr-rest/src/system_log/classify.rs: every_generated_operation_is_explicit, every_read_operation_produces_an_access_record, unrecognised_operation_fails_closed_to_default, representative_mappings;
  • app/ferroehr-rest/tests/it/admin_party_delete_http.rs: an_admin_deletes_the_party_and_the_access_record_names_it;
  • app/ferroehr-rest/tests/it/audit_e2e.rs: one test per resource family (for example composition_get_emits_read_record, aql_execute_emits_execute_record), unauthenticated_request_emits_401_record, fail_closed_returns_503_when_channel_full, fail_closed_503_carries_openehr_error_body_and_retry_after, aql_execute_emits_one_access_record_per_served_ehr;
  • app/ferroehr/tests/it/access_event_fail_closed.rs: a_linkage_resolution_is_withheld_when_its_record_is_rejected, a_subject_lookup_is_withheld_when_its_record_is_rejected, an_identifier_resolution_is_withheld_when_its_record_is_rejected, an_extract_export_is_withheld_when_its_record_is_rejected;
  • app/ferroehr/src/config/deployment.rs: production_refuses_each_open_gap_and_names_the_remedy, production_refuses_the_open_access_default_and_the_open_audit_fail_mode;
  • app/ferroehr/tests/it/legal_marks.rs: setting_and_lifting_a_mark_is_recorded_in_the_access_trail;
  • app/ferroehr-rest/tests/it/audit_route_coverage.rs: every_route_writes_exactly_one_access_record enumerates every route the assembled router mounts, the extension routes included, drives each one once, and asserts exactly one access record carrying its operation id. The routes it exempts (the health probes, the public status and discovery documents, the OpenAPI document and Swagger UI, the OPTIONS manifest and the management surface) are listed in the test with the reason.

Remains: the shipped fail_mode is open, so under the sandbox profile a full queue drops a record and the request succeeds (fail_open_serves_request_when_channel_full pins that behaviour). Database access outside the server is recorded nowhere.

H2: a record names the wrong person, organisation or patient

Cause: the actor taken from something other than the verified credential; an organisation guessed when the token carries none; the EHR taken from the wrong part of the request; one record written for an AQL page that served several patients.

Effect: an access is attributed to a professional who did not make it, or recorded against the wrong patient, so a patient is told of an access to someone else’s record and the real one goes unseen.

Control: the principal comes from the authenticated credential; a 401 records the caller as unknown and never fabricates one. The organisation is read from the same token claim the ABAC layer reads, and stays empty when there is no claim or the caller used Basic. The EHR comes from the resource the dispatch resolved, or from the request path where none exists. An AQL execution records each served EHR separately. With [authz.professional] configured, a token that names no natural person is refused on patient data unless it is a client token acting for a named professional, and the record carries the acting mode (person or client) and that professional; with [authz.assurance] configured, it carries the assurance level the token maps to, and a token below the configured minimum is refused with a step-up challenge. The DICOM message and the FHIR AuditEvent give the client and this server the same data-flow role (DICOM PS3.15 §A.5.2.7, IHE BALP): on a read the server is the source and the client the destination, on a write or a query the client is the source. A FHIR record of a delete carries the agent types the BALP Delete profile fixes (the client as DCM 110150 Application, the server as custodian). The FHIR AuditEvent names the object a read, create, update or delete touched by its openEHR object id, as entity.what.identifier in the system urn:openehr:object-id, and ITI-81 finds it by entity.identifier.

Shown by:

  • app/ferroehr/tests/it/audit_ehds_mapping.rs: element_a_the_accessing_organisation_is_recorded_and_rendered_in_fhir, element_b_the_person_who_accessed_is_recorded_and_rendered, element_b_the_professional_behind_a_client_is_recorded_and_rendered, the_assurance_level_is_recorded_and_reaches_the_fhir_rendering_as_a_level, nen_7513_the_actor_roles_are_recorded_and_rendered_in_both_formats;
  • app/ferroehr/tests/it/audit_store.rs: access_fields_round_trip_on_both_write_paths, the_accessing_organisation_round_trips_on_both_write_paths, the_actor_roles_round_trip_on_both_write_paths, both_inserts_persist_the_authentication_columns;
  • app/ferroehr-rest/tests/it/patient_data_assurance.rs: a_token_below_the_minimum_gets_the_step_up_challenge, a_client_token_without_acting_for_is_refused, a_client_acting_for_a_professional_passes_and_is_recorded;
  • app/ferroehr-rest/src/system_log/middleware.rs: ehr_id_from_the_matched_route, object_id_from_template_paths, object_id_from_query_paths, object_id_from_demographic_paths;
  • app/ferroehr-rest/tests/it/audit_e2e.rs: unauthenticated_request_emits_401_record, aql_execute_emits_one_access_record_per_served_ehr;
  • app/ferroehr/src/system_log/fhir.rs: dicom_and_fhir_agree_on_the_data_flow_direction, delete_carries_the_agent_types_balp_delete_fixes, a_classified_read_names_its_object_by_identifier.

Remains: both blocks are off by default, and until the deployment configures [authz.professional] a client application’s credential is recorded as that application, not as the professional behind it. A shared Basic account names the account; with either block configured, Basic is refused on patient data. The DICOM format has no element for the assurance level, so the syslog feed carries none; the FHIR record carries the level as the BALP ihe-assuranceLevel extension, and the raw claim value stays in the local store’s column.

H3: the category of the data is not recorded

Cause: a template or archetype missing from the deployment’s [audit.categories] map; a record type the classifier does not reach.

Effect: a record cannot say whether the access reached a patient summary, a prescription or a test result, which Annex II 3.2(c) asks for and the patient’s information under EHDS Art. 9(2)(c) depends on.

Control: each access is classified when it happens by ehds-logging, by template id, then root archetype id, then the query’s positive constraints, every one of which must be mapped. A logical delete is classified by the version it deleted, a VERSIONED_COMPOSITION read by the composition it holds, a CONTRIBUTION read (with or without Prefer: resolve_refs) by the versions it committed, and an EHR Extract export or import by the versions it carried or landed. EHR, EHR_STATUS, EHR_ACCESS and the directory are recorded none by resource kind. While the deployment declares no template and no archetype, the map FerroEHR ships for the published CKM templates and COMPOSITION archetypes of one priority category applies, and deployment_profile = "production" refuses a deployment that switches it off with nothing declared. An access the map cannot classify is recorded unclassified, with the ids as evidence, and is never refused. Each record carries the digest of the map that classified it.

Shown by:

  • app/ferroehr-rest/tests/it/audit_categories.rs: an_unmapped_template_is_unclassified_with_its_ids_as_evidence, a_multi_category_query_records_every_category_it_served, a_leaf_only_query_is_classified_from_its_constraints, a_zero_row_query_is_classified_from_its_constraints, a_not_contains_operand_never_counts, a_flat_write_is_classified_by_its_template, a_logical_delete_is_classified_by_the_version_it_deletes, a_versioned_composition_read_is_classified_by_its_composition, a_contribution_read_is_classified_by_its_versions, the_shipped_map_classifies_an_undeclared_deployment;
  • app/ferroehr/tests/it/service_message_audit.rs: the_extract_records_carry_the_categories_of_the_data;
  • app/ferroehr/tests/it/audit_ehds_mapping.rs: element_c_the_priority_category_is_recorded_and_rendered_in_fhir_only.

Remains: the shipped map covers only the published CKM templates and archetypes whose data sit in one priority category, and a deployment’s own map replaces it whole, so a map that misses a template the deployment uses yields unclassified records. Records in the demographic domain carry no classification, because no priority category lives there. The Helm chart carries a deployment’s own map as config.audit.categories, empty by default, so the shipped map applies.

H4: a record misstates where the data came from

Cause: a body’s FEEDER_AUDIT provenance ignored; an AQL page’s origins taken from one row only.

Effect: a patient or a reviewer is told the wrong system created the data that was read (Annex II 3.2(e)).

Control: each committed version records the distinct originating systems of its body; a body with none records the origin of the registered client that committed it directly ([origins]), or this server; a read records the origins of what it served, an AQL page the union, with the true count beside a capped set. The FHIR AuditEvent of a read or a query carries the origins of the data it served.

Shown by: app/ferroehr/tests/it/access_origins.rs: a_body_without_feeder_audit_has_this_server_as_its_origin, a_registered_client_stamps_its_origin_on_a_direct_commit, feeder_audits_anywhere_in_the_body_count_once_each, an_aql_page_records_the_union_of_the_origins_it_served, an_unstamped_row_is_assessed_from_the_body_at_read; app/ferroehr-rest/tests/it/audit_e2e.rs: composition_get_records_the_origins_of_the_served_data; app/ferroehr/src/system_log/fhir.rs: query_record_carries_the_origins_of_the_served_data.

Remains: the DICOM rendering carries no origins, because PS3.15 defines no element for them; the local store and the FHIR export carry them.

H5: the log is lost, or reaped too early

Cause: a retention period shorter than the law requires; a reaper that deletes more than it should; the database that holds the local store lost; a forwarding sink down.

Effect: the information a patient has a right to for “at least three years from each date of access” (EHDS Art. 9(2)) no longer exists.

Control: each jurisdiction has a registered retention floor (three years in every EU Member State, longer where national law sets more), and a configured period below it stops the boot. The reaper deletes only records past the horizon, in calendar years where the period is given in years, and the chain stays verifiable after it runs. The forwarding outbox keeps records until the sink takes them, and the audit drain closes its syslog connection with a TLS close_notify when it exits, so the records it sent last are not lost to a reset. production refuses audit with no durable sink unless accepted by name.

Shown by:

  • app/ferroehr/src/config/mod.rs: a_retention_below_the_jurisdiction_floor_is_refused, the_ehds_floor_holds_in_every_member_state_and_a_longer_national_floor_wins, retention_years_meets_a_floor_in_years_exactly, a_ceiling_below_a_floor_is_refused;
  • app/ferroehr/src/system_log/config.rs: the_ehds_floor_reaches_every_member_state_and_a_longer_national_floor_wins;
  • app/ferroehr/tests/it/audit_store.rs: reap_deletes_only_rows_past_the_horizon, reap_years_deletes_only_rows_past_the_calendar_horizon;
  • app/ferroehr/tests/it/audit_chain.rs: retention_reaping_leaves_a_verifiable_trail;
  • app/ferroehr/tests/it/audit_feed.rs: arr_outage_leaves_rows_pending_then_delivers_on_recovery;
  • app/ferroehr/tests/it/system_log_tls_roundtrip.rs: the_drain_closes_its_connection_with_close_notify_when_it_exits.

Remains: a local store shares the fate of its database, so backups and an off-box sink are the deploying organisation’s (B7).

H6: a patient cannot read their log

Cause: the log reachable only with an administrator credential; no retrieval scoped to one patient.

Effect: the information EHDS Art. 9 gives a patient cannot be produced.

Control: ITI-81 GET /fhir/r4/AuditEvent searches the log by patient, agent, action and date, and a configured subject audit role reads one subject’s records and nothing else. EHDS Art. 9(2) has the information provided “through electronic health data access services”, which the Member States establish (Art. 4(1)); FerroEHR serves the log to such a service and has no patient-facing interface of its own.

Shown by: app/ferroehr-rest/tests/it/audit_iti81.rs: searchset_bundle_with_filters_and_paging, the_subject_audit_role_reads_one_subjects_log_and_nothing_else, rbac_gates_the_audit_surface_to_admins.

Remains: the subject audit role is unset by default, so the log is admin-only until the deployment configures it.

H7: the access log is altered

Cause: a record changed or deleted in the database, by a compromised application role or by someone with direct access.

Effect: an access disappears, or appears to have been made by someone else, and the record a patient or a court relies on is false.

Control: records are hash-chained in the database, and a record’s digest covers every column but the digest and the two forwarding stamps, its search tokens included (audit migration 0017); each render policy a record names is checked against its own digest, and each search token and token set against its content; UPDATE, DELETE and TRUNCATE on the log are refused; the application role cannot run DDL or rewrite the log; audit.verify_audit_chain() names any record that changed or went missing; the server runs that verification one minute after boot and then every [audit.store] verify_interval_seconds (default one day), logs a finding at ERROR, counts it in atna_audit_chain_findings and shows it on the audit_chain readiness indicator; forwarding puts copies off the box.

Shown by:

  • app/ferroehr/tests/it/audit_chain.rs: a_modified_audit_record_is_detected_and_named, a_deleted_audit_record_is_detected_in_the_middle_and_at_the_end, the_audit_table_refuses_mutation_deletion_and_truncation, the_application_role_cannot_run_ddl_or_rewrite_the_audit_trail, the_batched_write_path_is_chained_too, the_chain_check_reports_a_tampered_record_on_the_health_surface, the_audit_subsystem_schedules_the_chain_check, the_digest_covers_every_column, every_hashed_column_is_tamper_evident, a_changed_or_missing_render_policy_is_reported, a_changed_search_token_or_set_is_reported;
  • app/ferroehr/tests/it/audit_store.rs: the_access_trail_refuses_rewriting_deletion_and_truncation.

Remains: the chain gives evidence of tampering and does not prevent it. A deployment that sets verify_interval_seconds = 0 finds a broken chain only when an operator runs the check. The indicator never flips readiness, so a finding reaches someone only through an alert on the counter or the indicator, which the deploying organisation sets up.

H8: the log discloses more than it should

Cause: the category classification or identifying content copied into request logs, spans or metrics; the log served to a caller with no right to it.

Effect: the log of who read a patient’s record becomes a disclosure of that record.

Control: the classification stays in the access log; telemetry carries no identified data; the ITI-81 surface is authorised like any other.

Shown by: app/ferroehr-rest/tests/it/audit_categories.rs: the_classification_never_reaches_spans_logs_or_metrics; app/ferroehr/tests/it/privacy_leak.rs: the_atna_records_carry_the_opaque_pseudonym_and_nothing_else; app/ferroehr-rest/tests/it/audit_iti81.rs: rbac_gates_the_audit_surface_to_admins.

Remains: the log names patients and actions by design, so whoever holds ITI-81 access reads that.

H9: an emergency access to data restricted under EHDS Art. 8 is not marked

Cause: a natural person restricts the access of health professionals to some of their data (EHDS Art. 8), and a professional reaches that data anyway because the person’s vital interests require it (Art. 11(5)). Without a mark, the access record of that emergency looks like any other.

Effect: EHDS Art. 9(1) includes “access provided in accordance with Article 11(5)” in what a patient is told about, and Art. 11(5) asks that such cases be “logged in a clear and understandable format” and be “easily accessible for the data subject”. An unmarked emergency access hides that it happened.

Control: a deployment names the purpose-of-use codes its callers declare for an emergency ([audit] emergency_purpose_codes, each also on purpose_codes). An access declaring one carries the emergency mark in its access record: the emergency_access column, and in the FHIR AuditEvent the ehds-logging entity ehds-emergency-access, which states the mark in plain words and names the purposes that marked it. The person’s own subject-scoped ITI-81 retrieval serves that document. The mark lifts no restriction: an object restricted under GDPR Art. 18 is refused with an emergency purpose exactly as without one, and the refused attempt is logged with the mark.

Shown by: app/ferroehr-rest/tests/it/audit_emergency_access.rs: an_emergency_access_is_marked_and_shown_in_the_subjects_own_access_log, an_emergency_purpose_does_not_lift_a_gdpr_restriction; app/ferroehr/tests/it/audit_ehds_mapping.rs: art_11_5_the_emergency_mark_reaches_the_fhir_rendering_only; app/ferroehr/tests/it/audit_store.rs: both_inserts_persist_the_emergency_mark_and_it_cannot_be_rewritten.

Remains: the Art. 8 restriction hides restricted data from every professional route, query and export, and only a caller holding authz.rbac.emergency_access_role that declares an emergency purpose reads it (app/ferroehr-rest/tests/it/access_restriction_http.rs: only_an_emergency_access_reads_restricted_data_and_is_marked). The mark rests on the purpose the caller declares, and an emergency the caller does not declare is not marked. The DICOM syslog rendering has no purpose element and does not carry the mark.

When the log is reviewed

The log is reviewed in the release that changes any module of the component listed above, with the hazards, controls and test names checked against that release’s tree. It is also reviewed when an incident, a complaint or a vulnerability shows a hazard the log does not name. The technical documentation cites it as the EHDS-side risk assessment of the component.

VersionDateChange
12026-10-06First log, nine hazards
22026-10-07H9 names the EHDS Art. 8 restriction; the emergency mark and the subject-scoped retrieval are its control
32026-10-08Reviewed for release 4.3.6 (changes since 4.3.5: the assurance level and the natural person, the emergency mark, the FHIR connector removed beside the ITI-81 route); every named test exists and asserts its control; no hazard added
42026-10-15Reviewed for release 4.3.9 (changes since 4.3.6: digest version 2 of the audit chain over every column, audit migration 0011 (#3686); one data-flow direction for the DICOM and FHIR renderings (#3687) and the BALP Delete agent types (#3750); the physical party delete classified and its party named (#3683); every audit-store and retention-register checkout through the typed database acquire (#3749)); every named test exists and asserts its control; H1, H2 and H7 name the new tests, H7 the version 1 records; no hazard added
52026-10-20Reviewed for release 5.1.0 (changes since 5.0.0: the audit FHIR feed authenticates with an OAuth 2.0 client-credentials token, and a token endpoint that refuses or cannot be reached is a transport error, so the records stay pending and are delivered on recovery as H5 states (#3829); the assurance and professional rules the access record carries move under [authz] (#3872)); every named test exists and asserts its control; no hazard added
62026-10-20Reviewed for release 5.2.0 (changes since 5.1.0: the EHDS Art. 8 restriction of access lands, so H9’s control is the restriction with its emergency override and the mark on that access record (#3682); a read of a party’s protected identifiers is recorded per scheme (#3776); the access context carries whether a request enforces the restriction); every named test exists and asserts its control; no hazard added
72026-10-20Reviewed for the move onto FerroAUDIT (#3887): the access record, its categories and their classification, the retention floor and the emergency mark come from ehds-logging 0.1.1, the BALP and DICOM renderings and the ATNA sender from ihe-atna 0.1.1; the syslog feed sends over TLS only; H2, H3 and H9 name the moved tests; every named test exists and asserts its control; no hazard added
82026-10-22Reviewed for release 5.3.2 (changes since 5.2.0: the move onto FerroAUDIT of review 7 (#3887), then ehds-logging and ihe-atna 0.2.4 (#3902), so the record carries the token’s jti, the operation subtype, outcomeDesc and the optional DICOM elements again, a Basic user is recorded with no issuer, a retention without end or under three years is stated as such, and archetype ids are compared as openEHR BASE 1.3.0 asks with each id kept as the access named it (#3908); the ITI-81 search form-decodes its query string (#3891)); every named test exists and asserts its control; no hazard added
92026-10-24Reviewed for release 5.4.0 (changes since 5.3.3: ehds-logging and ihe-atna 0.2.5, and the audit drain closes its syslog connection with a TLS close_notify when it exits, logging and counting a close that fails, so the records it sent last are not lost to a reset (#3959); H5 names its test); every named test exists and asserts its control; no hazard added
102026-10-27Reviewed for release 5.5.0 (changes since 5.4.2: syslog frames are delivered from ihe-atna’s spool with retries, and [audit.fhir_feed] batch_size and max_retries are gone (#4029); the ITI-81 query is read and answered by ihe-atna’s retrieve and requires a date (#4030, #4040); each access record is deleted at its own retention end (#3899); the chain check verifies from the last intact link, and the FHIR feed owes only the records written while it is configured (#4100, #4102); the access record and its renderings carry the data permit (#3379, #4120); the source address comes from the family server stack, believing a forwarded address only from a trusted proxy (#4025); every access to one EHR names the EHR and the user, so H2 names ehr_id_from_the_matched_route, the test #4192 put in place of ehr_id_from_various_paths); every named test exists; no hazard added
112026-10-28Reviewed for release candidate 5.6.0-rc1 (changes since 5.5.0: the store keeps each record’s facts in columns and renders its AuditEvent on read under the render policy the record names, the chain digest covers every column in one version, and audit migration 0017 refuses a store holding records of an earlier release, so no record keeps an older digest and H7 drops that remainder (#4300); the recorded_at indexes ascend (#4302); every ITI-81 parameter answers through search tokens the chain covers (#4313); the ITI-81 retrieval moves to /ferroehr/rest/fhir/r4/AuditEvent (#4330); a deployment that declares no category map records the published CKM priority-category templates and archetypes through the shipped map, so H3 names its test and its remainder no longer says FerroEHR ships none (#4298); H7 names the render-policy and search-token tests and the digest test #4300 renamed); every named test exists; no hazard added
122026-10-29Reviewed for release candidate 5.6.0-rc2 (changes since the 5.6.0-rc1 review: a body without FEEDER_AUDIT committed directly by a client the [origins] register names records that client’s origin, so H4 names it and its test, and the retention register keys a period on the origin of the data (#4299); H3’s remainder said the binary ships no category map, which #4298 changed; the FHIR rendering of an access record ehds-logging does not classify carries the BALP entity:data slice its profile requires (#4353); the component’s intended performance is stated and measured (#4293), and its ATNA transactions are exercised against independent implementations (#4296)); every named test exists; no hazard added
132026-10-29Reviewed for release candidate 5.6.0-rc3 (changes since the 5.6.0-rc2 review: FerroEHR takes ehds-logging and ihe-atna 0.3.2, and the FHIR AuditEvent names the object it touched by its openEHR object id as entity.what.identifier where it wrote a what.reference no FHIR server resolves, so H2 states it and names its test (#4366); the audit store writes a drained batch, its search tokens and its token sets in one transaction on one connection, and the GIN index on search_tokens collects new entries in a 512 kB fast-update list (GIN fastupdate) that audit migration 0019 sets, which changes no control H5 or H7 states (#4375, #4424); the ITI-81 route answers an authentication refusal as an OperationOutcome, the fail-closed 503 is RFC 9457 problem details, and fail_closed_503_carries_openehr_error_body_and_retry_after asserts the problem body (#4342)); every named test exists; no hazard added

The logging component’s intended performance

EHDS Annex II 1.1 asks that the harmonised software components “achieve the performance intended by its manufacturer” and be suitable for their intended purpose in normal conditions of use (Regulation (EU) 2025/327, docs/law/eu/ehds/text.html). Annex III point 2 asks the technical documentation for the system in place to evaluate that performance. This page states the performance Cadasto B.V. intends for FerroEHR’s European logging software component, as claims a test can check, and the test that checks them. The Regulation names no method and no figure, so both are FerroEHR’s own design.

Warning

A met claim shows what the software did on the run that measured it. It does not show that the component meets Annex II, which no conformity assessment has examined.

The claims

The component is the access log the hazard log assesses: every access through the API is recorded, by ehds-logging and ihe-atna through ferroehr::system_log, in the local store, and retrieved through IHE ITI-81. The claims hold under the deployment posture the instructions for use set for real data: the access log on, the local store on, and [audit] fail_mode = "closed".

ClaimStated performanceHow it is checked
CompletenessEvery composition create and read has exactly one access record, and every AQL execution at least one (one per EHR it served).Measured on every run: the access records of the measured window are counted through ITI-81 per operation id and compared with the operations that ran.
Loss under outageNo access succeeds without its record. A record the queue or the local store cannot take refuses the request with 503, and a record owed to a FHIR Audit Record Repository that is down stays owed and is delivered when it returns.Deterministic tests, not a load measurement: app/ferroehr-rest/tests/it/audit_e2e.rs (fail_closed_returns_503_when_channel_full, fail_closed_503_carries_openehr_error_body_and_retry_after), app/ferroehr/tests/it/access_event_fail_closed.rs, and app/ferroehr/tests/it/audit_feed.rs (arr_outage_leaves_rows_pending_then_delivers_on_recovery).
Latency addedWith the access log on, the median p50 and p95 request latency are at most 125 % of those with it off.Measured: the same workload with the log off and on, on one machine.
Throughput keptWith the access log on, the median throughput is at least 80 % of that with it off.Measured, as above.
RetrievalA page of 50 access records retrieved through ITI-81 from the measured window takes at most a quarter of a second at p95 (MAX_RETRIEVAL_P95_MS in the verdict script).Measured: 50 paged retrievals after each run.

“Normal conditions of use” for these figures are the load of the release performance guard: a store seeded with 20 EHRs of 10 compositions each, then 2,000 operations over 8 concurrent workers, of every 20 six composition creates, eight composition reads and six AQL queries (tools/loadtest). The intended-purpose statement names no load, so this one is chosen to be the one every release is already measured under. The ratios compare two runs on one machine, so they hold across machines in a way an absolute latency would not; the retrieval figure is absolute and generous for that reason.

The test

scripts/perf/audit.sh starts the quickstart on fresh volumes, three times with the access log off and three times with it on and failing closed, alternating the order. The load test runs the workload against each; with the log on it waits for the asynchronous log to settle after the seed, opens the measured window, and after the measured phase counts the window’s records per operation id (the ITI-81 subtype and _count=0) until two readings a second apart agree, then times the paged retrievals. scripts/perf/audit-verdict.sh compares the medians with the claims above and exits non-zero when one is not met; its --self-test holds each budget to its edge.

The release pipeline runs it on the release candidate in its performance leg. A claim not met refuses the release, and the summary is attached to the release as ferroehr-<tag>-logging-performance.md, which is the latest result for that release. The weekly performance guard runs it on main.

To run it yourself against an image:

cargo build --release -p loadtest
FERROEHR_PORT=8080 bash scripts/perf/audit.sh ghcr.io/ferrohealth/ferroehr:<tag> \
  ghcr.io/ferrohealth/ferroehr-postgres:<tag> perf-audit

What it does not measure

  • Records over syslog or the FHIR feed are not timed: both send from the stored record, after the request, so they add no latency to it.
  • The store’s size over years is not loaded: the retrieval figure holds for the store a run leaves. ITI-81 answers through a token index, so a search reads an index rather than every record, but no run measures a store of millions of records.
  • Access made straight against the database, outside the server, is not recorded at all (hazard H1).

Claims review

Every public text about FerroEHR is read, once per release, against EHDS Art. 28 (Regulation (EU) 2025/327). The article reads: “In the information sheet, instructions for use or other information accompanying EHR systems, and in the advertising of EHR systems, it shall be prohibited to use text, names, trademarks, pictures and figurative or other signs that may mislead the professional user […] with regard to their intended purpose, interoperability and security by:

  • (a) ascribing functions and properties to the EHR system which it does not have;
  • (b) failing to inform the professional user of likely limitations related to interoperability or security features of the EHR system in relation to its intended purpose;
  • (c) suggesting uses for the EHR system other than those stated to form part of the intended purpose in the technical documentation.“

This page is the record of those reviews: what was read, what was found, what was changed. It also carries the re-assessment of EHDS Annex II 2.5. The measure for point (c) is the intended-purpose statement.

Warning

The quotations are from the Official Journal text vendored at docs/law/eu/ehds/text.html (EUR-Lex). Art. 28 takes “professional user” from Article 3, point (8), of Regulation (EU) 2018/1807, which the repository does not carry, so these pages do not restate that definition. A text that passes this review has been read against Art. 28 by the manufacturer; no authority has examined it.

What is reviewed, and how

The texts in scope, each release:

  • the repository README.md and the landing page at ferroehr.eu;
  • every page of this book, except the pages generated from the tracker and from committed measurement records, whose generators are reviewed instead;
  • SECURITY.md;
  • the READMEs of the published openehr-* crates;
  • the OCI labels of the three images (docker/Dockerfile, docker/viewer/Dockerfile, docker/postgres/Dockerfile) and the Helm chart’s description;
  • the public sandbox at sandbox.ferroehr.eu.

Each claim about a function, a property, interoperability or security is checked against the code, the configuration defaults and the committed records, and against the intended-purpose statement. A misleading claim is fixed in the same change as the review. Measured numbers are never typed into a text: the conformance and performance figures come from generators over the committed run records, and a CI check fails a page whose numbers disagree with them.

The review of 2026-10-06

Read on the tree after commit d6539d470, before FerroEHR 4.3.5.

Changed

#TextClaimArt. 28FindingNow reads
1README, opening“Every compliance claim it makes is machine-verified”(a)Only the openEHR conformance numbers are machine-verified. The regulatory pages are reviewed by hand, and “compliance” invites the regulatory reading“Every conformance claim it makes is machine-verified”
2README, “What makes it different”“Compliance you can verify.”(a)as row 1“Conformance you can verify.”
3README, licence paragraph and “Security & operations”“Multi-tenancy […] fully integrated: each tenant is an isolated logical openEHR system, enforced by PostgreSQL row-level security”(a)FerroEHR is single-tenant, with no tenant column, row policy or tenancy configuration (one instance, one organisation)“One instance per organisation: FerroEHR is single-tenant […]”; multi-tenancy removed from the feature list
4README, “Security & operations”national identifiers “sealed under a per-tenant key”(a), (b)there are no tenants, and the sealing is off by default“sealed under a key the deployment holds […] (off by default)”
5README, “Security & operations”“IHE ATNA-compliant system log (DICOM audit messages over TLS syslog)”(a), (b)“compliant” claims a property nobody assessed; the syslog sink is off by default and ships with UDP; the default sink is the local hash-chained storethe access log, its two renderings, the local store and the optional forwarding, described as they ship
6README, “Security & operations”“Hardened by default”(b)TLS is off by default, and the shipped deployment_profile is sandbox, which must not hold real patient data“Hardening built in”, with both limitations stated
7README, “Integration”the FHIR R4B connectors, with no limitation(b)the in-tree connector was removed in favour of FerroBRIDGE in v4.3.6 (#3080)the removal is stated
8Landing page, title, og: and twitter: descriptions, structured data“Spec-compliant and measured”, “spec-conformant and measured”(a)states conformity as a property; what exists is a measured openEHR conformance record“openEHR conformance, measured”
9Landing page, hero note“every compliance claim machine-verified”(a)as row 1“every conformance number machine-verified”
10Landing page, conformance section“Compliance you can verify.” and “Every release runs the complete conformance catalogue against the live server”(a)the release pipeline does not run the catalogue; the committed record measured FerroEHR 4.3.0“Conformance you can verify.”; the record “names the FerroEHR version it measured”
11Landing page, feature grid heading“Everything the openEHR standard asks for”(a)claims complete coverage of the standard, which nothing measures“The openEHR platform services”
12Landing page, Security card“multi-tenancy”(a)as row 3“One instance per organisation.”
13Book, Introduction“Compliance you can run yourself”(a)as row 1“Conformance you can run yourself”
14Book, System architecture“why the compliance claims are checkable”, “the compliance claims are machine-derived”(a)as row 1“conformance claims”
15Book, Beyond the core“Security, multi-tenancy, and the audit trail”(a)as row 3“Security, instance separation and the audit trail”

Read and kept

  • The conformance and performance figures on the README, the landing page and the book: rendered from docs/conformance/ferroehr/ by scripts/render/conformance-stats.sh and scripts/render/perf-assets.sh, and checked in CI. No number is typed.
  • “openEHR-spec-conformant”, on the Conformance page, in the openehr-* crate READMEs, in the org.opencontainers.image.description label of the server image and in the Helm chart description: a claim about the openEHR specifications, backed by the committed record (CORE and STANDARD, every case passed) and explained on that page. It makes no claim about any regulation.
  • “aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture” (the compliance overview, Why FerroEHR exists): stated as an aim, beside the statement that FerroEHR holds no certification and no declaration.
  • The compliance pages, the DPIA and the go-live checklist: each says it does not make a deployment compliant with anything.
  • The image labels: the server image carries eu.ferroehr.image.carries-sandbox-posture="true", which tells an operator the shipped profile; the manufacturer label names Cadasto B.V.
  • SECURITY.md: its commitments match the procedures in docs/post-market.md and the release procedure.
  • The public sandbox: the viewer shows the sandbox notice on every page, the README calls its content demo data, wiped nightly, and the landing page calls it a live demo.

Left open

  • The Helm chart’s description (Chart.yaml, and the chart README that helm-docs generates from it) says “hardened-by-default security posture” and lists the workload hardening, which is accurate, but does not say that a stock install runs the sandbox profile. The chart now declares config.deployment_profile: sandbox in its values, and the chart README says so under “Before you install”; the Chart.yaml description still does not.

The review of 2026-10-08

Read on the tree after commit de648fcc3, for FerroEHR 4.3.6. This release removes the in-tree FHIR connector (#3080) and moves the deployment gaps off the public status document (#3671), so those two facts were checked in every text in scope.

Changed on 2026-10-08

#TextClaimArt. 28FindingNow reads
1Landing page, “Fits your stack”“FHIR R4B”(a)FerroEHR maps no FHIR resources; it reaches FHIR R4B terminology servers and renders its access log as FHIR R4 AuditEvent“FHIR R4B terminology”
2Book, instructions for use, CRA user information, configuration, go-live checklistan accepted deployment gap is stated “on GET /ferroehr/rest/status”(a)the gaps moved to the authenticated GET /management/status (#3671); the public status document carries the profile only“on GET /management/status”
3Book, instructions for use, the deployment profilethe list of what production refuses(b)omitted the plaintext audit feed and the plaintext object store (#3676, #3689)both named
4Book, instructions for use, limitationsnone on FHIR(b)an operator of an earlier release may rely on the removed connectorFerroEHR maps no FHIR resources, and [fhir] is refused at boot
5Book, information sheet, (e)the standards table(b)omitted the FHIR terminology operations and RFC 9470, which this release usesboth listed

Read and kept on 2026-10-08

  • The README’s integration list and the book’s Beyond the core: both state that FHIR R4 mapping is FerroBRIDGE’s and name the two uses of FHIR FerroEHR keeps.
  • Single tenancy: the README, the landing page and the book state one instance per organisation; “multi-tenant” appears only where the book quotes openEHR’s placement of multi-tenancy outside one system.
  • The Helm chart’s description: it now says a stock install runs the sandbox profile (#3637), which closes the item left open on 2026-10-06.

Left open on 2026-10-08

Nothing.

The review of 2026-10-15

Read on the tree after commit 606a29cce, for FerroEHR 4.3.9. The facts of this release checked in every text in scope: the ABAC patient scope over EHR-only queries and [authz.abac] patient_namespace (#3771); a restricted EHR_ACCESS default with the role gate off, and the rbac_off gap (#3742); national identifiers sealed per party version, the sealed form in write responses and the refusal of plaintext protected values on import (#3772); digest version 2 of the audit chain (#3686); the AQL row rules (#3733, #3734); the removal of FOSSA; the TLS certificate reload and EC keys (#3765, #3761); the four-cluster production layout (#3755 to #3757, #3762); the example generator (#3739, #3740); quoted GET query parameters (#3781). The 2026-10-08 move of the deployment gaps to GET /management/status (#3671) was re-checked on every page, and eleven pages still named the public document.

Changed on 2026-10-15

#TextClaimArt. 28FindingNow reads
1Book, licensing, security, go-live checklist (two items), Kubernetes, authentication configuration (two places), Compose (two places), installation, configuration, command line, DPIA R9, compliance overview (GDPR Art. 25, DSV Art. 4)an open or accepted deployment gap, or the licence in force, is reported on GET /ferroehr/rest/status (or /rest/status)(a)the public document carries status, the versions, the timestamp and the profile only; the gaps, the licence and the support period are on GET /management/status (#3671)“on GET /management/status”; the configuration page adds that the public document carries the profile only
2Book, instructions for use, the deployment profilethe list of what production refuses(b)omitted the role gate off while authentication is on, the rbac_off gap this release adds (#3742)named
3Book, instructions for use, limitationsnone on how AQL reads repeated nodes(b)AQL 1.1.0 does not define the rows of a path through a repeated node; this release changed them (#3733), so another server, or a client written for an earlier release, may count differentlythe limitation is stated, with the regrouping a client needs
4Book, instructions for use, limitationsnone on the audit chain’s coverage(b)records written before the upgrade keep digest version 1, which leaves out the columns added after the chain was introduced (#3686)stated, with the columns and the trigger that alone protects them
5Book, configuration, migrate_on_runtime_credentiala domain relocated to its own database “is prepared on that domain’s runtime DSN”(a)[storage.migrate.<domain>] url now prepares it on its own migration credential (#3756); only without it does the runtime DSN prepare it and open the gapboth cases stated
6Book, records of processing, categories of personal dataclinical content “in the ehr schema”; parties “in the demographic schema”(a)the schemas are clinical and party since the storage rewriteclinical and party
7Book, Kubernetes, server TLSthe viewer’s OIDC client “trusts the public roots it is built with”, so a private-CA issuer is not trusted(a)the viewer’s OIDC client verifies against the platform trust store (#3767)a private-CA issuer is trusted once its CA is in the store or under SSL_CERT_DIR
8Book, querying with AQL, the URL formhow a parameter value binds(b)silent on a quoted value, which now binds the JSON string it denotes (#3781)the quoted form and the malformed case stated
9Book, templates and validation, the example composition“a skeleton instance you can fill in”, with no limitation(b)the example passes the server’s own validation except for two open defects (#3779, #3780) and templates whose mandatory content only a slot admitsthe exceptions named
10Book, threat model, what each database credential can reachthe writer roles hold SELECT, INSERT, UPDATE and DELETE on their schema(b)the migration bookkeeping is now read-only to every runtime role, and the boot refuses a writable onestated below the table
11Book, information sheet, (e)TERM “3.1.0”(b)the server applies amendment 3.1.4 (SPECPR-450), which adds properties and units the template upload and the quantity validation accept (#3717, #3727)“3.1.0, with amendment 3.1.4 (SPECPR-450)”

The technical documentation’s Annex III 2 named the public status document for the deployment posture too; it names GET /management/status now. | 12 | Repository README, the template-model and example bullets (two places) | every template “generates spec-valid example compositions that pass the server’s own validation” | (a) | #3779 and #3780 are open, and a template whose mandatory content only an archetype slot admits has no committable example | “a template […] generates” them, “except for the cases the book’s example page lists” | | 13 | Repository README, the deployment posture and licence bullets | sandbox names every open gap “on GET /rest/status”; “GET /ferroehr/rest/status reports the grant in force” | (a) | both are on GET /management/status (#3671) | “on GET /management/status” | | 14 | Book, EHDS readiness row 1.2 | its issue reference | (a) | the probe that issue asked for ran (#3692), without hostUsers: false or sslmode=verify-full | names #3788 for those two |

Read and kept on 2026-10-15

  • FOSSA: no text in scope names FOSSA, a FOSSA badge or a FOSSA lane after its removal in this release.
  • The patient scope and patient_namespace: the [authz.abac] table and its patient-scope list state the EHR-only queries, the direct-route refusals and the namespace rule as they ship; the security page’s fail-closed box is unchanged by them.
  • The restricted EHR_ACCESS default and rbac_off: the authentication configuration, the configuration page, the go-live checklist, the Kubernetes values table and the CRA risk assessment (d) state both.
  • Identifier protection: the privacy configuration, the threat model’s sealed-identifier section, the operations guide and the admin API page state the per-version rows, the sealed write response, the plaintext refusal on import and load, and the erasure of sealed rows.
  • The audit chain: the audit trail page states both digest versions and the downgrade finding.
  • AQL: querying with AQL and the AQL engine concept page state the row rules and say AQL 1.1.0 does not define them.
  • TLS: the server configuration and the Kubernetes page state the reload interval, the EC key formats and the explicit-curve refusal.
  • The information sheet’s BALP row (“the profile revision is not pinned”): kept. The delete agent types follow BALP 1.1.4, and the profile URLs the records carry are unversioned.
  • The image labels and the Helm chart’s description: unchanged by this release but for the chart description’s NetworkPolicy defaults (#3743), which match values.yaml.

Left open on 2026-10-15

Nothing.

The review of 2026-10-16

Read on the tree after commit e923076de, for FerroEHR 4.3.10. The facts of this release checked in every text in scope: the usage report in format 2 (#3795); the usage report off in a CI run, whatever the switch says (#3794); the dump and load archive on zip 9.0.4 (#3798).

Changed on 2026-10-16

#TextClaimArt. 28FindingNow reads
1Book, instructions for use, the usage report“Every deployment sends a daily usage report to Cadasto B.V. by default”(a)the report also goes out at each start, and a server started in a CI run sends none (#3794)both stated, with the two CI variables
2Book, operations, the usage report“It is on by default”(a)off in a CI run, whatever the switch says (#3794)“on by default outside a CI run”

Read and kept on 2026-10-16

  • The usage report’s fields: the usage report page lists the format 2 fields and the status object as the server sends them, and says earlier releases send format 1. The field list in the CRA risk assessment’s outbound data flows, the shared responsibility summary, the records of processing note, the intended purpose and the threat model name the same information, which format 2 only regroups.
  • The CI switch: the usage report page states the two variables, the boot line and the --print note as they ship, and the server configuration table states the two variables.
  • The dump and load archive: the admin APIs page names no zip library and its compression_format values are unchanged.
  • The README, SECURITY.md, the crate READMEs, the image labels and the Helm chart’s description: unchanged by this release, and none names the usage report’s format or the zip library.

Left open on 2026-10-16

Nothing.

The review of 2026-10-19

Read on the tree after commit a15290689, for FerroEHR 4.3.11. The facts of this release checked in every text in scope: the viewer drawn in FerroUI 0.1.6 (#3802, #3811, #3815, #3820); the viewer’s tab icon (#3808); the coming split of the logging component with FerroSYS (#3662); the zip advisories judged not affecting in the Rust-dependency VEX document (#3806).

Changed on 2026-10-19

Nothing. Every text in scope already states these facts.

Read and kept on 2026-10-19

  • The viewer pages: the viewer names the footer’s manufacturer line and the System screen’s Manufacturer card (#3634), and dark mode describes the moon and sun button and the theme cookie as FerroUI draws them. No page names a component library the viewer no longer uses.
  • The logging component: the EHDS readiness page and the technical documentation say FerroEHR ships the whole logging component today and that FerroSYS takes the central store, review, retention and retrieval in a later release; nothing calls that split shipped.
  • The security notices: the release’s Security entry names the VEX document that records the three zip advisories as not affecting FerroEHR.

Left open on 2026-10-19

Nothing.

The review of 2026-10-19, for 5.0.0

Read on the tree after commit ec78e7f40, for FerroEHR 5.0.0. The facts of this release checked in every text in scope: openehr-sdt builds a canonical PARTY from a party Web Template and stamps the caller’s rm_version (#3810, #3809); the openEHR terminology serves the interoperability group; archetype identifiers follow the BASE 1.3.0 grammar under the development profile (#3835, #3845); the nine openehr-* crates at 0.0.89; only the newest release is supported (#3853); the viewer serves its hydration bundle on a route of its own (#3849); the viewer draws with FerroUI 0.1.7 and chooses its language from Accept-Language or [ui] locale.

Changed on 2026-10-19, for 5.0.0

  • The Rust crates: the dependency examples named the crates at 0.0.86, three versions behind the published line; they name 0.0.89. The openehr-sdt row names the PARTY build beside the composition formats.
  • The viewer: the ui.locale row said a deployment adds a language as a catalog file. The catalogs are embedded when the viewer is built, so the row now says a new catalog reaches a deployment in a rebuilt image, never at run time.

Read and kept on 2026-10-19, for 5.0.0

  • The support period: SECURITY.md, the Rust crates page and the release notes say that only the newest release is supported and that the five years of security fixes are a commitment for the product, shipped in the newest release. No text in scope still promises fixes to an earlier release.
  • The crate READMEs: the openehr-sdt README describes the composition formats and does not mention the PARTY build. An omission ascribes no function the crate lacks, and the README changes with the crate’s next content version.
  • The specification pins: docs/VERSIONS.md and the crate table name BASE 1.2.0 and 1.3.0, which is what the two generations implement.

Left open on 2026-10-19, for 5.0.0

Nothing.

The review of 2026-10-20, for 5.1.0

Read on the tree after commit 18097c6f1, for FerroEHR 5.1.0. The facts of this release checked in every text in scope: authentication and the SMART on openEHR scope check run on the shared ferrohealth-smart crate, only asymmetric algorithms verify a token, the HMAC key is gone, and every route requires a scope (#3872); the Keycloak overlay and the development stack serve their issuer over TLS with a development CA, and the CDR accepts their tokens again (#3873); AQL rooted at PERSON, ORGANISATION and ROLE, and a demographic root joined to its EHR’s content (#3818, #3831); the audit FHIR feed and the remote policy service authenticate with OAuth 2.0 client credentials (#3829); the chart mounts a database CA for sslmode=verify-full (#3788); a rotated mTLS client CA is re-read without a restart (#3769); the viewer reads every word from its catalog with FerroUI 0.2.0 (#3860); the nine openehr-* crates at 0.0.90.

Changed on 2026-10-20, for 5.1.0

  • The repository README.md: the authentication bullet named Basic and OAuth2/OIDC alone. Every route now refuses a caller whose scopes do not grant the operation, a limitation a deployment must know (Art. 28(b)), so the bullet says the SMART on openEHR scopes are checked on every route.
  • The Rust crates: the dependency examples named the crates at 0.0.89; they name 0.0.90.

Read and kept on 2026-10-20, for 5.1.0

  • The Keycloak quickstart: Docker Compose, getting started and the README offer the overlay for bearer-token authentication. Between #3872 and #3873 the CDR did not accept the overlay’s tokens; on this tree it does, and the compose page names the development CA and the scopes the realm puts in every token.
  • Authentication and authorization: security, the authentication reference and SMART App Launch describe the [auth] keys, the RFC 9068 profile and the scope check as the code does.
  • Annex II 2.5: a caller refused for want of a scope is refused by the grant its own identity provider issued, which the deployment controls; the re-assessment below stands.

Left open on 2026-10-20, for 5.1.0

Nothing.

Annex II 2.5, re-assessed

EHDS Annex II 2.5: “The harmonised software components of an EHR system shall not include features that prohibit, restrict or place an undue burden on authorised access, personal electronic health data sharing or use of personal electronic health data for permitted purposes.” The text does not define “undue”.

The EHDS readiness page marks the row “Shipped” with two pieces of evidence: the query surface over the whole stored record, and an authorisation layer that refuses or permits without a commercial gate. The question re-assessed here is whether the authorisation defaults change that, now that production refuses the open EHR_ACCESS default unless it is accepted by name.

  • What “authorised” means here. The deploying organisation decides who is authorised, and expresses it in RBAC roles, ABAC policy and per-EHR EHR_ACCESS settings. A refusal of a caller the organisation has not authorised is not a burden on authorised access.
  • The restricted default. Under ehr_access_default = "restricted", an EHR with no settings is reachable only by the admin role, so a professional the organisation means to authorise is refused until someone writes that EHR’s settings. That is a burden, and it is the organisation’s choice: the open default remains available, production accepts it by name, and the restrictive one exists because GDPR Art. 25(2) asks that personal data are not by default “made accessible without the individual’s intervention to an indefinite number of natural persons”. The settings take role entries (role:nurse), so one entry covers a whole role, and the admin role can always reach the record to write them (per-EHR access control).
  • Restriction of processing. A record under restriction is refused on every read path. The controller sets the mark under GDPR Art. 18; it is a restriction the organisation decides on, not one the software imposes (retention, restriction and objection).
  • Small-cell suppression. A cohort query that would serve fewer distinct EHRs than cohort.small_cell_threshold (default 5) withholds its rows and says so. It applies only to population queries across the pseudonymisation boundary, and the deployment sets the threshold.
  • Rate limits and the admission limit. On by default, set above the server’s own measured whole-server ceiling, so they refuse abuse and leave normal load alone (request limits).
  • The licence. The licence in force changes only the stamp on new identifiers; it gates no feature, endpoint, limit or message (app/ferroehr/src/licence/mod.rs).

Outcome. No feature found prohibits or restricts access, sharing or use that the deploying organisation has authorised, and the burdens found are settings the organisation chooses and can change. The “Shipped” status stands on this reasoning, which the readiness page will cite.

Review record

One row per release. The release procedure adds the row when the release is cut, after the texts in scope have been read.

ReviewedTreeChangedLeft open
2026-10-06before 4.3.5, after d6539d47015 claims, in the README, the landing page and three book pagesthe chart description (#3637)
2026-10-084.3.6, after de648fcc35 claims, in the landing page and five book pages; the chart description item closednothing
2026-10-154.3.9, after 606a29cce14 claims, in 17 book pages, the README and the readiness rownothing
2026-10-164.3.10, after e923076de2 claims, in two book pagesnothing
2026-10-194.3.11, after a15290689nonenothing
2026-10-195.0.0, after ec78e7f402 claims, in two book pagesnothing
2026-10-205.1.0, after 18097c6f12 claims, in the README and one book pagenothing
2026-10-205.2.0, after e5aa1902b1 claim, in one book pagenothing
2026-10-225.3.2, after 434cd351e4 claims, in four book pages and the technical documentationnothing
2026-10-235.3.3, after 25845e6d31 claim, in one book page: the templates page said `raw` is write-only, corrected for #3934
2026-10-245.4.0, after de38ec433nonenothing
2026-10-245.4.1, after 9fdd360912 claims, in two book pages: the compliance index named [terminology.external] and a terminology route, and the crates page pinned 0.0.97nothing
2026-10-255.4.2, after 2333f98banone; the changes of this release updated their own pages in the pull requests that made themnothing
2026-10-275.5.0, after bed3543e31 claim, in the hazard log: H2 named a middleware test renamed by #4192the conformance chapter bars still count the CNF TDD cases, which only a new measured run removes (#4204)

The review of 2026-10-20, for 5.2.0

Read on the tree after commit e5aa1902b, for FerroEHR 5.2.0. The facts of this release checked in every text in scope: authentication runs on ferrohealth-smart 0.3.1, with [auth.basic] cache_ttl_seconds in place of [authz] basic_cache_ttl_seconds, scopeless_subjects accepted and a malformed bearer token answered 401 (#3875); [smart] issuer_metadata_file (#3875); the EHDS Art. 8 restriction of access with its emergency override (#3682); the audited read of a party’s protected identifiers (#3776); an EHR extract never carries a sealed identifier (#3777); the FLAT history origin from the earliest event (#3888); the nine openehr-* crates at 0.0.91.

Changed on 2026-10-20, for 5.2.0

  • The Rust crates: the dependency examples named the crates at 0.0.90; they name 0.0.91.

Read and kept on 2026-10-20, for 5.2.0

Left open on 2026-10-20, for 5.2.0

Nothing.

The review of 2026-10-22, for 5.3.2

Read on the tree after commit 434cd351e, for FerroEHR 5.3.2. The facts of this release checked in every text in scope: the access log written through FerroAUDIT’s ehds-logging and ihe-atna 0.2.4, with the categories named by their HL7 Europe codes, category_basis, and the record’s jti, operation subtype and outcomeDesc written again (#3887, #3902); the retention register keyed on the category token (clinical migration 0016); the syslog feed over RFC 5425 TLS only, with the UDP transport removed; ferrohealth-smart 0.4.0 and one login record per Basic credential verification (#3895); a + in a query string read as a space (#3891); the subject id of a lookup by subject kept out of every log, span and error body (#3906); Rust 1.99.1 with the MSRV at 1.98; the nine openehr-* crates at 0.0.95.

Changed on 2026-10-22, for 5.3.2

  • The Rust crates: the dependency examples named the crates at 0.0.91; they name 0.0.95.
  • EHDS readiness, the European logging component row (from website/book/ehds.yaml): it named ehds-logging and ihe-atna at 0.1.1; it names 0.2.4, the version the root Cargo.toml pins.
  • The hazard log, the component paragraph: as the row above.
  • The technical documentation’s scope (docs/technical-documentation/README.md): as the row above.

Read and kept on 2026-10-22, for 5.3.2

Left open on 2026-10-22, for 5.3.2

Nothing.

The review of 2026-10-24, for 5.4.0

Read on the tree after commit de38ec433, for FerroEHR 5.4.0. The facts of this release checked in every text in scope: the viewer rebuilt on FerroUI 0.3.6 in islands mode, working with JavaScript off, with its new addresses, its preferences as cookies of their own and an open tab that idles out (#3932, #3057); an ECDSA or Ed448 OpenPGP signing key refused at boot for RUSTSEC-2026-0387 (#3965); the signer’s choice of subkey and primary key (#3967, #3969, #3971); the audit drain closing its syslog connection with close_notify and FerroAUDIT 0.2.5 (#3959); the nine openehr-* crates unchanged at 0.0.97.

Changed on 2026-10-24, for 5.4.0

Nothing: the pull requests that made these changes updated the texts they touched.

Read and kept on 2026-10-24, for 5.4.0

  • The viewer statements: the README, the viewer pages and the image label of docker/viewer/Dockerfile describe the viewer the code draws; no text names a removed address, the ferroehr-theme cookie, the Tailwind build or a background poll.
  • The signing statements: PGP signing and the authentication and signing configuration state the refused ECDSA and Ed448 keys, the subkey the server signs with and the retired-key path, as the code does.
  • The audit statements: the hazard log and EHDS readiness name ehds-logging and ihe-atna 0.2.5, the version the root Cargo.toml pins.
  • The landing page, SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the other image labels and the chart description make no claim this release changes.

Left open on 2026-10-24, for 5.4.0

Nothing.

The review of 2026-10-24, for 5.4.1

Read on the tree after commit 9fdd36091, for FerroEHR 5.4.1. The facts of this release checked in every text in scope: the CDR asks one terminology server under [terminology.server] through ferrobridge-term, and chart 14.0.0 wires it (#3085); an unserved method is 405 with Allow under SMART (#3987); the conformance statement declares unmatched-member-refused (#3988); a ROLE template whose single attributes state 0..* uploads, and a demographic template’s example is a party (#3994, #3995); the viewer offers demographic templates only where they work (#3997); the nine openehr-* crates at 0.0.98.

Changed on 2026-10-24, for 5.4.1

  • The compliance index (SGB V § 355 row) named [terminology.external] and “the terminology route”; it now names [terminology.server] and its url.
  • The crates page pinned the openehr-* crates at 0.0.97; this release publishes 0.0.98.

Read and kept on 2026-10-24, for 5.4.1

  • The terminology statements: the integrations configuration, the Kubernetes installation and the chart README describe the one [terminology.server] and secrets.terminologyClientSecret, as the code and the chart do.
  • The template statements: the templates page and content negotiation state the example party of a demographic template and its JSON and XML forms.
  • The 405 statement: content negotiation’s “405 always names the allowed methods” holds under SMART as well.
  • The landing page, SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.

Left open on 2026-10-24, for 5.4.1

Nothing.

The review of 2026-10-25, for 5.4.2

Read on the tree after commit 2333f98ba, for FerroEHR 5.4.2. The facts of this release checked in every text in scope: a valid Certification Revocation of a Direct Key signature cancels it, so it no longer lets the primary key sign (#3972); an access record whose patient lookup failed writes unavailable (#3900); the viewer’s Paths tab offers a query only on a COMPOSITION template, on FerroUI 0.3.7 (#4001); ferrohealth-smart 0.5.0, one openehr-sdt in the build (#4002); ihe-atna and ehds-logging 0.2.7 (#3954); zip 8.6.0 out of the build (#3799); chart 14.0.1; the nine openehr-* crates at 0.0.98.

Changed on 2026-10-25, for 5.4.2

Nothing at the cut: each change above updated its page in its own pull request.

Read and kept on 2026-10-25, for 5.4.2

  • The signing statement: the PGP signing page states that a revoked Direct Key signature is not chosen, as the code does.
  • The viewer statement: Templates & EHRs says a node offers Build a query on this path only on a COMPOSITION template.
  • The logging component: the EHDS readiness page, rendered from the FerroHEALTH book’s website/book/ehds.yaml, and the hazard log name ehds-logging and ihe-atna 0.2.7.
  • The installation pins: the Kubernetes installation, the chart README and verifying releases name chart 14.0.1 and image 5.4.2.
  • The landing page, SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.

Left open on 2026-10-25, for 5.4.2

Nothing.

The review of 2026-10-27, for 5.5.0

Read on the tree after commit bed3543e3, for FerroEHR 5.5.0. The facts of this release checked in every text in scope: the server and the viewer run on the family’s ferrohealth-server, ferrohealth-config, ferrohealth-telemetry, ferrohealth-smart, ferrohealth-authz and ferrohealth-secret crates, with the renamed and retired configuration keys under Changed and Removed (#4087, #4038, #4028, #4139, #4214); the EHR index asks an IHE patient identity source over PIXm by default (#3380); opaque access tokens are checked by introspection (#4033); [auth] ca_file trusts a private issuer CA (#3909); the audit trail is delivered and retrieved through ihe-atna and carries the data permit (#4029, #4030, #4120); Template Data Document import is removed (#4199); the quickstart Keycloak overlay trusts its CA through ca_file and signs the viewer in (#4236, #4243); the chart’s usageReport.enabled=false reaches FerroTERM, FerroBRIDGE and FerroPIX (#4270); chart 20.0.0 with appVersion 5.5.0.

Changed on 2026-10-27, for 5.5.0

  • The hazard log: H2 named ehr_id_from_various_paths, which #4192 replaced with ehr_id_from_the_matched_route; the log names the new test.

Read and kept on 2026-10-27, for 5.5.0

  • The configuration pages: the server configuration, authentication, security and the viewer name the new keys and say which old keys refuse the start (slow_aql_ms, user_role, LEPTOS_SITE_ADDR); no page still offers token_url, server.bind, ferroehr config default, healthcheck --url or the TDD routes.
  • The CRA risk assessment: its outbound data flows table gains the token introspection flow and the patient identity source over PIXm, with revision 14.
  • The installation pins: the Kubernetes installation, the chart README and verifying releases name chart 20.0.0 and image 5.5.0, and the compose page names FERROEHR_CONFIG and the Keycloak overlay’s ca_file.
  • The landing page, SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.

Left open on 2026-10-27, for 5.5.0

  • The conformance chapter bars still show the CNF TDD cases from the last measured run; the record drops them only when the campaign runs again (#4204).

The review of 2026-10-28, for 5.6.0-rc1

Read on the tree after commit df14b4311, for the FerroEHR release candidate 5.6.0-rc1. The facts of this release checked in every text in scope: the REST surface serves three namespaces, ITS-REST alone under /openehr/v1/, FHIR and IHE under /fhir/r4/ and FerroEHR’s extensions under /extensions/v1/, with no redirect from the old paths (#4330); the audit store renders each record’s AuditEvent from its columns, answers every ITI-81 parameter through search tokens, and its migration refuses a store holding records of an earlier release (#4300, #4302, #4313); ferrohealth-server 0.3.7 bounds a silent connection by [server] header_read_timeout_ms (#4283, GHSA-2mc2-h3jq-c5wg); an AQL query nested past its depth bound answers 400 (#4284); a cut or unreachable database answers 503 with Retry-After within about a second (#4290, #4323); an abandoned query frees its connection (#4285); storage generation 3 (#4306); a deployment that declares no category map records accesses through the map FerroEHR ships (#4298); chart 20.0.1-rc1 with appVersion 5.6.0-rc1.

Changed on 2026-10-28, for 5.6.0-rc1

  • The hazard log: H7 named the_current_digest_covers_every_column and records_under_the_original_digest_still_verify and kept a remainder for records under the version 1 digest; #4300 retired the versioned digests with the records they covered, so H7 names the_digest_covers_every_column and the render-policy and search-token tests, and drops that remainder. H3 said FerroEHR ships no category map; it names the shipped map and its test (#4298).
  • The technical documentation named the FerroAUDIT pins as 0.2.5; it names 0.3.1, the version the root Cargo.toml pins.

Read and kept on 2026-10-28, for 5.6.0-rc1

  • The API pages: the API overview names the three namespaces, the resources page the paged CONTRIBUTION list under /extensions/v1/, and the audit trail the ITI-81 route under /fhir/r4/ and the empty audit store 5.6.0 starts on; no page in scope names a moved route under /openehr/v1/.
  • The operations pages: AQL states the depth bounds, operations the 503 on a cut or unreachable database, and the server configuration and security header_read_timeout_ms.
  • The installation pins: the Kubernetes installation, the chart README and verifying releases name chart 20.0.1-rc1 and image 5.6.0-rc1 in the book of main; the frozen book of 5.5.0 under /docs/latest/ keeps the 5.5.0 pins, since a pre-release freezes no book.
  • The landing page, SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.

Left open on 2026-10-28, for 5.6.0-rc1

  • The public sandbox runs 5.5.0 and is not redeployed for a pre-release, so it serves the old paths until 5.6.0 ships.

The review of 2026-10-29, for 5.6.0-rc2

Read on the tree after commit 40982f374, for the FerroEHR release candidate 5.6.0-rc2; 5.6.0-rc1 was tagged at 61d5d1ff7 and not published. The review of 2026-10-28 stands for everything 5.6.0-rc1 carried. The facts added since and checked in every text in scope: the [origins] register stamps a direct commit from a registered client with that client’s origin, a retention period may be keyed on a registered origin, and the ABAC request carries category and origin attributes (#4299); a client CA under client_auth = "none" is refused at load (#4349, GHSA-52mf-qg39-4q4j); the chart refuses to render without an explicit deployment profile (#4294); the logging component’s intended performance is stated and measured (#4293) and its ATNA transactions are exercised against independent implementations (#4296); an unclassified record’s FHIR rendering carries the BALP entity:data slice (#4353); chart 21.0.0-rc2 with appVersion 5.6.0-rc2.

Changed on 2026-10-29, for 5.6.0-rc2

  • The hazard log: H4 said a body without FEEDER_AUDIT records this server as its origin; it names the registered client’s origin and its test (#4299). H3’s remainder said the chart’s empty map matches the binary, which ships a map since #4298; it says the shipped map applies.

Read and kept on 2026-10-29, for 5.6.0-rc2

  • The retention and access pages: retention, the audit configuration and security describe the [origins] register, the origin-keyed retention policy and the two ABAC attributes as the code implements them.
  • The installation pins: the Kubernetes installation, the chart README and verifying releases name chart 21.0.0-rc2 and image 5.6.0-rc2 in the book of main, and the Kubernetes page sets config.deployment_profile in its install example (#4294).
  • The TLS and performance pages: the server configuration says client_ca_file is refused under client_auth = "none", and logging performance states the claims scripts/perf/audit.sh measures.
  • The landing page, SECURITY.md, the crate READMEs, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.

Left open on 2026-10-29, for 5.6.0-rc2

  • The public sandbox runs 5.5.0 and is not redeployed for a pre-release, so it serves the old paths until 5.6.0 ships.

The review of 2026-10-29, for 5.6.0-rc3

Read on the tree after commit df26a92e6, for the FerroEHR release candidate 5.6.0-rc3. The review of 2026-10-29 for 5.6.0-rc2 stands for everything that release candidate carried. The facts added since and checked in every text in scope: every error on the openEHR, extension and management routes is RFC 9457 problem details, the violations of a validation refusal reach a client as errors under Prefer: return=representation, and an authentication refusal under the FHIR base is an OperationOutcome (#4342); the openehr-its client reads a problem details refusal (#4355); a + in a query string is a literal plus (#4356); FROM PERSON p CONTAINS EHR e joins through the EHR’s EHR_STATUS.subject.external_ref (#4411); a projection ending on a list attribute answers the list once (#4413); the local EHR index is a supported posture (#4379); [server] idle_timeout_ms closes an idle kept-alive connection on ferrohealth-server 0.4.0 (#4417); the FHIR AuditEvent names the object it touched by entity.what.identifier on FerroAUDIT 0.3.2 (#4366); openehr-query is Apache-2.0 from 0.0.116 (#4405); the chart and the sandbox run FerroPIX 0.0.12 (#4392); chart 21.0.0-rc3 with appVersion 5.6.0-rc3.

Changed on 2026-10-29, for 5.6.0-rc3

  • The validation pages: template validation and the privacy configuration said a 422 lists its violations in validationErrors, the body #4342 replaced; they name the problem’s errors, sent under Prefer: return=representation, and link the validation-failed type.
  • The hazard log: H2 states that the FHIR AuditEvent names the object it touched by entity.what.identifier and names its test (#4366).

Read and kept on 2026-10-29, for 5.6.0-rc3

  • The API pages: Problem types and content negotiation and errors describe the problem body, its types and the Prefer members as the code writes them; querying with AQL describes the party join through the subject reference and the list projection as the engine answers them.
  • The server configuration: the server configuration states idle_timeout_ms and the head bound as ferrohealth-server 0.4.0 applies them.
  • The licensing page and the crate READMEs name openehr-query Apache-2.0 from 0.0.116 and BUSL-1.1 for 0.0.60 to 0.0.115.
  • The installation pins: the Kubernetes installation, the chart README and verifying releases name chart 21.0.0-rc3 and image 5.6.0-rc3 in the book of main.
  • The landing page, SECURITY.md, the information sheet, the instructions for use, the image labels and the chart description make no claim this release changes.

Left open on 2026-10-29, for 5.6.0-rc3

  • The public sandbox runs 5.5.0 and is not redeployed for a pre-release, so it serves the old paths and the old error body until 5.6.0 ships.