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.

What openEHR gives you

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

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

What makes this implementation different

  • Compliance you can run yourself. The openEHR conformance catalogue is executed by a committed runner against a live server, over both canonical JSON and canonical XML, and the profile verdicts are computed from the per-case outcomes. The run records live in the repository, and every number on the Conformance page is derived from them at build time, never hand-typed.
  • The openEHR specifications, generated directly from the official machine-readable models: the Reference Model, the Archetype Model (1.4 and 2.4), the serialization schemas, and the REST API contract (Release-1.1.0). openEHR’s own published terminology (3.1) ships embedded, byte-identical to upstream. A specification update is a regeneration.
  • Two selectable specification generations. One configuration key, spec_profile, chooses the whole generation set the server runs: development (Reference Model 1.2.0 with BASE 1.3.0 and LANG 1.1.0 — the default) 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, the FHIR R4 connectors, change events and the viewer are in one repository under the Business Source License 1.1. Nothing is held back to be sold back to you. (Vendored openEHR material keeps its own upstream terms, and the spec crates that embed it say so in their own metadata; see Licensing & legal.)
  • Every claim checkable. Conformance is executed by a runner against a live server, and the run records, per-case results, measured performance and the comparison with another CDR (in both directions) are committed to the repository. If a number appears on this site, the record it came from is in the tree, and a change that moves a verdict cannot land quietly.
  • The specification is the authority. The normative openEHR text is vendored in the repository and cited decision by decision. Where we find it silent or self-contradictory, the finding is filed in public and reported upstream.
  • The specification layer as reusable libraries. The generated openEHR model, the canonical codecs, the REST contract, the ADL engine and the AQL parser are published on crates.io as eight standalone crates, so the next Rust project does not have to re-model openEHR to get started. The five generated model crates are under the Apache License 2.0, the licence of the openEHR artifacts they are generated from, and need no licence conversation at all.
  • Maintenance in the open. Public roadmap, public issue tracker, changelog-driven releases, signed artifacts, and a security policy with a private reporting channel.
  • A compliance posture you can read before you buy. FerroEHR aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture, and an EHDS conformity self-assessment is on its roadmap. The compliance overview states which controls ship today, which are planned and under which issue number, and which obligations stay with the organisation running the software. It claims no certification and no conformity, because a product cannot hold either on its own.

Running FerroEHR commercially

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

What you are 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. It starts with a conversation with the maintainer named in MAINTAINERS.md, and that conversation is usually short. Talk to us early.

What we ask in return

Contribute back.

The licence does not oblige you. The arithmetic does:

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

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

What contributing back can look like

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

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

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

What this is not

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

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

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

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


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

Getting started

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

Warning

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

1. Start the stack

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

docker compose up

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

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

2. Probe the status endpoint

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

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

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

What is already in the sandbox

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

  • 8 EHRs, each with a mixed record and its own subject id (sandbox-patient-01-08 in the ferroehr-sandbox namespace, so GET /ehr?subject_id=sandbox-patient-01&subject_namespace=ferroehr-sandbox finds one). 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 definition/archetype/adl2 and 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, the FHIR connector, SMART discovery), plus FerroEHR — Complete surface last, which is the whole server in one document. Every entry is filtered from that same document, which the server generates from its own handlers, so nothing here can drift from the routes it actually serves. When authentication is enabled the “Authorize” dialog shows the one scheme the server is configured for (HTTP Bearer/JWT when OIDC is set up, otherwise HTTP Basic). The hosted sandbox runs the same UI at https://sandbox.ferroehr.eu/ferroehr/rest/swagger-ui if you want to try it before running anything locally — and https://sandbox.ferroehr.eu itself opens the viewer on that server, with the demo credentials ferroehr / ferroehr.

Next steps

Installation

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 /ferroehr/rest/status. Set it to production and the server refuses to start while a separation is open and not accepted by name in deployment_accepts. Read deployment_profile before you point a deployment at real patient data.

Choosing a specification generation

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

Note

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

Try it in Codespaces

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

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/rubentalstra/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/rubentalstra/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 extensions (uuid-ossp, pgcrypto, pg_trgm, btree_gist), so the app role never needs superuser.
ghcr.io/rubentalstra/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 extensions, and bakes in no migration state. The server owns the schema content and applies its migrations idempotently at every boot, so a fresh database self-provisions and a restart is a no-op.

Note

PostgreSQL init scripts run only when the data volume is empty. If you see startup notices like skipping role creation (no CREATEROLE privilege) 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 two core services (no profile needed):

  • 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; server.swagger_ui chooses who may read it).

Important

Every published port binds the loopback interface by default (127.0.0.1:HOST:CONTAINER), and that is a security property rather than a style choice: a published port is DNAT’d by rules Docker inserts ahead of the host firewall’s own chains, so 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 default sandbox deployment profile: the server names every separation it has not made on the boot banner, in the log and on GET /ferroehr/rest/status, and the viewer raises a notice saying the deployment must not hold real patient data. That is the quickstart telling the truth about itself, not a fault. 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 OIDC variant (Keycloak overlay)

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

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

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

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

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

One confidential client is defined (ferroehr / ferroehr-quickstart-secret, with the password grant enabled so a token can be fetched by curl). Fetch a token and call the API with it:

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

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

Warning

The realm, the client secret and the user passwords are demo values, served over plain HTTP by a Keycloak in start-dev mode, which is also why the overlay sets the server’s allow_insecure_issuer opt-in. For a real deployment, drop this overlay and point [auth.oidc] (or FERROEHR__AUTH__OIDC__*) at your own issuer over HTTPS.

The terminology overlay (FerroTERM)

FerroTERM is the Ferro family’s FHIR terminology server: R4, R4B and R5 endpoints over a precomputed index, no JVM, no database, one distroless image. The overlay docker-compose.terminology.yml starts it beside the CDR and switches the CDR’s external terminology on, so archetype value-set bindings resolve at commit and AQL TERMINOLOGY() expands through it (the /terminology/* extension routes answer from it too once [terminology] api_enabled = true, which is off by default):

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

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

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

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

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

Three things the operator owns:

  • The code-system licence. SNOMED CT needs an Affiliate Licence (free in Member countries, see snomed.org/get-snomed); LOINC, ICD and RxNorm have their own terms. Every surface that shows SNOMED CT content carries the notice its licence prescribes (clause 8.3.1) and the release version and date.
  • Who can reach the server. The overlay publishes FerroTERM on the loopback interface at port 8090 (FERROEHR_TERMINOLOGY_PORT) so you can query it while developing. FerroTERM has no authentication and no rate limit of its own; a deployment that serves other machines puts a reverse proxy with both in front, or removes 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__EXTERNAL__FAIL_ON_ERROR=true turns an unreachable server into a 422 refusal instead; the terminology page explains both. A code the server resolves as outside its value set is refused with 422 under either posture.

Optional services (Compose profiles)

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

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

    docker compose --profile viewer up
    # → http://localhost:3000  (log in with ferroehr / ferroehr)
    
  • 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_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/rubentalstra/ferroehr:<release>Server image to run.
FERROEHR_POSTGRES_IMAGEghcr.io/rubentalstra/ferroehr-postgres:<release>Database image to run.
FERROEHR_VIEWER_IMAGEghcr.io/rubentalstra/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_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__LOG__FORMATprettyLog rendering for docker compose logs. Set json for log collectors.
FERROEHR__LOG__FILTERinfoLog level filter.
FERROEHR__DB__MAX_CONNECTIONS10Server connection-pool ceiling.
FERROEHR__SERVER__MAX_IN_FLIGHT256In-flight request admission cap (503 past it; 0 disables).
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 /ferroehr/rest/status.

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

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

Repository development

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

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

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

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

Tip

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

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

Build provenance

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

Observability overlay

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

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

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

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

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

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

Next

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/rubentalstra/charts/ferroehr \
  --version 10.0.2 -n ferroehr \
  --set database.existingSecret=ferroehr-db \
  --set image.tag=4.3.0

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/rubentalstra/charts/ferroehr --version 10.0.2

Pin two versions, not one

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

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

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

Note

A published chart version being immutable is a property of that refusal, not of the registry: an OCI tag is mutable, and helm 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.36.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/rubentalstra/charts/ferroehr:<chart-version> \
  --certificate-identity-regexp '^https://github\.com/rubentalstra/FerroEHR/\.github/workflows/build-chart\.yml@' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

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

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

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

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

Important

Both commands verify what the publishing lanes produce now. A published artifact is never replaced, so if one you pinned answers HTTP 404: Not 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/rubentalstra/charts/ferroehr --version 10.0.2 \
  -s templates/configmap.yaml --set database.existingSecret=ferroehr-db \
  | sed -n '/ferroehr.toml/,$p' | sed '1d;s/^    //' > /tmp/ferroehr.toml
docker run --rm -v /tmp/ferroehr.toml:/etc/ferroehr/ferroehr.toml:ro \
  -e FERROEHR__DB__URL=postgres://u:p@db:5432/ferroehr \
  --entrypoint /usr/local/bin/ferroehr ghcr.io/rubentalstra/ferroehr:<tag> config 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.oidc issuer or a config.auth.basic.users entry before expecting a request to succeed.

Configuration: one key, one file

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

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

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

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

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

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

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

Database roles — who runs migrations

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

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.

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

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

Secrets and mounted config

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

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

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.authOidcHmacSecretmounted at /etc/ferroehr-secrets/auth.oidc.hmac_secret; only the path is env
secrets.signingKeyPassphrasemounted at /etc/ferroehr-secrets/signing.key_passphrase
secrets.multimediaSecretAccessKeymounted at /etc/ferroehr-secrets/multimedia.secret_access_key
secrets.basicUserPasswordHashes (per username)mounted at /etc/ferroehr-secrets/auth.basic.users.<username>.password_hash; the chart injects the matching password_hash_file
secrets.terminologyOauth2ClientSecrets (per client)mounted at /etc/ferroehr-secrets/terminology.external.oauth2_clients.<name>.client_secret; the chart injects the matching client_secret_file
secrets.eventsUrl, secrets.fhirOutboundUrlmounted at /etc/ferroehr-secrets/events.url and …/fhir.outbound.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 username, and declare only the username and 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 (TLS with sslmode=verify-full, pgaudit, at-rest encryption, WAL archiving / PITR) belong to whoever provisions PostgreSQL; the chart references them but cannot enforce them. See Operations.

Health probes

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

ProbeRouteContract
liveness/health/liveness200 while the process is up; touches no dependency
readiness/health/readiness200 (UP/DEGRADED) or 503 (DOWN): checks the database ping, migrations applied, the audit sender and the event outbox, each bounded at one second
startup/health/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 defaults to the openEHR status document rather than a health route, so readiness never touched the database and a pod with a dead database reported Ready and took clinical traffic. ferroehr healthcheck --url … is still useful by hand; it is not what a readiness probe should run.

Warning

Migrations run only at boot, so a replaced or wiped database leaves the pods NotReady until something migrates it. Readiness reports "migrations": {"status":"DOWN","detail":"core schema tables missing (migrations not applied)"} while "db" 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:
    oidc:
      issuer: https://keycloak.example.org/realms/ferroehr
      audiences: [ferroehr]

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

Warning

config.auth.enabled: 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.

Optional integrations

Any server setting is reachable

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

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

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

Two things make this safe to rely on:

  • A typo is a boot refusal, not a silent default. The server sweeps its configuration strictly and rejects an unknown key with a did-you-mean, so a misspelled path fails loudly at startup instead of quietly doing nothing. Check before you deploy with the schema and boot gates described under Check your values before you deploy them.
  • Credentials do not belong in this tree. 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.
OAuth2/OIDC authconfig.auth.oidc.issuerunsetPrefer JWKS/discovery over the HS256 secrets.authOidcHmacSecret.
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.
FHIR inbound/façadeconfig.fhir.api_enabledoffRead façade + inbound mapping.
FHIR outbound → AMQPconfig.fhir.outbound.enabledoffCarries PHI (the mapped FHIR resource). Separate exchange; TLS broker only; URL via secrets.fhirOutboundUrl.
S3 multimediaconfig.multimedia.enabledoff⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket; keys via secrets.multimediaAccessKeyId and secrets.multimediaSecretAccessKey.
External terminologyconfig.terminology.external.enabledoffFHIR terminology server; the provider map is more config.terminology.external.providers keys. To run one in the cluster instead, use terminology.enabled (FerroTERM), which sets these keys for you.
ATNA audit 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; unset means the OpenTelemetry layer is not installed at all (zero overhead). With networkPolicy.egress.enabled, add a rule for the collector, since a blocked exporter drops spans without an error.

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

FerroEHR Viewer (a second workload, off by default)

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

Three properties are worth knowing before you switch it on:

  • It reaches the CDR strictly over the REST API, which viewer.networkPolicy.enabled (on by default) enforces rather than assumes: the viewer’s egress admits the CDR Service, DNS and outbound HTTPS for an identity provider, and nothing else. It holds no database credential.
  • It is a human-facing web UI, so viewer.ingress.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.

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

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

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

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

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

  • config.terminology.external.providers.default — two answers to “which server resolves a binding” is how resolution ends up pointed somewhere nobody chose.
  • config.terminology.external.enabled: false — the rendered ferroehr.toml would say the opposite of your values file.
  • config.terminology.external.fail_on_error disagreeing with terminology.failOnError — same reason, for the posture that decides whether an unresolvable binding is a 422.
  • a provider of your own with no config.terminology.external.routes entry naming it — an unmatched terminology falls back to the provider called default, which the wiring has just taken, so an unrouted provider is configuration that renders, boots and is never called.

Set terminology.wireCdr=false to keep your own provider and run FerroTERM unwired. A second provider under any other name is fine at any time, with a routes entry pointing at it:

config:
  terminology:
    external:
      providers:
        tx:
          type: fhir
          url: https://tx.example.com/fhir
      routes:
        "http://loinc.org": tx

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

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

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

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

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

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

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

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

Per-domain backups (three CronJobs, off by default)

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

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

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

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

Staying available while things move

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

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

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

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

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

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

Upgrades

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

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

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

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

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

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

Check your values before you deploy them

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

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

FERROEHR_IMAGE=ghcr.io/rubentalstra/ferroehr:4.3.0 \
  deploy/helm/ci/boot-check.sh my-values.yaml

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

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

Cluster hardening

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

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

Important

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

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

The five pages

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 OIDC HMAC secret, the version-signing passphrase, any Basic user’s Argon2id hash, and any terminology-server client secret. So “etcd is a cluster concern” is true and insufficient: for this workload etcd is the confidentiality boundary of the credentials that reach PHI. The full inventory is what our Secrets contain.

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

  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 development mechanism here and config.auth.oidc is the production one.

Cluster RBAC, and why this chart needs none

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

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

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

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

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

Kubelet access

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

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

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

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

Images: build, provenance, scanning

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

The build phase, and what distroless costs

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/rubentalstra/ferroehr:main \
  -R rubentalstra/FerroEHR --format json \
  | jq '.[0].verificationResult.signature.certificate
        | {subjectAlternativeName, issuer, sourceRepositoryRef, runnerEnvironment}'
{
  "subjectAlternativeName": "https://github.com/rubentalstra/FerroEHR/.github/workflows/build-image.yml@refs/heads/main",
  "issuer": "https://token.actions.githubusercontent.com",
  "sourceRepositoryRef": "refs/heads/main",
  "runnerEnvironment": "github-hosted"
}

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

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)
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/rubentalstra/FerroEHR/.github/workflows/, and all with issuer https://token.actions.githubusercontent.com.

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

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

Kyverno

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

  • type: SigstoreBundle. These attestations are GitHub Artifact Attestations, stored in the Sigstore bundle format as an OCI referrer. Kyverno reads that format only under this type; the field defaults 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/rubentalstra/ferroehr"
            - "ghcr.io/rubentalstra/ferroehr:*"
            - "ghcr.io/rubentalstra/ferroehr-viewer*"
          # Sigstore bundle format, GitHub Artifact Attestations. Omitting
          # this defaults to Cosign, which looks for a signature that does not
          # exist and refuses every image.
          type: SigstoreBundle
          failureAction: Enforce
          attestations:
            - type: https://slsa.dev/provenance/v1
              attestors:
                - count: 1
                  entries:
                    - keyless:
                        issuer: https://token.actions.githubusercontent.com
                        # Released images only. For a staging cluster that runs
                        # the development tag, make the group
                        # `(heads/main|tags/v.+)`.
                        subjectRegExp: '^https://github\.com/rubentalstra/FerroEHR/\.github/workflows/containers\.yml@refs/(tags/v.+)$'
                        rekor:
                          url: https://rekor.sigstore.dev
              conditions:
                - all:
                    - key: '{{ buildDefinition.buildType }}'
                      operator: Equals
                      value: https://actions.github.io/buildtypes/workflow/v1

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

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

sigstore-policy-controller

If you already run it. The equivalent two details:

  • signatureFormat: 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/rubentalstra/ferroehr**"
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://token.actions.githubusercontent.com
            # Same group as the Kyverno policy: `(heads/main|tags/v.+)` for a
            # staging cluster that runs the development tag.
            subjectRegExp: '^https://github\.com/rubentalstra/FerroEHR/\.github/workflows/containers\.yml@refs/(tags/v.+)$'
      signatureFormat: bundle
      attestations:
        - name: require-slsa-provenance
          predicateType: https://slsa.dev/provenance/v1

Note

What has been checked, and what has not. The identity, the issuer and the predicate type above are verified first-hand against the published images with cosign verify --certificate-identity-regexp …, which admits them and refuses both an unsigned image and, under a tags-only pattern, a :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/rubentalstra/ferroehr:main \
  -R rubentalstra/FerroEHR \
  --signer-workflow rubentalstra/FerroEHR/.github/workflows/build-image.yml

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

Important

Signing landed in the publishing lanes during the 3.17.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/rubentalstra/ferroehr:<tag> -R rubentalstra/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/rubentalstra/charts/ferroehr:<version> -R rubentalstra/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.limits.body_bytes (413 on an over-large body), 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.connection.header_read_timeout_secs and its HTTP/2 siblings bound a socket before a request exists; config.server.rate_limit refuses 429; config.server.max_in_flight sheds 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 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
OIDC issuer (discovery + JWKS)config.auth.oidc.issuer, unless config.auth.oidc.jwks_json or config.auth.oidc.jwks_json_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 server(s)config.terminology.external.enabled plus a provider 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.external.oauth2_clients.<name>.token_url443off-cluster
AMQP broker (events)config.events.enabled plus secrets.eventsUrl5672, or 5671 with config.events.tlsin-cluster
AMQP broker (FHIR outbound)config.fhir.outbound.enabled plus secrets.fhirOutboundUrl5672 / 5671in-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.enabled514 UDP, or 6514 TCP with config.audit.syslog.transport: tlsoff-cluster
FHIR audit repositoryconfig.audit.fhir_feed.enabled plus secrets.auditFhirFeedUrl443off-cluster
Subject-proxy source systema config.subject_proxy.systems entry’s base_url443off-cluster

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

Warning

A NetworkPolicy cannot match a DNS name. The API selects peers by pod, namespace or 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 OIDC HMAC secretsecrets.authOidcHmacSecret (HS256 development setups)the ability to mint valid tokens for any user and role
The version-signing passphrase (plus the PGP key via config.files)config.signing.mode: pgpthe ability to forge version signatures, breaking the integrity guarantee
A terminology client_secretsecrets.terminologyOauth2ClientSecretsaccess to that terminology server as this client
AMQP broker URLssecrets.eventsUrl, secrets.fhirOutboundUrlthe FHIR outbound stream carries PHI; the events stream 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.auth.oidc.hmac_secret_file, config.signing.key_passphrase_file, config.multimedia.secret_access_key_file, a terminology client’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
OIDC HMAC secretupdate, then restartinvalidates every token signed with the old secret; prefer JWKS or discovery, where the issuer rotates for you and no secret lives here
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.log.format: json (the chart’s default) for a collector; pretty is for a terminal.

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

Application 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.98.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 the search order (FERROEHR_CONFIG, ./ferroehr.toml, /etc/ferroehr/ferroehr.toml);
  • --set <key>=<value> is a repeatable dotted-path override with the highest precedence of all (--set db.max_connections=40);
  • ferroehr config 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 config default writes the annotated default configuration template to stdout, which is the reference every key in the configuration reference is drawn from;
  • 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) probes the status endpoint and exits 0 or 1. It defaults to http://127.0.0.1:8080/ferroehr/rest/status; override with --url or FERROEHR_HEALTHCHECK_URL.

Note

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

Build features

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

A slim build compiles them out entirely:

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

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

Warning

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

Configuration reference

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

Quickstart

Generate an annotated template, edit it, and run:

# Write a fully-commented ferroehr.toml with every key at its default.
ferroehr config default > ferroehr.toml

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

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

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

How configuration loads

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

  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_URLdb.url, and RUST_LOGlog.filter.
  • PORT → a 0.0.0.0:<PORT> value for server.bind (the port container platforms such as Vercel, Cloud Run and Heroku inject and route traffic to).
  • The libpq environment set (PGHOST, PGUSER, PGPASSWORD, PGDATABASE, PGPORT, PGSSLMODE), which managed-Postgres integrations such as Neon inject: 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.oidc] issuer = "…"FERROEHR__AUTH__OIDC__ISSUER=…
[management.endpoints] env = "off"FERROEHR__MANAGEMENT__ENDPOINTS__ENV=off
[terminology.external.providers.default] url = "…"FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__DEFAULT__URL=…

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

Note

Enum values are lowercase / snake_case tokens, exactly as the tables show. Secret-typed keys are redacted everywhere the configuration is rendered (the /management/env snapshot, ferroehr config check, logs), and each has a *_file sibling that reads the value from a file, for Kubernetes and Docker secret mounts. Setting a secret and its *_file sibling at once is a boot error.

File discovery

The first of these that exists is loaded (later layers still override its values):

  1. --config <path>,
  2. FERROEHR_CONFIG=<path>,
  3. ./ferroehr.toml (current directory),
  4. /etc/ferroehr/ferroehr.toml.

An explicitly pointed-at file (1–2) is fatal if absent; the search-order files (3–4) are simply skipped when absent, but fatal if present and unreadable or unparseable.

Strict validation

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

  • Unknown keys are rejected: in the file, with the offending line number and a did-you-mean suggestion, and in the FERROEHR_ environment namespace. A variable in that namespace that is neither a known section nor one of the reserved non-configuration names is a boot error, so a misspelled security key can never be silently ignored. A single-underscore near miss (FERROEHR_DB_URL) is reported with the exact uniform spelling it should have had.
  • Type errors are boot errors, naming the key and what was expected.
  • Semantic errors are aggregated: one pass reports every problem at once, so a broken configuration is fixed in a single iteration.

spec_profile

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

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

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
stabledevelopmentAlways 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.
developmentstableOnly 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 (default)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 /ferroehr/rest/status under deployment
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 three pools reach three different PostgreSQL clusters, read from pg_control_system().system_identifier on each pool, never from the DSN text
open_subject_namespace[privacy] subject_namespaces declared, so an EHR_STATUS subject is an opaque pseudonym
audit_off[audit] enabled with a durable sink (the local store, syslog or the FHIR feed)
migrate_on_runtime_credential[db] migrate_url set, or migrate = "verify", so the credential that serves requests cannot alter the schema

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

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

Where each section is documented

SectionWhat it coversPage
[server], [server.limits], [server.rate_limit], [server.connection], [server.tls], [server.identity]The HTTP listener, request limits, rate limiting, connection bounds, TLS, the deployment’s own identityServer, database & telemetry
[db]PostgreSQL connection, pool, migrationsServer, database & telemetry
[log], [telemetry]Log rendering and OpenTelemetry exportServer, 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
[events], [fhir]Change eventing and the FHIR connectorIntegrations
[terminology], [multimedia]External terminology servers, multimedia externalizationIntegrations
[audit], [audit.store], [audit.syslog], [audit.fhir_feed]The IHE ATNA audit trail and its sinksAudit & subject proxy
[subject_proxy]The FHIR systems subject-proxy frames may readAudit & subject proxy
[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 and REST surface ([server] and its sub-tables), the PostgreSQL connection ([db]), and the two observability sections ([log], [telemetry]). Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.

[server]

The HTTP listener and REST surface.

[server]
bind = "0.0.0.0:8080"
base_path = "/ferroehr/rest/openehr/v1"
max_in_flight = 256
swagger_ui = "private"
cors_permissive = false
system_id = "ferroehr.local"
KeyTypeDefaultDescription
bindstring0.0.0.0:8080Socket address the API listener binds.
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.
max_in_flightint256Concurrent-request admission cap (not a rate). Requests beyond it are shed immediately with 503 + Retry-After, never queued, so offered load beyond capacity cannot exhaust memory. 0 installs no shedding layer at all.
swagger_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__SERVER__SYSTEM_ID).

The shed sits on the clinical API subtree only, as its outermost layer: a shed request never reaches authentication, auditing, or the request body, and the always-on status, health, discovery and management routes are never shed.

base_path: shortening the REST base path

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

  • The first segment is ferroehr. /ferroehrx/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 stays at the process root: /health, /health/liveness, /health/readiness.

Set it from the environment with:

FERROEHR__SERVER__BASE_PATH=/ferroehr/v1

Note

The ferroehr healthcheck subcommand, which the published container images run as their Docker health check, derives its default URL from the effective configuration (http://127.0.0.1:<port><REST root>/status), so it follows a shortened base path on its own. An explicit FERROEHR_HEALTHCHECK_URL still wins, for example to probe /health/readiness, which no base path affects.

Behind a path-prefixed reverse proxy

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

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

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

FERROEHR__SERVER__BASE_PATH=/ferroehr/v1

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

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

[server.limits]: request-body sizes

[server.limits]
body_bytes = 16777216        # 16 MiB
bulk_body_bytes = 67108864   # 64 MiB
KeyTypeDefaultDescription
body_bytesint16777216The largest request body the ordinary clinical surface accepts, in bytes.
bulk_body_bytesint67108864The largest body the bulk routes accept: operational-template upload, /message/import, /message/tdd.

A request over its tier’s limit is refused 413 Payload Too Large with the standard openEHR error body. The status is not in the ITS-REST status table; it is admitted there as an additional, non-conflicting code, and is what RFC 9110 §15.5.14 defines for this refusal.

The defaults are sized against the measured payloads in the vendored clinical corpus rather than chosen as round numbers: the clinical tier clears the largest operational template in that corpus several times over, and the bulk tier is four times the clinical one, for payloads with no published bound (a whole-EHR extract, a TDD batch). Raise body_bytes if your compositions embed large DV_MULTIMEDIA data: a base64 radiology image can exceed either tier on its own, and that is a deliberate operator decision rather than a default.

[server.rate_limit]: per-caller request rates

[server.rate_limit]
enabled = true
principal_per_second = 1024
principal_burst = 2048
address_per_second = 2048
address_burst = 4096
KeyTypeDefaultDescription
enabledbooltrueWhether rate limiting is active. Off allocates no limiter state and costs no per-request check.
principal_per_secondint1024Sustained requests per second per authenticated subject, on the clinical API.
principal_burstint2048How far one principal may burst before refusal.
address_per_secondint2048Sustained requests per second per client address, across the whole tree.
address_burstint4096How far one address may burst before refusal.

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

Two tiers, because they defend different things. The address tier sits outside authentication, so a flood of unauthenticated requests is refused before it can make the server verify a signature per request; a limiter must not itself be the expensive path. The principal tier sits inside authentication, keyed on the authenticated subject, which is the only fair key for a clinical API: a hospital behind one NAT is a single address, so an address-keyed clinical limit would throttle an entire site because one client was busy.

Both defaults sit above this implementation’s own measured whole-server ceiling, so neither tier can refuse a caller until it is asking for more than the server could have served; below that line, capacity is max_in_flight’s job. A deployment sized for more than the reference measurement environment should raise both in proportion.

Refusals carry the limiter’s own Retry-After and x-ratelimit-* headers alongside the openEHR error body. 429 is the status RFC 6585 §4 defines for this refusal, admitted by ITS-REST as an additional, non-conflicting code.

The always-on health family is covered by the address tier only, deliberately: an orchestrator probe must never be refused because a principal-keyed bucket was exhausted, and probe rates are nowhere near the address ceiling.

Tip

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

[server.connection]: bounds before a request exists

[server.connection]
header_read_timeout_secs = 10
max_concurrent_streams = 256
http2_keep_alive_interval_secs = 30
http2_keep_alive_timeout_secs = 10

Every other limit on this page engages once a request has been parsed and dispatched: body size, the request timeout, the rate limiter, the in-flight shed. A client that opens a socket and then trickles request headers reaches none of them, while costing itself almost nothing. This table is where that is bounded, and HTTP/1 and HTTP/2 need different bounds because the exposure differs: HTTP/1 streams a request head, so it can be trickled; HTTP/2 multiplexes streams, so the exposure is concurrency.

KeyTypeDefaultDescription
header_read_timeout_secsint10How long a connection may take to deliver a complete HTTP/1 request head, in seconds. 0 disables the bound. Applies to both listeners.
max_concurrent_streamsint256The most HTTP/2 streams one connection may have open at once. Bounds the request-setup work a peer can trigger by opening and immediately cancelling streams (the “HTTP/2 Rapid Reset” amplification, CVE-2023-44487). 0 leaves the HTTP library’s own default.
http2_keep_alive_interval_secsint30Interval between HTTP/2 keep-alive PINGs, in seconds. 0 disables them, and a peer that vanishes without a FIN is then held until the operating system notices.
http2_keep_alive_timeout_secsint10How long to wait for a keep-alive PING response before closing the connection, in seconds.

system_id: the data-authoring identity

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

  • EHR.system_id, recorded when an EHR is created. The openEHR RM (EHR Information Model, EHR Identifier Allocation) says 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.
  • system_id is not [server.identity]. system_id says which system authored the data; [server.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.

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

Native TLS termination on the main listener, off by default; deployments commonly terminate TLS at an ingress. client_auth = "required" is the IHE ATNA ITI-19 mutually-authenticated-node posture (see the Audit trail chapter). The separate-port management listener always stays plain HTTP.

[server.tls]
enabled = false
client_auth = "off"
min_version = "1.3"
KeyTypeDefaultDescription
enabledboolfalseTerminate TLS natively on the main listener.
cert_filepathunsetServer certificate chain (PEM). Required when enabled; a missing, unreadable or certificate-less file stops startup.
key_filepathunsetServer private key (PEM). Required when enabled.
client_authenum{off,optional,required}offClient-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 unless client_auth = "off".
min_versionenum{“1.3”,“1.2”}"1.3"The lowest TLS version this listener negotiates.

Note

min_version defaults to 1.3 only, following the OWASP Transport Layer Security Cheat Sheet: web applications must default to TLS 1.3 and may support TLS 1.2 for compatibility. Setting "1.2" enables 1.2 alongside 1.3, never instead of it; pick it only for a client that genuinely cannot do 1.3, such as an older integration engine or a pinned Java runtime. TLS 1.1 and 1.0 are not selectable at all: RFC 8996 deprecates them, and neither this key nor the TLS library offers them.

[server.identity]

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

KeyTypeDefaultDescription
solutionstringFerroEHRProduct name.
solution_versionstringthe build’s versionProduct version.
vendorstringFerroEHR projectProviding organisation.
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 DSN. Which it is, is read from the server (pg_control_system() plus current_database()), never from the DSN text. Unset, it falls back to url. See Operations → Which credential prepares the schema.
migrate_url_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 the HTTP request timeout cannot be: answering a client by dropping the handler future does not cancel the statement PostgreSQL is running, so without it a handful of expensive queries can hold every pooled connection while every one of their callers has already given up. Keep it above query.timeout_ms so the AQL engine’s own typed refusal fires first and this only catches what the engine does not govern.

[storage]

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

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

[storage.linkage]
url_file = "/run/secrets/db-linkage-dsn"
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.

One constraint on relocation: the linkage migration set revokes a function the party set creates, so those two domains are prepared in the same database. A layout that splits them is refused before anything connects, with the remedy.

[log]

[log]
format = "auto"
filter = "info,ferroehr=info"
KeyTypeDefaultDescription
formatenum{auto,json,pretty}autoStdout rendering. auto picks json when stdout is not a TTY and the coloured human format when it is; an explicit pretty forces colour even through a pipe.
filterstringinfo,ferroehr=infoBoot log-filter directives; also the value /management/loggers resets to. RUST_LOG is a recognized lower-priority alias.

Tip

The ASCII boot banner follows the rendering that is actually installed, not the configured word: it prints for pretty, and 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.

[telemetry]

OpenTelemetry export. With otlp_endpoint unset the trace export layer is not installed at all, at zero overhead.

[telemetry]
service_name = "ferroehr"
environment = "dev"
traces_sample_ratio = 1.0
metrics_push = false
KeyTypeDefaultDescription
otlp_endpointstringunsetOTLP/gRPC collector endpoint. Unset ⇒ no trace export.
service_namestringferroehrThe service.name resource attribute.
environmentstringdevThe deployment.environment resource attribute.
traces_sample_ratiofloat1.0Head-sampling ratio (0.1 is a common production start).
metrics_pushboolfalseAlso push metrics over OTLP, alongside the Prometheus pull surface. Both surfaces are fed by one meter provider, so every instrument reaches both; the push needs otlp_endpoint set as well.
flame_filepathunsetSpan-timing flamegraph capture: write folded stack samples of every span to this file, and render offline with inferno-flamegraph. Unset ⇒ the layer is not installed. For diagnostic sessions, not a standing posture: the file grows with span traffic.

Note

Instrument names carry no Prometheus suffix; the exporter derives one. A counter named auth_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.

Authentication & access

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

[auth]

Authentication: Basic credentials, OAuth2/OIDC bearer tokens, or both.

[auth]
enabled = true
verified_cache_ttl_seconds = 60

[[auth.basic.users]]
username = "clinician"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$…$…"   # never a plaintext password
roles = ["USER"]

[auth.oidc]
issuer = "https://keycloak.example.com/realms/ferroehr"
audiences = ["ferroehr"]
algorithms = ["RS256"]
KeyTypeDefaultDescription
enabledbooltrueMaster switch. false = all requests pass unauthenticated (development only). With true and no mechanism configured the server refuses to start; see below.
verified_cache_ttl_secondsint60Verified Basic-credential cache TTL (0 disables). Argon2 verification costs real CPU per call by design, so a credential that has verified is remembered (as a digest of the presented header, never plaintext) and re-verified after the TTL. It bounds both the KDF cost of a busy client and how long a revoked credential keeps working.

Warning

auth.enabled = 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 [[auth.basic.users]], configure [auth.oidc], or set auth.enabled = false for a development server.

The Basic-auth user store

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

KeyTypeDefaultDescription
usernamestringrequiredPrincipal name. A blank or missing one is a boot error.
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. The Argon2id floor is validated identically either way, because validation runs after the file is resolved. Exactly one of the pair is required.
roleslist of string["USER"]Roles granted, upper-cased on authentication. Use ["ADMIN"] for an administrative account.

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.

[auth.oidc]: bearer validation

The table’s absence disables bearer authentication entirely. When present, the server validates tokens as a resource server; it never issues them.

KeyTypeDefaultDescription
issuerstringrequired when the table is presentExpected iss; also the OIDC discovery base. Must be an absolute https URL with no query and no fragment (RFC 8414 §2), boot-validated.
audienceslist of stringrequired, non-emptyAccepted aud. An empty or all-blank list is a boot error.
algorithmslist of string["RS256"]Accepted signature algorithms. Boot-bound to the key source: HS* requires hmac_secret, RS*/ES*/PS* require public keys (a static JWKS or the discovered one). none is refused outright.
require_at_jwtboolfalseRefuse a token that does not carry typ: at+jwt. A token that does carry it is held to RFC 9068 §2.2 either way: iat, jti and client_id become mandatory for it.
clock_skew_leeway_secondsint60Leeway on the time-based claims (exp/nbf). Capped at 300; above that is a boot error.
allow_insecure_issuerboolfalseAccept a non-https issuer. Development and test only.
hmac_secret / hmac_secret_filesecret / pathunsetSymmetric HS* secret (development/test), minimum 32 bytes. At most one of the pair.
jwks_json / jwks_json_filestring / pathunsetStatic JWKS document. At most one of the pair.
connect_timeout_msint3000TCP connect timeout for the discovery + JWKS fetches.
request_timeout_msint5000Whole-request timeout for the discovery + JWKS fetches (connect, TLS, body read).
negative_cache_ttl_secondsint10How long a failed discovery/JWKS fetch is remembered (0 disables).

The boot rules, and what each one prevents:

  • audiences must name at least one audience. RFC 7519 §4.1.3 obliges a recipient that does not identify itself with a value in a present aud claim to reject the JWT, and RFC 9068 §4 step 4 makes the check unconditional for an access token. A resource server that declares no audience cannot reject a token minted for a different resource server, and cannot tell an OpenID Connect ID token (whose aud is a client id) from an access token (RFC 8725 §3.9, §3.12). Set it to whatever your identity provider puts in aud for this CDR.
  • issuer must be an https URL with no query or fragment. That is the RFC 8414 §2 definition of an issuer identifier, and §6.2 requires TLS for issuer metadata: over plain HTTP an attacker on the network can serve their own signing keys. A development issuer is opted in explicitly with allow_insecure_issuer = true; the no-query/no-fragment rules still apply, since those are structural.
  • clock_skew_leeway_seconds is capped at 300. RFC 7519 §4.1.4 allows “some small leeway, usually no more than a few minutes, to account for clock skew”, and RFC 9068 §4 step 6 repeats the bound. A large leeway silently extends the life of every token past its exp.
  • hmac_secret must be at least 32 bytes. RFC 8725 §3.5: a human-memorizable password must not be used directly as the key to a keyed-MAC algorithm such as HS256. A symmetric key is also shared with the authorization server (meaning this server could mint the very tokens it accepts) so the boot log warns that it is a development posture. Prefer discovery or jwks_json.
  • The algorithm set is bound to the key source. A key belongs to one algorithm family, and accepting an algorithm the configured key material cannot verify is the algorithm-confusion setup RFC 8725 §3.1 warns about: most famously an RS256 deployment that also accepts HS256, letting an attacker sign with the public key as if it were a shared secret.

The signing-key source is exactly one of: the symmetric secret, the static JWKS, or (when neither is set) the issuer’s OIDC discovery document. Configuring both hmac_secret and jwks_json (in either direct or *_file form) is a boot error, never resolved by silent precedence. A validated token must also carry a non-blank sub claim: the authenticated subject is stamped into the audit trail, so a token without one is refused with 401 rather than recorded under a placeholder identity.

The last three keys apply only when keys come from OIDC discovery. The timeouts stop an unresponsive identity provider from parking bearer requests until the operating system’s TCP timeout; the negative cache means a provider outage costs one discovery attempt per negative_cache_ttl_seconds rather than one per incoming request, so callers get fast 401s instead of slow ones. Keep the negative TTL short: it is also how long recovery takes to be noticed after the provider comes back.

[authz]

Role-based (RBAC) and attribute-based (ABAC) authorization. The full evaluation order and design rationale are in Security.

[authz.rbac]

KeyTypeDefaultDescription
enabledbooltrueThe coarse role gate (active when authentication is enabled).
admin_rolestringADMINRole required for admin-class operations. A blank value is a boot error.
user_rolestringUSERBaseline clinical role.
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.
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.
role_claimslist of string["roles","groups","entitlements","realm_access.roles"]JWT claim paths mined for roles, in order. Dotted paths walk nested claims. Must be non-empty and contain no blank path. scope is not a role source; see Security.
ehr_access_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. 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.
check_directoryboolfalseSubmit DIRECTORY (FOLDER) operations to the decision point. Engine-independent, so it works under Cedar as well as a remote PDP.
  • [authz.abac.cedar]: policy_dir (path, required when engine = "cedar" and ABAC is on) and reload_secs (int, unset, an optional hot-reload interval).
  • [authz.abac.remote]: server (string, required when engine = "remote", and it must end with /, because the policy name is appended), connect_timeout_ms (int, 2000), request_timeout_ms (int, 5000).
  • [authz.abac.policy.<kind>]: one entry per resource kind, with kindehr, 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]

SMART App Launch. Off by default; when off the discovery document is not served and the scope gate is inert. See SMART App Launch.

[smart]:

KeyTypeDefaultDescription
enabledboolfalseServe the discovery document and activate the scope gate.
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.
ehr_id_claimstringehrIdToken claim carrying the launch context’s openEHR EHR id.
patient_claimstringpatientFallback launch-context claim when ehr_id_claim is absent.
require_smart_scopesboolfalseWhen true, the resource-scope gate is fail-closed across the composition, template and AQL families, and the openehr-permission-v1 capability is advertised: every caller on those families needs a Bearer token carrying a matching SMART resource scope, so a Basic-authenticated caller, which carries no scopes at all, is refused with 403 there (the EHR family is not scope-governed). When false the gate is advisory (it enforces only when a token actually carries SMART resource scopes) and the capability is not claimed.
launch_base64_jsonboolfalseAdvertise the launch-base64-json capability. Experimental, and advisory: the base64-JSON launch object is consumed by the application, not the CDR.

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

[smart.endpoints] carries the external authorization-server endpoints the discovery document publishes verbatim: issuer, jwks_uri, authorization_endpoint, token_endpoint, registration_endpoint, introspection_endpoint, revocation_endpoint, management_endpoint (all string, unset ⇒ omitted from the document); the advertised lists token_endpoint_auth_methods_supported, grant_types_supported, response_types_supported, code_challenge_methods_supported, scopes_supported and capabilities (all list of string, []; capabilities appends operator-advertised HL7 base capabilities such as launch-ehr or sso-openid-connect to the derived openEHR set); and allow_insecure_endpoints (bool, false).

Everything in [smart.endpoints] is published at /.well-known/smart-configuration for third-party applications to act on, so smart.enabled = true boot-validates it rather than relaying whatever is configured:

RuleWhy
Deprecated grant types (implicit, password) rejected, whether or not SMART is enabledthe SMART App Launch specification’s deprecated-flows section
public_base_url, authorization_endpoint, token_endpoint requiredan enabled Platform without them publishes an unusable document
Every advertised endpoint an absolute https URLthe document tells apps where to send an authorization request and exchange a code, so a plaintext endpoint exposes the code and the access token (RFC 6749 §3.1.2.1, RFC 8414 §6.2). allow_insecure_endpoints = true opts out for development
issuer has no query and no fragmentRFC 8414 §2, the same rule auth.oidc.issuer follows, because it is the same identity
response_types_supported non-emptyRFC 8414 §2 marks the field REQUIRED
token_endpoint_auth_methods_supported non-emptyan empty list advertises a server that authenticates no client
code_challenge_methods_supported includes S256SMART App Launch requires PKCE (RFC 7636); publishing a list without it tells every app the server cannot do PKCE, and plain alone is not sufficient
smart.endpoints.issuer equals auth.oidc.issuerone says where apps obtain tokens, the other which tokens this server accepts. A mismatch means every app gets a valid token and every request is refused
smart.enabled requires [auth.oidc]the CDR cannot validate the tokens it directs applications to obtain

Note

An empty advertised list is not silence: it claims the authorization server supports none of that thing, and a conforming application will believe it.

[management]

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

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

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

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

[management.profiling]
max_seconds = 30
max_frequency = 999
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.bind port, and it is plain HTTP, an internal surface.

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

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 /rest/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 /rest/status; the embedded grant stays in force.

[signing]

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

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 (or other ECC) signing key

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

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

  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 and the server signs with it automatically, with no retired_key_paths entry needed: the certificate keeps the previous subkey, so past signatures continue to verify.

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

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

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

Integrations

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

[query]

AQL execution bounds.

[query]
plan_cache_capacity = 256
timeout_ms = 30000
max_result_rows = 10000
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. The HTTP request timeout cannot stand in for it: answering the client by dropping the handler does not cancel the statement PostgreSQL is running, so overrunning queries would keep holding pooled connections after their callers had been given up on. Keeping this budget the tighter of the two means an overrun surfaces as the engine’s own typed 408 rather than a driver error.

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

[events]

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

KeyTypeDefaultDescription
enabledboolfalseSpawn the outbox publisher. Together with fhir.outbound.enabled it also gates the per-commit outbox INSERT, so with both off the commit path writes no outbox rows at all.
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).
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.

[fhir]

The FHIR connector: an inbound façade and an independent outbound emitter.

[fhir]: api_enabled (bool, false) mounts /fhir/r4/* plus the /admin/fhir_mapping CRUD; the routes answer 404 while it is off.

[fhir.outbound]:

KeyTypeDefaultDescription
enabledboolfalseEmit mapped FHIR resources to the broker.
urlsecret URLamqp://guest:guest@localhost:5672/%2fAMQP broker URL; credentials redacted.
url_filepathunsetRead the broker URL from a mounted file instead. At most one of the pair.
exchangestringferroehr.fhirTopic exchange, deliberately distinct from the events exchange, for PHI isolation.
tlsboolfalseUpgrade amqp:// to amqps://.
batch_sizeint128Outbox rows scanned per poll.
poll_interval_msint1000Idle poll interval.
publish_max_retriesint3Per-message publish retries before backing off.

Warning

The outbound stream carries PHI: its payload is the mapped FHIR resource. That is why it is a separate switch and a separate exchange from the PHI-free change-event stream: broker-level access control can then restrict the PHI-bearing stream on its own. Enable it only against a TLS, access-controlled broker.

[terminology]

The terminology extension API and external FHIR terminology servers.

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

[terminology.external]:

KeyTypeDefaultDescription
enabledboolfalseMaterialise the configured providers. With it off, no remote provider is built and validation stays on the in-process openEHR terminology bundle.
fail_on_errorboolfalseWhat an unresolvable terminology lookup does to a commit: false accepts it (fail-open), true rejects it.

Enabling [terminology.external] with no provider configured is a boot error.

[terminology.external.providers.<name>], conventionally at least default:

KeyTypeDefaultDescription
typeenum{fhir}fhirServer kind. Only FHIR R4B is supported.
urlstringrequiredThe server’s FHIR base URL. Empty is a boot error.
operationenum{validate_code,expand}validate_codeThe membership operation. validate_code is a direct yes/no with the least payload; expand plus a membership test is the fallback for servers without $validate-code.
connect_timeout_msint2000TCP connect timeout.
request_timeout_msint10000Overall request timeout.
oauth2_clientstringunset ⇒ unauthenticatedNames an entry under [terminology.external.oauth2_clients]. A name with no such entry is a boot error.
client_cert_path / client_key_pathpathunsetThe mutual-TLS client identity; see below.
ca_bundle_pathpathunsetThe trust anchors this server’s certificate is verified against; see below.
cache_ttl_secsint300TTL of the per-provider response cache; a repeated validate/expand/subsumes/lookup within the window is served locally instead of one HTTPS round trip per validated code. 0 disables it.
cache_capacityint10000Maximum cached responses per provider.

Cached entries are the decoded responses, not raw JSON: a server answer that is not a valid FHIR R4B Parameters/ValueSet resource (for example an $expand result missing the required ValueSet.status or expansion.timestamp) is treated as an upstream fault rather than partially read, so it takes the same path as an unreachable server and fail_on_error decides what the commit does.

Note

External terminology servers need the 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.external is enabled with any provider configured; the in-process openEHR terminology bundle remains available.

Several terminology servers at once

Every entry under [terminology.external.providers] is materialised at startup, so one instance can serve SNOMED CT from one server and LOINC or ICD from others. [terminology.external.routes] maps a terminology to the provider that answers for it: the key is a terminology id (SNOMED-CT) or a system URI (http://snomed.info/sct), matched case-insensitively as a whole string, and the value names a provider. A terminology with no route goes to the provider named default, or to the sole configured provider when there is exactly one. A route naming a provider that does not exist is a startup error, because a dangling route would otherwise degrade silently into “ask the default server”.

[terminology.external]
enabled = true
fail_on_error = false

[terminology.external.providers.default]
type = "fhir"
url = "https://r4.ontoserver.csiro.au/fhir"

[terminology.external.providers.snomed]
type = "fhir"
url = "https://snowstorm.example.org/fhir"
oauth2_client = "ts-client"

[terminology.external.routes]
"SNOMED-CT" = "snomed"
"http://snomed.info/sct" = "snomed"
"http://loinc.org" = "default"

Routing applies everywhere terminology is consulted: the /terminology/* extension API, AQL TERMINOLOGY(…) resolution, and the composition-commit binding checks below.

Authenticating to a terminology server

[terminology.external.oauth2_clients.<name>] configures an OAuth2 client-credentials client; a provider references it by name with oauth2_client. The access token is cached and re-requested shortly before it expires, so a validation burst costs one token request per token lifetime.

KeyTypeDefaultDescription
token_urlstringrequiredThe OAuth2 token endpoint. Empty is a boot error.
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.
scopeslist of string[]Scopes requested with the client-credentials grant.
refresh_leeway_secsint30How long before stated expiry the token is renewed.
auth_methodenum{client_secret_basic,client_secret_post}client_secret_basicHow the client authenticates at the token endpoint.
[terminology.external.oauth2_clients.ts-client]
token_url = "https://idp.example.org/realms/ts/protocol/openid-connect/token"
client_id = "ferroehr-cdr"
client_secret_file = "/run/secrets/ts-client"
scopes = ["system/*.read"]

Mutual TLS to a terminology server

A terminology server that authenticates its clients with certificates instead of (or in addition to) a bearer token is configured per provider, because a client certificate is issued by that server’s PKI: a deployment enrolled with a national SNOMED CT service, a commercial value-set server and an in-house FHIR server holds three different certificates. Repeat the same paths in each provider table if one identity really does serve them all.

KeyMeaning
client_cert_pathPEM file with the client certificate (optionally a chain) presented to this server.
client_key_pathPEM file with that certificate’s private key.
ca_bundle_pathPEM bundle of the trust anchors this server’s certificate is verified against.
[terminology.external.providers.snomed]
type = "fhir"
url = "https://snowstorm.example.org/fhir"
client_cert_path = "/run/secrets/ts-snomed-client.crt.pem"
client_key_path = "/run/secrets/ts-snomed-client.key.pem"
ca_bundle_path = "/run/secrets/ts-snomed-ca.pem"

client_cert_path and client_key_path are set together; one without the other is a startup error, never a connection that silently presents no certificate. Unreadable files, a certificate file with no certificate in it and a key file with no key in it are startup errors too, so a broken identity never waits until the first validated code to surface.

ca_bundle_path replaces the default trust anchors for that provider, so a terminology server issued by a private PKI is pinned to that PKI instead of also accepting the whole public web PKI. Leave it unset to use the platform’s default trust store.

Warning

There is no option to disable certificate verification. Server-certificate and hostname verification are always on for every provider; ca_bundle_path changes which anchors are trusted, never whether the server is verified.

The client identity applies to the connection to the terminology server itself. An OAuth2 token endpoint (oauth2_client) is a different host in a different trust domain and keeps the default TLS stack.

Kubernetes deployments mount the PEM files with the chart’s config.files map, which materialises them under /etc/ferroehr/.

Archetype value-set bindings at commit

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

  • The code is in the value set → the commit proceeds.
  • The code is not in the value set → 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, no provider routes to that terminology) → fail_on_error decides: false (the default) accepts the commit and logs a warning; true rejects it with 422.

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

Note

The composition’s terminology_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.
endpointstringunset ⇒ default AWS endpoint resolutionS3-compatible endpoint. Must be an absolute http/https URL when set.
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 & subject proxy

The IHE ATNA audit trail ([audit] and its three sinks) and the FHIR systems a subject-proxy frame may read from ([subject_proxy]). Precedence, the environment-name grammar, and file discovery are on the Configuration reference index.

[audit]

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

[audit]
enabled = true
source_id = "ferroehr"
value_if_missing = "UNKNOWN"
suppress_login_events = true
fail_mode = "open"
resolve_subject = true
queue_capacity = 8192
purpose_header = "x-purpose-of-use"
purpose_codes = []
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.
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.
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 and the ATX:FHIR Feed both carry a FHIR R4 AuditEvent document, 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.

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

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

KeyTypeDefaultDescription
enabledboolfalseShip DICOM PS3.15 audit records to an external repository over syslog.
hoststringlocalhostRepository host.
portint514Repository port (514 for UDP, 6514 for TLS, conventionally).
transportenum{udp,tls}udpSyslog transport: RFC 5426 UDP or RFC 5425 TLS. Use tls for PHI-adjacent audit.
tls_ca_filepathunsetPEM file with the repository CA to trust for the TLS transport.
tls_identity_cert_filepathunsetClient-certificate PEM for mutual TLS.
tls_identity_key_filepathunsetClient-key PEM for mutual TLS.

[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.
batch_sizeint64Outbox rows shipped per poll.
poll_interval_msint2000Outbox poll interval when idle.
max_retriesint3Per-record POST retries before the record is left pending (local store on) or dropped and metered (store off).

With the local store on, the feed drains the store’s outbox and is therefore loss-free across a repository outage. With the store off it ships in-drain, and a record that exhausts its retries is dropped and counted.

[subject_proxy]

The named FHIR systems a subject-proxy API_CALL/fhir_get data frame may retrieve from. Empty by default and fail-closed: no external FHIR system is reachable until one is named here, and a frame whose system_id matches no configured system is a typed rejection rather than an arbitrary outbound request. The per-system key table and examples live on Subject Proxy — Connecting FHIR systems.

Privacy & data minimisation

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

What this section is for

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

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

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

[privacy]
subject_namespaces = []
allow_identified_parties_in_ehr = false

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

[privacy]

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
Mintingrefused: there is no namespace to mint intolink_as_subject derives the pseudonym itself, in the first declared namespace

Where FerroEHR itself makes a party the subject of an EHR (service::linkage::link_as_subject), the pseudonym is minted by the server: a keyed derivation over the party id under the linkage key, so no caller-supplied value enters the subject reference on that path and a national identifier cannot become one. The rule above still governs every value that arrives from elsewhere, a client writing EHR_STATUS directly, an EHR-Extract, an archive load, which is why declaring the namespace remains the deployment’s act: minting needs a namespace to mint into, and the rule is what makes a value from outside meet the same bar.

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

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

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

Identified parties

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

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ötunnusDigital and Population Data Services Agency
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.

[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 leaves the surface off.

The allow-list

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

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 demographic.national_identifier, sealed with AES-256-GCM under a key derived per domain, and the body keeps a reference in its place. Beside the ciphertext sits an HMAC-SHA-256 digest of the value, which is what makes “which party holds this identifier” answerable without decrypting anything — and, because it is keyed, what stops the database, a backup or a read replica from reversing a nine-digit space.

[demographic.identifier_protection]
enabled = false
schemes = ["nl-bsn"]
#? key_file = "/run/secrets/ferroehr-identifier-key"
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 demographic.identifier_scheme registry; an unregistered code is refused at the write rather than stored in the clear.
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, or with an empty schemes list, is a boot error. A server that believes it seals national identifiers and does not is worse than one that never claimed to.

Three properties worth knowing before you enable it:

  • The stored, signed and served body are the same form. Sealing runs before the body is decomposed and signed, exactly like the multimedia offload, so a signature still verifies against the bytes the server holds. What a reader receives carries the reference; the value is available through the resolution path below.
  • Resolution is audited. Going from an identifier to a party is recorded as a linkage-domain access naming the scheme and whether it matched — never the value. A miss is recorded too: it says someone asked whether this deployment holds that identifier.
  • The key is load-bearing and rotation is a re-encryption. The runbook is in Operations.

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

CLI & production checklist

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

The command line

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

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

With no subcommand, the binary boots the server.

ferroehr config …

ferroehr config default             # print the annotated default ferroehr.toml
ferroehr config check [--config P]  # validate file + environment + --set

config default writes the fully-commented template every key’s default comes from, the starting point for a real ferroehr.toml.

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

ferroehr db …

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

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

ferroehr healthcheck

Probes the running server’s status endpoint and exits 0 on a 2xx, 1 otherwise: the container HEALTHCHECK and the Kubernetes exec-probe fallback.

VariableTypeDefaultDescription
FERROEHR_HEALTHCHECK_URLURLderived: http://127.0.0.1:<server.bind port><REST root>/status (http://127.0.0.1:8080/ferroehr/rest/status with the defaults)The URL the subcommand probes; also settable as --url. Not part of ferroehr.toml. Unset, the subcommand loads the same configuration as the server (file, environment, --set) and follows server.bind and server.base_path, so a shortened base path moves the probe with it.

Zero-config boot and the production checklist

With no file and no environment, the effective configuration is: listener 0.0.0.0:8080 at the ITS-REST base path with Swagger UI; the database at the built-in development DSN with migrations applied at boot; RBAC on; signing on in digest mode with read-time verification strict; the audit trail on with only the local store; rate limiting on; logs in auto format at info; and every integration off.

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

For production, set at least:

  • db.url: the real DSN, 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 [auth.oidc].
  • log.format = "json" for cluster log collectors.
  • server.cors_permissive stays false; server.swagger_ui per posture.
  • server.system_id: this deployment’s own openEHR system identifier. Choose it before the first EHR is created: it is stored with every EHR, audit entry and version identifier, and changing it later never rewrites what is already committed.
  • management.* per posture. A 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), audit.syslog.transport = "tls", events.tls, fhir.outbound.tls, HTTPS for the object store.
  • 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), FERROEHR_HEALTHCHECK_URL (the container healthcheck), the build-stamp variables, and the Compose parameterization (image tags, host ports, CPU and memory limits). They keep a single _ by design, which is exactly what distinguishes them from configuration keys, and why a single-underscore misspelling of a real key is reported at boot with the uniform spelling it should have had.

Concepts

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

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

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

openEHR primer

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

The Reference Model: a fixed vocabulary of shapes

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

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

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

Archetypes and templates: the meaning layer

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

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

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

Note

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

Compositions: the unit of clinical data

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

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

Versioning: nothing is ever overwritten

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

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

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

AQL: querying by meaning, not by table

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

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

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

How it fits together

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

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

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

System architecture

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

Two layers

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["Native service API<br/>(SM Platform Service Model)"]
        core["Platform: PG18 storage · versioning ·<br/>AQL→SQL engine · validation · signing"]
        ext["Optional integrations<br/>(FHIR · events · multimedia — compiled in by cargo feature)"]
    end

    db[("PostgreSQL 18")]

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

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

The application layer is the server. It holds everything the generated layer does not: storage, the query execution engine, validation, and security. This is where design choices specific to FerroEHR live. The optional integrations (FHIR R4, change events, S3 multimedia) sit beside it in their own crate behind additive cargo features, so a build without them contains none of their code; see Beyond the core.

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

Two specification generations, one selectable set

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

  • Surface the selected generation does not define is refused: an AQL 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 compliance claims are machine-derived. See Conformance.
  • Exact versioning. Nothing is overwritten; every version and its audit are retained and readable.
  • Operational simplicity. One self-contained binary and a PostgreSQL 18 database; see Installation.

Storage architecture

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

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

The big picture

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

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

Core tables and how they relate

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

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

Supporting tables not drawn above: stored_query (stored AQL, qualified name plus SemVer), archetype_store and adl2_artefact (the two DEFINITION dialects), the restriction and retention registers, and the sp_* family (Subject Proxy Service), which keys its rows by a derived opaque subject key rather than by the caller’s own subject identifier. The ehr table itself carries the three creation-immutable values the RM names (system_id, id, time_created) plus promoted copies of the current EHR_STATUS subject reference and is_queryable / is_modifiable flags, which back the one-EHR-per-subject rule, the AQL full-population gate, and the content-write guard without probing a JSON root per request.

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

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

  • 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 stores the node’s canonical openEHR JSON fragment verbatim (the ITS-JSON encoding) with its structure children pruned out: no alias compaction, no synthetic fields, so what sits in node.data is byte-identical in shape to what the API serves. Storage equals wire.

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

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

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

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

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

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

The write path: one transaction per commit

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

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

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

Details that matter:

  • time_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, leaf values are extracted from the canonical fragments with jsonb_path_query_first, arrays are unnested with jsonb_path_query as a lateral set-returning function, and comparison and ordering go through ext.openehr_magnitude (the IMMUTABLE helper realizing DV_ORDERED ordering semantics) and ext.openehr_timestamp (STABLE, because its result depends on the session time zone). The engine uses no jsonpath item methods, no JSON_TABLE and no GIN index. The whole pipeline has its own page.

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

The archival tier is a partition

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

The consequences are deliberate and visible:

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

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

Why this design

The shape follows documented PostgreSQL behaviour rather than habit:

  • JSONB has no documented partial detoast: a big single-document design pays whole-document decompression for every leaf access. Decomposed fragments are small enough to stay under the TOAST threshold, so an AQL leaf access touches only the rows it needs. A point read takes the other route on purpose: it serves the whole 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.
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
  • 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

All clinical API routes hang off a configurable base path, which defaults to:

/ferroehr/rest/openehr/v1

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

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

Capability discovery

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

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

The response carries an Allow header and a JSON body:

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

Two things to know before you build discovery on it:

  • 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 ([server.identity], see Server, database & telemetry); their defaults are the build’s own provenance and the last machine-computed conformance verdict, so an unmodified deployment never over-claims.

Note

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

Authentication

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

  • HTTP Basic: a configured user store; send Authorization: Basic …. The examples in this book use -u user: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, or simply wrong
Authorization header malformed400an unparsable header, an unknown scheme, or a bearer token outside the RFC 6750 §2.1 b64token grammar. The server never got as far as a credential, so this is a request defect (error="invalid_request")
Authenticated, not 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.

Four 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; omitting either is a 422. The server fills in time_committed, and system_id when you do not supply one.
  • lifecycle_state is required on every version and is not defaulted. Omitting it is a 400. The one exception is an attestation entry (see below), which commits no new version and therefore has no lifecycle state.
  • A version entry carries exactly the six declared members (preceding_version_uid, signature, lifecycle_state, attestations, data, commit_audit) plus an optional _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.

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

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

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

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

Note

The contribution envelope is canonical JSON only: openEHR publishes no CONTRIBUTION XML document, so an XML 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.
  • 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 uniform JSON body shape: the openEHR Error object’s members plus a machine-readable error reason phrase:

  • Validation errors (a composition that fails its template) populate the list:

    {
      "error": "Unprocessable Entity",
      "message": "Composition validation failed",
      "validationErrors": [
        "/content[0]/data/events[0]/data/items[1]/value/magnitude: value out of range",
        "/content[0]/data/events[0]/data/items[2]/value/defining_code: code not in group"
      ]
    }
    

    Each entry is "<path>: <message>", so a client can point the user at the exact offending node. A refusal from the data-minimisation rules uses the same list and the same shape, with one entry per finding, and deliberately never echoes the value that matched: repeating it would carry the identifier into the response body, the access log and the traces.

  • All other errors carry the same shape with an empty list:

    { "error": "Not Found", "message": "No EHR with id …", "validationErrors": [] }
    

    This shape is used consistently, including for 405 Method Not Allowed and 501 Not Implemented, which some servers leave bodyless, and for the two refusals that come from the transport layer rather than a handler: 408 Request Timeout (the request exceeded the server’s request-execution limit) and 413 Payload Too Large (the request body exceeds the accepted size).

Match on the HTTP status first; read the body for the human-readable detail and, for validation, the per-node list.

405 always names the allowed methods

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

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

{ "error": "Method Not Allowed", "message": "the request method is not allowed on this resource", "validationErrors": [] }

When a resource is switched off by configuration (the admin API with FERROEHR__ADMIN__ENABLED=false), the header is present but empty, which RFC 9110 §10.2.1 defines as “the resource allows no methods”: nothing you can send to that path will be served until the gate is opened.

A method the server does not recognize at all is answered 405 as well (with Allow), not 501. The openEHR spec suggests 501 there, but the two rules it states overlap for any method outside its own list, and a blanket 501 would also mislabel requests to paths that simply do not exist and are owed a 404. 501 Not Implemented remains reserved for a recognized operation this server does not implement.

Querying with AQL

The Archetype Query Language (AQL 1.1) is how you read data out of FerroEHR. Instead of querying hidden database tables, you query the clinical model directly: you name the RM types and archetypes you want, express structural nesting with CONTAINS, and select values by their path within an archetype. The same query runs unchanged on any conformant openEHR system. This chapter is a practical walkthrough: the language, how to run queries over HTTP, parameters, stored queries, version scope, terminology, pagination and limits, and the supported feature envelope.

The shape of a query

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.
  • SELECT projects values by path. Paths use archetype node ids (at0004) and RM attribute names (value/magnitude); AS names a column.
  • WHERE filters on typed leaf values, with comparisons, EXISTS, LIKE, MATCHES, and boolean combinators. Comparisons over multi-valued paths use any-match semantics: when a path matches several nodes or several elements of a list attribute (links, participations, identifiers, …), the predicate holds if any matched value satisfies it (the AQL specification is silent here; any-match is this engine’s documented convention, deterministic and index-friendly). Projecting a path that crosses a list-valued attribute returns every match as one JSON array cell, and null where nothing matches.
  • 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

Three details about the URL form: the leading $ is optional ($name and name bind the same parameter); a value that parses as a JSON scalar binds as that type (36 as a number, true as a boolean) while everything else binds as text; and the reserved keys q, offset, fetch, ehr_id and query_parameters are request controls, never parameters. Where the same name appears both as its own key and inside query_parameters, the named key wins.

Stored queries

You can register a query once, under a qualified name and version, and execute it by name later. Storing is done through the definition API with the AQL as a plain-text body; executing is done through the query API.

# Store a query as org.example::bp_over, version 1.0.0
curl -u ferroehr:ferroehr -X PUT -i \
  -H 'Content-Type: text/plain' \
  --data-binary 'SELECT o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude FROM EHR e CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]' \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/query/org.example::bp_over/1.0.0

# List and fetch stored queries
curl -u ferroehr:ferroehr \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/query/org.example::bp_over

# Execute it
curl -u ferroehr:ferroehr \
  http://localhost:8080/ferroehr/rest/openehr/v1/query/org.example::bp_over/1.0.0

Storing. Both store forms answer 200 OK with an empty body and a Location header naming exactly the version that was written:

  • PUT /definition/query/{name}/{version} stores at that exact SemVer. The pair is immutable (re-storing an existing (name, version) is a 409 Conflict, never an overwrite) and a partial or malformed version segment is a 400.
  • PUT /definition/query/{name} (no version) stores or updates at the default 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 /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.

Running one.

curl -u ferroehr:ferroehr -X POST \
  -H 'Content-Type: application/json' \
  -d '{
        "q": "SELECT c/uid/value FROM EHR e CONTAINS COMPOSITION c",
        "cohort": [{ "name": "city", "value": "Groningen" }],
        "purpose": "secondary-use",
        "fetch": 100
      }' \
  http://localhost:8080/ferroehr/rest/openehr/v1/query/cohort

q, offset, fetch and query_parameters mean what they mean on the ad-hoc body. There is no ehr_id: the cohort is the scope, and supplying one is a 400. Several cohort entries intersect — city and sex, not city or sex.

The response is an ordinary RESULT_SET with one extra block:

{
  "meta": {
    "_type": "RESULTSET",
    "cohort": {
      "size": 412,
      "served_ehrs": 388,
      "suppressed": false,
      "definition": "9f2c…"
    }
  },
  "rows": [["8849fd1e-…::local::1"]]
}

size is how many EHRs the cohort resolved to, served_ehrs how many the rows actually came from, and definition a SHA-256 digest of the predicate list — the cohort’s identity, carrying none of its values.

Small-cell suppression. A result set narrow enough to name one person re-identifies that person out of content you hold no individual entitlement to. When fewer EHRs are served than cohort.small_cell_threshold (default 5), the rows are withheld and suppressed is true. The response says so rather than looking like an empty cohort: a caller who cannot tell the two apart will keep widening the predicate until the boundary leaks. Set the threshold to 0 to disable suppression.

The access record. Every execution — suppressed and failed ones included — records one linkage-domain access event naming the cohort by its digest, the purpose you declared, and how many EHRs were served. A crossing of this boundary that nobody can reconstruct afterwards is what the access log exists to prevent. The predicate values are never recorded: they are the cohort, and a trail carrying them would be a second copy of it.

Limits. A predicate matching more than cohort.max_cohort_size parties (default 100000) is refused 422 rather than truncated — a silently shortened cohort is a wrong denominator with nothing on the wire to say so. An unbound predicate name, a malformed age band (the form is <min>-<max> whole years, both inclusive) or a request with no predicate at all is a 400. An empty cohort runs no AQL and answers an empty result set.

Measured. The committed record docs/conformance/ferroehr/cohort-bench.json in the repository (produced by the ignored cohort_bench test, not by the conformance instrument) measures the whole call, predicate to result set, over a corpus of 100 000 parties, each the subject of one EHR holding one composition, seeded through the service API, with the database in a local container. Selecting one composition path per EHR:

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.

Version scope: LATEST_VERSION and ALL_VERSIONS

By default a query sees the latest version of each object. FerroEHR also supports querying the entire version history, a capability many CDRs lack. Wrap a source in VERSION and choose the scope:

SELECT v/commit_audit/time_committed, c/name/value
FROM EHR e
    CONTAINS VERSION v[ALL_VERSIONS]
        CONTAINS COMPOSITION c

LATEST_VERSION (the default) reads only current trunk versions; ALL_VERSIONS reads across history (including branch versions, where a version tree has any) so you can see how a record changed over time. A version predicate on commit_audit/time_committed reads the trunk version current at that instant. The VERSION variable also exposes commit metadata: the audit, the committed time, and the version uid.

Terminology in queries

Value filters can be backed by terminology in three ways:

  • TERMINOLOGY('expand', …) as (or inside) 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 external terminology is not configured, the in-process openEHR bundle is used. See Terminology servers for wiring an external FHIR terminology server.

Two rejections to expect here: only expand may stand as a matches operand (the other operations have no value-list meaning), and only validate and subsumes have boolean semantics, so TERMINOLOGY('lookup', …) = true is refused rather than guessed at. A value set the configured server does not know is a 400 naming it; a terminology server that fails mid-query is a 500, kept distinct from a bad query on purpose.

Pagination and limits

Combine LIMIT/OFFSET in the AQL with the fetch/offset request parameters to page through large result sets. Three bounds interact, and it is worth knowing which one you hit:

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.

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, VERSION (LATEST_VERSION / ALL_VERSIONS), and the RM structure classes, with archetype and name predicates;
  • 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 sources. FROM PARTY/ROLE/ACTOR and the other demographic classes are out of the query engine’s scope; the demographic API serves them directly.
  • 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.
  • 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 validationErrors, each with a human-readable detail.

The 422 gate also validates the template’s own meta-data against the RM resource-package invariants: the template’s language (and every description detail’s) must be in the openEHR languages code set, is_controlled must agree with the presence of a revision_history, and a description needs a non-empty original_author, lifecycle_state, and at least one detail with a non-empty purpose, with details keyed by distinct languages. Those refusals carry the RM invariant name (for example RESOURCE_DESCRIPTION.Lifecycle_state_valid) in validationErrors. ADL 1.4 archetype source uploads enforce the same family; there, an empty purpose/use/misuse string is reported as a named warning rather than refused, because the empty string is how real-world 1.4 authoring spells absence.

A refusal lists everything it found, not the first thing. validationErrors carries one entry per violation, so a template with an empty use and an empty misuse reports both, and a code list with several duplicated codes names each one. Fix the list, upload once. Two things are worth knowing about the shape of that list:

  • A constrained type the reference model does not have, or an attribute its parent type does not declare, makes every rule below it meaningless. Those violations say that the tree under them went unchecked, so a short list is never a claim that nothing else is wrong. Fix them and upload again.
  • A template with more than 200 violations reports the first 200 and a final 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/rubentalstra/FerroEHR/issues/3401.

Uploading ADL 2 artefacts

ADL 2 artefacts (archetypes, templates and operational templates) are accepted as text/plain source on …/definition/template/adl2 and validated by the full ADL 2 engine: the source is parsed, then checked against the AOM2 validity catalogue (phase-1 basic integrity, reference-model conformance, and, for a specialised artefact whose parent is already loaded, specialisation conformance).

The two failure modes are again distinguished by status:

  • A source that does not parse is a 400 Bad Request, carrying the ADL syntax (S-prefixed) error codes.
  • A source that parses but fails validation is a 422 Unprocessable Entity, whose validationErrors 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, where SMART is off. Under [smart] require_smart_scopes = true, the posture the conformance stack runs, the template family is scope-governed and a Basic caller answers 403: only a Bearer token carrying a user/template-* (or broader) SMART resource scope reaches these endpoints. See [smart].

Listing templates

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

# Filter and page the list
curl -u ferroehr:ferroehr \
  'http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4?template_id=vital*&offset=0&fetch=20'

# Get the canonical OPT XML for one template
curl -u ferroehr:ferroehr -H 'Accept: application/xml' \
  http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl1.4/vital_signs

Both list endpoints (ADL 1.4 and ADL 2) accept the same three filters (template_id, concept, and version, each a glob pattern with * wildcards) plus offset (rows to skip, default 0) and fetch (maximum rows; absent or 0 = all). The filters AND together. An offset past the end of the match set is an empty list, not an error.

Note

When the 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 /admin/template/{template_id}, behind the admin gate (see Admin & messaging APIs);
  • an ADL 2 artefact is removed with DELETE /definition/artefact/adl2/{artefact_id}.

Both refuse with 409 Conflict while any committed version still references the template, and the message names how many versions still hold the reference. A physical delete can never orphan committed clinical data. The count spans archived content too, so an archived composition’s template cannot be deleted out from under it. Delete the compositions first.

Archetype sources (a FerroEHR extension)

Beyond operational templates, FerroEHR can also hold the source archetypes they were built from, so a deployment can keep its definition material in one place:

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).

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 422. Use them for form-driven capture; use canonical JSON/XML for full-fidelity exchange and archival.

Optional RM attributes (_-prefixed keys)

Beyond the template’s own fields, FLAT and STRUCTURED carry the optional reference-model attributes as _-prefixed path segments, round-tripping in both directions. Indexed families take an :n suffix; sub-fields ride the usual |attribute pipes:

{
  "vital_signs/blood_pressure/_uid": "9fcc1c70-…",
  "vital_signs/blood_pressure/_link:0|type": "problem",
  "vital_signs/blood_pressure/_link:0|target": "ehr://…",
  "vital_signs/blood_pressure/any_event:0/systolic/_null_flavour|code": "253",
  "vital_signs/blood_pressure/any_event:0/systolic/_normal_range/lower|magnitude": 90,
  "vital_signs/blood_pressure/_other_participation:0|function": "witness",
  "vital_signs/blood_pressure/_other_participation:0|name": "Dr. Marcus Johnson"
}

Which families apply depends on the node they hang off:

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. Output always uses the ctx/ form for these, so a round trip through FLAT is stable. A path the Simplified Formats specification does not define is rejected with an error, never silently dropped.

Embedding canonical JSON with |raw

When one node needs full fidelity inside an otherwise-FLAT commit, write the node’s canonical JSON verbatim under the |raw suffix:

{
  "vital_signs/blood_pressure/any_event:0/systolic|raw": {
    "_type": "DV_QUANTITY", "magnitude": 120, "unit": "mm[Hg]"
  }
}

The embedded object must carry _type (without it, the key is treated as a normal leaf). |raw is write-only: retrieval always decomposes to regular FLAT keys.

Coded text and open value sets

A coded field whose template value set is open accepts free text under the |other suffix. |other may not be combined with |code, |value, |terminology, or |preferred_term on the same leaf, and is rejected when the value set is closed.

Duplicate node names

When a template contains sibling nodes with the same name, the generated WebTemplate/FLAT path ids are disambiguated with underscore suffixes counted from 1 (blood_pressure, blood_pressure_1, blood_pressure_2) as the specification prescribes. There is no vendor-compatibility mode: the specification’s numbering is the only numbering, and vendor-only DV_QUANTITY suffixes such as |unit_system and |unit_display_name are not accepted. If your tooling was written against another server’s path ids, map them on the client side.

Validation on commit

Every composition is validated against its template at commit time. This is where the template earns its keep. The server checks:

  • structure: required sections and fields are present, and cardinality and occurrence constraints are respected;
  • leaf values: data types, units, value ranges, string patterns, decimal precision, and date/time constraints match the template;
  • terminology: coded values are members of the value sets the template binds, using the bundled openEHR terminology or a configured external FHIR terminology server (see Terminology servers).

A second pass runs beside it and refuses the same way: the clinical side’s data-minimisation rules read the subject reference, the party proxies and every string leaf of the body, and a finding is a 422 naming the RM path and the rule that matched, never the offending value. That pass is configured independently of the template, and the shipped default already refuses a national identifier anywhere in clinical content. See Privacy & data minimisation.

If a composition is well-formed but breaks its template, the commit fails with 422 Unprocessable Entity and a validationErrors list (one entry per offending node, as "<path>: <message>") so a client can show the user exactly what to fix. A syntactically malformed request instead gets 400 Bad Request. The error shapes are described in Content negotiation & errors.

A version committed as 553|incomplete| relaxes what must be present while still checking everything that is; see the lifecycle notes in Resource walkthroughs.

Next

Beyond the core

The core of FerroEHR is the openEHR platform: EHRs, compositions, contributions, templates, versioning, and AQL. Around that core the server carries capabilities for fitting into the systems around it: moving whole records between systems, storing the people those records refer to, validating codes against an external terminology server, telling downstream systems that something changed, bridging to FHIR, and keeping large attachments out of the database.

This chapter set describes each one the way you meet it: what it does, whether you have to turn it on, and how to consume it.

What is on already, and what you switch on

Two different things are collected here, and they behave differently:

  • Always mounted, part of the API surface. The demographic API and the /message group (EHR Extract and TDD import) are ordinary routes. They have no feature switch: they are served on every deployment and gated only by the same authentication and authorization as the clinical API (Security). The bundled openEHR terminology is likewise always present in-process, with no configuration and no external dependency.
  • Off until you configure them. Everything that reaches outside the server is opt-in: external terminology servers, Subject Proxy FHIR systems, change events, the FHIR connector and its outbound emitter, and multimedia offload to object storage. A bare server contacts none of them, and its clinical behaviour is that of a single-tenant, integration-free openEHR CDR until you enable one.

Important

Some of these carry PHI, and each chapter says which. The two that move clinical content off this system are the outbound FHIR emitter (its payload is the mapped clinical resource) and multimedia offload (the blob bytes land in your bucket). Change-event envelopes carry identifiers and metadata only. Treat enabling either of the two as a deliberate, auditable decision about where clinical data is allowed to go.

Every configuration key these chapters name lives in the configuration reference; the integration sections are on Integrations and Audit & subject proxy.

Build features, and what a slim build refuses

Three of these capabilities are also cargo features of the server build (fhir, events, and multimedia) all on in the published binaries and container images, and on in any default build. Their code lives in a separate crate the platform pulls in only when the matching feature is on, so a --no-default-features build contains none of it.

A slim build does not start up quietly missing a capability: it refuses at boot when the configuration asks for one it was built without. The fhir feature covers more than the connector: the external FHIR terminology providers and the FHIR AuditEvent audit sinks need it too, and enabling fhir also enables events, because the outbound emitter drains the same commit outbox. See From source → Build features for the exact list of settings a slim binary rejects.

Note

One gap worth knowing if you build slim: fhir.api_enabled and terminology.api_enabled are route switches, and in a slim build those routes are simply not compiled in, so the setting has no effect rather than failing loudly. The boot refusals cover the settings that would otherwise lose or fail to deliver data.

The capability set

  • EHR Extract & messaging: export a whole EHR, clone it into another system with its distributed version identity intact, import an extract into an existing record, and import Template Data Documents (TDDs) as compositions.
  • Demographics: a versioned party store (persons, organisations, groups, agents, roles) with relationships, over a REST surface that mirrors the EHR APIs.
  • Terminology servers: the bundled openEHR terminology for local codes, plus any number of external FHIR terminology servers for validating coded values against external value sets; FerroTERM ships with the quickstart as the terminology overlay and runs beside the sandbox CDR.
  • Subject Proxy: read facts about a subject (“date of birth”, “latest blood pressure”) through named variables backed by data frames: AQL against this CDR, reads from configured external FHIR servers, or values pushed in manually. A service-layer capability; it has no REST endpoints.
  • Change events (AMQP): a transactional outbox that publishes a PHI-free, at-least-once event for every commit to an AMQP broker, so downstream systems can respond to changes instead of polling.
  • FHIR connectors: mapping-driven ingestion of FHIR R4 resources, a patient-scoped read façade that returns openEHR data as FHIR, and event-driven outbound emission of mapped FHIR resources.
  • S3 multimedia: threshold-based, content-addressed offload of large DV_MULTIMEDIA blobs to any S3-compatible object store, with integrity verification on the way back in.

Security, multi-tenancy, and the audit trail are covered in Security; running the server in production, including the health and observability surfaces these integrations feed, is covered in Operations.

EHR Extract & messaging

Moving a patient’s record between openEHR systems (migrating to another CDR, replicating an EHR to a downstream repository, or importing an externally produced document) is what openEHR’s EHR Extract and messaging services are for. FerroEHR implements whole-EHR export, import into a new or an existing EHR (including cross-system cloning that preserves version identity), and Template Data Document (TDD) import.

Note

The openEHR service model defines a Message component, but the released REST API publishes no extract, message, or TDD endpoint at all. FerroEHR therefore serves these operations under a /message group of its own design; the routes are ours, not the standard’s, and they gate no openEHR conformance claim. Unlike the admin extensions, they are not admin-gated: they carry the same ordinary authentication as the clinical API. The six routes, with their bodies, parameters, and status codes, are in Admin & messaging APIs.

Exporting an EHR

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.

When ATNA auditing is enabled (it is on by default, see Security) each completed export and import emits a security-audit event under the ATNA Extract object class, so records moving between systems are captured in the audit trail with their direction.

Importing TDDs

A Template Data Document (TDD) is a template-shaped XML document carrying the data for one composition. TDD import converts a TDD into a composition against the operational template its root names and commits it, returning the new version’s object version id.

The template must already be provisioned through the definition API, and the commit goes through the same validated write path as any other composition (see Templates & validation) so a malformed document, an unknown EHR, or an unknown template is rejected rather than partially stored.

A batch variant imports several TDDs in one call and is all-or-nothing: every document is converted before any is committed, so one unconvertible document rejects the whole batch and commits nothing. An empty array is a fulfilled no-op: the target EHR is still checked, and nothing is created.

Divergent copies and version branches

openEHR’s version tree branches when a version created on another system is modified locally, and FerroEHR implements that: the local write forks a branch at the imported version’s fork point, and the new version id carries a three-part tree id (…::2.1.1) rather than a plain trunk number.

Branch versions are served at the same URLs as trunk ones and appear in the revision history and in ALL_VERSIONS queries; the latest version of an object is always the latest trunk version. See Content negotiation & errors for how version ids read on the wire. So importing a copy of a record that has diverged on two systems is supported, and the divergence stays visible in the version tree instead of being flattened away.

Demographics

Alongside clinical records, a CDR often needs to store the people and organisations those records refer to: patients, clinicians, care teams, institutions. FerroEHR provides a versioned demographic store for the openEHR party types and the relationships between them, over a REST surface that mirrors the EHR APIs.

The demographic endpoints are always mounted (there is no feature switch) and are subject to the same authentication and authorization as the rest of the API. See Security.

Note

The party wire API is defined by the openEHR REST specification’s Demographic API, which carries DEVELOPMENT lifecycle status inside the released specification, and the conformance schedule places demographics in the Options profile tier rather than Core or Standard. Party relationships are the one part with no specification at all: those routes are a FerroEHR extension realizing the service model’s relationship interface, and they are excluded from conformance-profile claims.

What is stored

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).

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:

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 can additionally validate coded values against any number of external FHIR terminology servers at once.

The bundled openEHR terminology

The server ships the openEHR terminology bundle (Terminology 3.1.0) and uses it by default, with no configuration and no external dependency. It answers the questions the platform needs while validating and querying: which terminologies exist, whether a code belongs to one, what a term’s rubric is, whether one code subsumes another, and whether a code is a member of a value set.

Enumeration is always the bundle’s job. A lookup or a membership test goes to the bundle when the bundle knows that terminology, and only otherwise to a configured external server.

Exposing the lookups over REST

You can also expose these lookups over a small read-only REST surface. It is a FerroEHR extension (no openEHR REST contract defines a terminology API) and it is off by default; while disabled, every route answers 404 as if it were not mounted. Turn it on with FERROEHR__TERMINOLOGY__API_ENABLED=true and it serves:

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 API base path, /ferroehr/rest/openehr/v1. An unknown terminology or value set is a 404; a missing required query parameter is a 400.

External FHIR terminology servers

Note

The CDR is only ever a client of the terminology server. FerroEHR does not implement one: you run a FHIR terminology server and point the CDR at it by URL. The one that ships with the product is FerroTERM, started by the quickstart’s terminology overlay; any other server speaking the FHIR terminology operations (Ontoserver, Snowstorm, HAPI FHIR) works the same way.

External terminology is off by default, and while it is off nothing is requested: validation uses the in-process bundle alone. The keys live under [terminology.external]; the full table, including timeouts, caching, OAuth2 and mutual TLS, is on Integrations. The minimum is a master switch and one provider:

[terminology.external]
enabled = true

[terminology.external.providers.default]
type = "fhir"
url = "https://tx.example.org/fhir"

Enabling the section with no provider configured is a boot error, not a silent fall back to the bundle. So is an empty provider URL, an oauth2_client naming no configured client, and half a mutual-TLS identity: a control you configured either works or the server refuses to start.

What it changes at commit time

The mechanism is openEHR’s own archetype constraint binding. Where a template binds an archetype constraint code (an ac code) to a query against an external terminology, the specification puts the resolver outside the CDR: the archetype holds an identifier for a query, and the query itself is defined in the terminology server.

With external terminology enabled, committing a composition resolves those bindings for every bound coded value the instance actually carries, against the server the binding’s terminology routes to:

  • The code is in the value set → the commit proceeds.
  • The code is not in the value set → 422, naming the path, the code, and the bound query. That is a real constraint violation, so fail_on_error does not change it.
  • The value set could not be resolvedfail_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) only picks the provider. The server’s answer then decides:

  • the code exists → accepted;
  • the code system is served and the code is not in it422, naming the rule, the binding and the code;
  • the code system is not served by the terminology server (or the server cannot say) → accepted with a warning in the log. AOM2 puts it this way: codes for inaccessible terminologies “should be flagged with a warning indicating that no verification was possible”, so a CDR wired at a server that carries only LOINC still stores archetypes with SNOMED CT bindings.

The same rule holds with no external terminology server configured at all: nothing is asked and nothing is refused.

Which FHIR operation is used

Membership is tested with ValueSet/$validate-code by default: one direct yes/no with the least payload. A server that does not offer it can be switched to $expand plus a membership test with operation = "expand" on that provider. This is a per-provider configuration choice, not an automatic fallback: the server does not retry a failed $validate-code as an $expand, so set the operation your server actually supports.

Responses are cached per provider (decoded, not raw), so a burst of commits validating the same codes does not become one HTTPS round trip per code. A response that is not a valid FHIR resource is treated as an upstream fault rather than partially read, and takes the same path as an unreachable server.

Several terminology servers at once

Real deployments bind to more than one terminology: SNOMED CT from one server, LOINC or a national code system from another. Every entry under [terminology.external.providers] is materialised at startup, and [terminology.external.routes] decides which one answers each call:

[terminology.external]
enabled = true

[terminology.external.providers.snomed]
type = "fhir"
url = "https://snomed.example.org/fhir"

[terminology.external.providers.loinc]
type = "fhir"
url = "https://loinc.example.org/fhir"

# Terminology id or system URI -> provider name.
[terminology.external.routes]
"SNOMED-CT" = "snomed"
"http://snomed.info/sct" = "snomed"
"http://loinc.org" = "loinc"

Selection is deliberately mechanical, so you can predict which server answers:

  1. The caller offers candidate keys in priority order: a terminology id, a system URI, a value-set URL, the AQL service flavour.
  2. The first candidate with a route entry wins. Keys are matched case-insensitively and whole-string, never as a prefix.
  3. Otherwise the provider named default answers, or, when exactly one provider is configured, that one.
  4. With two or more providers and no default, an unrouted terminology has no server at all, and the call falls back to local behaviour.

Step 4 is a useful way to make routing mistakes loud instead of silent: add a default only when you genuinely want a catch-all server. A route naming a provider that does not exist fails at startup, never at request time.

Routing applies everywhere terminology is consulted: the /terminology/* extension API, AQL TERMINOLOGY(…) resolution, and the commit-time binding checks above.

Authenticating to a server

A provider that needs a bearer token references a client-credentials client by name; the token is cached and renewed shortly before it expires. The client secret should come from a mounted file rather than an inline value; client_secret_file under [terminology.external.oauth2_clients.<name>]. A provider that authenticates with a certificate instead gets its mutual-TLS identity per provider, because the certificate is issued by that server’s PKI. Both are configured on Integrations.

There is no option to disable certificate verification. Server-certificate and hostname verification are always on; a private-PKI trust bundle changes which anchors are trusted, never whether the server is verified.

When the terminology server cannot answer

fail_on_error decides what happens when a bound value set cannot be resolved at all, whether the server is unreachable, answers an error, or does not know the value set:

  • false (the default, fail-open): the composition is accepted and a warning is logged. The availability of an external service does not block clinical writes.
  • true (fail-closed): the composition is rejected with a validation error naming the unresolved binding.

A code that is resolved and turns out not to be a member is a different matter: that is a constraint violation and is rejected under either setting.

Pick deliberately. Fail-closed means commits stop while your terminology server is down; fail-open means they are accepted unvalidated. There is no third option that gives you both.

FerroTERM beside the CDR

FerroTERM is the terminology server FerroEHR ships with: the docker-compose.terminology.yml overlay of the quickstart starts it beside the CDR and points [terminology.external] at http://ferroterm:8080/r4b, the compose page has the commands. The hosted sandbox at https://sandbox.ferroehr.eu runs the same pair on one machine, so the coded-text binding you see resolving there is a real round trip to a real terminology server.

What the sandbox shows, and how it is arranged:

  • The server is not on the public surface. FerroTERM answers on the sandbox’s private compose network alone; there is no route to it at sandbox.ferroehr.eu, its own browser UI is off, and the CDR is its only caller. You exercise it through the CDR: commit a composition against 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 still runs its own server: from a checkout of the repository, the conformance stack can start a HAPI FHIR JPA server beside the CDR, seeded with the same synthetic code systems and value sets over its FHIR API (the lane moves to FerroTERM under #3085):

docker compose -p ferroehr-cnf --project-directory . --profile terminology \
  -f docker/sut-ferroehr.yml -f docker/sut-terminology.yml up -d --wait ferroehr

The profile starts the terminology server (host port 8090 by default, FERROEHR_TERMINOLOGY_PORT) plus a one-shot seeding container that uploads the fixtures over the server’s own FHIR API and verifies $validate-code and $expand before exiting, so a misconfigured server fails there rather than inside a later run. The overlay file is what points the CDR at it, by switching on the [terminology.external] providers the development configuration already carries in the disabled state.

None of this touches the downloadable quickstart Compose file on its own, which uses the in-process openEHR terminology only; the terminology overlay is the opt-in that adds FerroTERM to it.

Warning

The seeded content is synthetic and lives under the reserved example.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 providers at a real server, and for SNOMED CT hold the appropriate licence, for anything beyond experimentation.

The terminology container mounts no volume, so its seeded content lives inside the container only: re-create it and the seed is gone. Re-run the profile (or just the seeding container) afterwards.

Tip

Seeding your own server is the same shape: upload the CodeSystem and ValueSet resources your templates reference over plain FHIR REST (PUT to /fhir/CodeSystem/<id> and /fhir/ValueSet/<id>). A FHIR terminology server starts empty, and a value set it does not hold is an unresolved binding, which your fail_on_error setting then decides the fate of.

On Kubernetes

Providers and routes are maps, so they are supplied as chart values rather than environment variables; the config passthrough renders them verbatim into the server’s configuration file (Any server setting is reachable):

# values.yaml
config:
  terminology:
    api_enabled: true
    external:
      enabled: true
      fail_on_error: true      # fail-closed: an unresolvable binding rejects the commit
      providers:
        default:
          type: fhir
          url: https://tx.example.org/fhir
secrets:
  # only when a provider uses OAuth2; keyed by client name
  terminologyOauth2ClientSecrets: {}

Before you enable it: a reachable terminology server, a decision on fail_on_error, and (if the chart’s default-deny egress policy is on) an egress rule that admits the server, or every call fails as a timeout. To turn it off, set config.terminology.external.enabled: false; validation falls back to the in-process bundle and no external call is made.

Terminology in AQL

Query authors can constrain a match to a value set with the AQL TERMINOLOGY() function: TERMINOLOGY('expand', …) resolves a value set and merges its codes into a matches list at query-analysis time, so the planner sees an ordinary value list. A terminology://… operand in a matches list is expanded the same way. See Querying with AQL for the query surface.

Operations with no defined comparison semantics in AQL are typed rejections rather than silent wrong answers.

Subject Proxy

The Subject Proxy Service lets an application read facts about a subject (“date of birth”, “latest systolic blood pressure”, “current medications”) without knowing which system holds them, what standard it speaks, or what query language it uses. You register variables describing what you want and bind them to data frames describing how to fetch it (an AQL query against this CDR, a FHIR read against a remote server, or a manual feed). The service runs the frames, keeps a sample history per variable, and serves fresh values out of that history without re-querying the source.

Important

The Subject Proxy is a service-layer capability, not a REST API: no HTTP endpoints expose it. What you can configure today is the set of external FHIR systems its frames are allowed to reach (below), and the server builds that executor at startup when you name at least one system. The openEHR service model defines the operations; the wire exposure is future work.

The model

  • Subject: the person (or other entity) the variables are about, registered by an external subject id, with a free-text category (default individual). The id you register is never stored: the server keeps an opaque key derived from it, so the clinical database holds no subject identifier of its own. For openEHR-backed variables the subject id is resolved to an EHR: a literal EHR id first, then a lookup in the EHR id / subject cross-reference, which lives in the linkage domain and is read through its own pool.
  • Variable: a named, typed fact about a subject: a name (optionally qualified by a namespace, giving a canonical namespace::name identity), a type, an optional currency (how fresh a served value must be), and either a binding to a data frame (frame_id + frame_path) or the is_manual flag.
  • Data set: an application’s working set of variables for one subject, under local aliases (your app can call the canonical date_of_birth variable dob). Data sets track which applications use them; when the last using application deregisters, the empty data set is dropped.
  • Binding: an environment’s catalogue of data frames. Each frame names a retrieval method: an API_CALL (for example a FHIR read) or a QUERY_CALL (an AQL query) against a named system, plus an optional fallback method.

Defining frames

A binding is a plain document; YAML and JSON are interchangeable. Frames name their system with system_id, and $subject_id inside a query_text is substituted with the subject’s id at retrieval time:

env_id: prod
description: deployment environment
data_frames:
  - id: "openEHR::vital_signs"
    model_type: openEHR-EHR
    primary_method:
      _type: QUERY_CALL
      system_id: ehr1.nhs.org.uk
      call_name: aql_query
      query_text: SELECT c FROM EHR e CONTAINS COMPOSITION c
  - id: "fhir::demographics"
    model_type: HL7-FHIR_DSTU4_UK
    primary_method:
      _type: API_CALL
      system_id: pas
      call_name: fhir_get
      query_text: Patient/$subject_id
    fallback_method:
      _type: QUERY_CALL
      call_name: aql_query
      query_text: SELECT e/ehr_id/value FROM EHR e

A variable then points at a frame and a path within its result, for example a dob variable bound to fhir::demographics with the frame path /birthDate.

Primary, then fallback. The primary method runs first; if it yields data, that is the sample. If it is unavailable (the source is down, answers a non-2xx status, times out, or returns a body that will not parse) and a fallback is defined, the fallback runs and its outcome wins, available or not. Every attempt produces a sample either way, so “the source was unreachable at 14:02” is itself recorded history rather than a gap.

Sample history and currency

Every retrieval attempt is persisted as a sample: the retrieve time, the real-world effective_time the data pertains to (for FHIR reads, the resource’s meta.lastUpdated), and the value, or an unavailability marker carrying the reason. The most recent hundred samples per variable are kept, newest first, so a variable read returns a value with its recent history and provenance. The history is a bounded ring by design, not an unbounded log.

A variable’s currency is an ISO 8601 duration saying how fresh a served value must be. On a read, if the newest stored sample’s effective time falls inside the currency window, it is served without touching the source; otherwise the frame runs again. Freshness is judged against the moment of evaluation, which is the only reading that makes a duration with nominal parts (months, years) decidable. A variable with no currency means “the most recent available value is valid”, so any stored sample serves. An unparseable timestamp counts as stale rather than fresh.

When an application registers a data set whose variables ask for a tighter currency than the stored definition, the variable’s currency is tightened to the stricter value: registration can only make data fresher, never staler.

Connecting FHIR systems

Frames of kind API_CALL / fhir_get read from remote FHIR servers (the FHIR release is the remote’s property; the proxy relays fhir+json bodies and decodes nothing release-specific). Which servers are reachable is opt-in and fail-closed: only systems named in configuration can ever be called, and a frame naming an unconfigured system_id is a typed rejection, never an arbitrary outbound request. By default no system is configured and every FHIR frame is rejected.

Systems are a map keyed by the name frames use as their system_id, under [subject_proxy.systems.<name>]:

KeyTypeDefaultDescription
base_urlstringrequired per systemThe remote FHIR server’s base URL. The frame’s query text is resolved relative to this. Blank or absent is a boot error naming the system.
connect_timeout_msint2000TCP connect timeout.
request_timeout_msint10000Overall request timeout.
[subject_proxy.systems.pas]
base_url = "https://pas.example.com/fhir"

To let the fhir::demographics frame above reach a patient administration system:

export FERROEHR__SUBJECT_PROXY__SYSTEMS__PAS__BASE_URL=https://fhir.example.org/r4

Tip

The environment form takes a double underscore after FERROEHR and between every segment, and the map key is just another segment, so the system named pas becomes …__SYSTEMS__PAS__BASE_URL. Getting it wrong is not a silent no-op: any unrecognised variable in the reserved FERROEHR_ namespace is a boot error with a did-you-mean suggestion, so a setting that never arrived cannot masquerade as a setting that had no effect.

Requests are sent with Accept: application/fhir+json, and the frame’s query_text (after $subject_id substitution) is resolved relative to the system’s base URL. A timeout, an error status, or a body that does not parse becomes an unavailable sample, which is exactly what triggers the frame’s fallback.

On Kubernetes

Systems are a map, so they are supplied as chart values and rendered verbatim into the server’s configuration file (Any server setting is reachable):

# values.yaml
config:
  subject_proxy:
    systems:
      pas:
        base_url: https://fhir.example.org/r4
        request_timeout_ms: 10000

Before you enable it: a reachable FHIR server per system, and (if the chart’s default-deny egress policy is on) an egress rule that admits it, or the calls fail as timeouts. To turn it off, remove the systems: with none configured every FHIR frame is rejected, which is the fail-closed default.

Manual variables

A variable marked is_manual has no frame: its values are pushed in by a notifier (typically a worker or a device observing the subject) through the service’s sample-notification call. Reads then serve the stored history; until a first sample arrives, a read returns an unavailable sample saying so.

Pushing is accepted only for variables marked manual (or flagged ask_user). Pushing to a frame-bound variable is refused, so a notifier cannot quietly override a value the service is supposed to retrieve.

Change events (AMQP)

When something is committed to the CDR, downstream systems often need to know: an analytics pipeline, a care-coordination service, a cache invalidator. Rather than have them poll, FerroEHR can publish a small event for every commit to an AMQP 0.9.1 broker (RabbitMQ). The events are built so you can fan them out broadly without leaking clinical data: they carry identifiers and metadata, never the record content.

Delivery guarantees

The publisher is built on a transactional outbox, which is what gives it properties you can design a consumer against:

  • At-least-once delivery. Every commit writes its event row to an outbox table in the same database transaction as the change itself, so no commit without its event, no event without its commit. A background task drains the outbox and marks a row published only after the broker confirms. A crash or a retry may deliver a message more than once, so consumers deduplicate.
  • Ordered draining. Rows are read in global sequence order, and the drainer stops the batch at the first publish failure rather than skipping ahead, so an earlier event for an EHR is not overtaken by a later one from the same drainer. Messages are published persistently to a durable exchange.
  • PHI-free envelopes. The message body carries ids, version numbers, and metadata. To read the actual clinical content a consumer calls back through the authenticated REST API.
  • Commits never wait on the broker. If the broker is down, events accumulate in the outbox and drain when it returns. Published rows are pruned after a retention window, and never past the cursor of an active reader (the FHIR outbound emitter, when enabled): the prune’s floor is the lowest active reader cursor in event_outbox_reader, read in the same statement.
flowchart LR
    commit["commit<br/>(composition / status / folder / party)"]
    tx[("same DB transaction")]
    node["clinical data"]
    outbox["event_outbox row<br/>(published_at = NULL)"]
    drain["outbox drainer<br/>(background task)"]
    broker["AMQP topic exchange<br/>ferroehr.events"]
    consumer["your consumer<br/>(bound queue)"]

    commit --> tx
    tx --> node
    tx --> outbox
    drain -->|"poll pending, publish, await confirm"| broker
    outbox -.->|"drained in seq order"| drain
    broker --> consumer
    consumer -.->|"fetch bodies via authenticated API"| commit

Note

Several server replicas can drain the same outbox safely (each row is claimed exclusively and the others skip it) but that also means two replicas may have different rows in flight at once. Order your consumer on the 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 /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 /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).

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.

FHIR connectors

Many systems around a CDR speak FHIR. FerroEHR ships a set of FHIR R4 connectors so it can take FHIR resources in, hand openEHR data back out as FHIR, and emit FHIR resources to downstream systems, all driven by mappings you control. It is not a full FHIR server; it is a focused, mapping-driven bridge between the FHIR and openEHR worlds.

There are two independent switches, an inbound/read-façade one and an outbound-emission one, because the two have very different data-exposure characteristics. All FHIR routes are relative to the API base path (/ferroehr/rest/openehr/v1) and speak application/fhir+json; every response on this surface (success or failure) is a FHIR resource, so an error arrives as an OperationOutcome rather than the openEHR error body.

Note

R4, and what that means if you run R4B. The connector’s routes are /fhir/r4/…, and the resources it exchanges (Bundle, OperationOutcome, AuditEvent) are identical in R4 and R4B, so an R4B client can use this surface unchanged: HL7 states that “implementers that do not use the specific portions where changes have been made can continue to use either R4 or R4B without any functional difference” (what R4B changed). Which resource types you may actually exchange is set by your mappings, not by the release. One neighbouring subsystem is deliberately different: the external terminology servers FerroEHR calls out to are R4B, because there the release belongs to the server you point it at.

Inbound ingestion

POST /fhir/r4/{resource_type} takes a FHIR resource and stores it as a validated openEHR composition. The connector resolves the mapping for the resource type (and its meta.profile, when the resource declares one), resolves or creates the EHR from the resource’s subject, builds a composition from the mapping, stamps it with a FEEDER_AUDIT recording the FHIR origin, and commits it through the normal validated write path.

Outcomes worth designing against:

  • A successful ingest is 201, with ETag and Location headers pointing at the openEHR composition that was created.
  • A mapped composition that fails validation is 422, and nothing is stored.
  • A resource type outside the connector’s starter set (Patient, Observation, Condition, DocumentReference) is 501, refused before the backend is touched.
  • A type inside the starter set with no enabled mapping stored for it is 404. This is the common first-run surprise: the connector is on, but nothing is mapped yet.

Provenance is not optional: the composition the CDR stores carries a FEEDER_AUDIT naming the FHIR origin and the source resource’s own id, so an ingested record is always distinguishable from one authored in openEHR.

Validating without committing ($validate)

POST /fhir/r4/{resource_type}/$validate is the ingest door’s dry twin, following FHIR R4’s own validation operation convention. It runs the whole ingest pipeline (mapping resolution, the FLAT build, the FEEDER_AUDIT stamp, and the same validation the real commit runs) and commits nothing: no composition, no version, and no EHR is created (the target EHR is resolved and reported, never touched).

The response is a FHIR OperationOutcome, and a completed validation is 200 whichever way the verdict falls:

  • Valid: information issues, the verdict naming the resolved template, plus the EHR disposition (would commit into existing EHR <id>, or would create a new EHR for subject '<id>').
  • Invalid: an error issue carrying the openEHR validator’s rejection verbatim (the exact text the real ingest would refuse with as a 422) plus the same disposition issue.

Operation-level failures mirror the ingest door: no enabled mapping is 404, a type outside the starter set is 501, a malformed body is 400, and the disabled connector is 404. This is what makes mapping development safe: iterate on a mapping with $validate against real sample resources, and only switch to the real POST once the outcome reads valid.

curl -s -X POST "$CDR/fhir/r4/Observation/\$validate" \
  -H 'Content-Type: application/json' \
  --data-binary @observation.json | jq '.issue[].diagnostics'

Read façade

GET /fhir/r4/{resource_type}?patient=<subject> returns openEHR data reverse-mapped into a FHIR searchset Bundle. Each entry is produced from a stored composition by running the mapping in reverse.

The patient parameter is mandatory; a missing or blank one is a 400. This is a targeted façade, not a general FHIR search engine: there is no free-text search, no chained parameter, and no _include. An optional _count caps the entries returned per mapping.

The EHDS priority categories, and what round-trips

The six priority categories of personal electronic health data are Annex I of the EHDS regulation, and Annex II 2.1 to 2.3 require an EHR system to provide and receive them in the European electronic health record exchange format. That format is set by implementing acts under Article 36 which have not been adopted, so this table is not a conformance claim against it. What it says is narrower and checkable: which category has a committed template whose example composition round-trips through this façade today.

Annex ICategoryCommitted templateA profile mapping would targetTransform proven
1Patient summariescorpus/templates/ckm/international-patient-summary.optBundle (IPS-shaped) over Patient, Condition, AllergyIntolerance, MedicationStatementYes
2Electronic prescriptionscorpus/templates/ckm/eprescription-fhir.optMedicationRequestYes
3Electronic dispensationsMedicationDispenseNo committed template
4Medical imaging studies and related imaging reportscorpus/templates/ckm/ccta-report.optDiagnosticReport (+ ImagingStudy for the study itself)Yes
5Medical test results, including laboratory and other diagnostic resultscorpus/templates/ckm/generic-lab-test-result.optDiagnosticReport + ObservationYes
6Discharge reportsComposition (discharge summary) + EncounterNo committed template

“Transform proven” means exactly what the test asserts, and no more (app/ferroehr/tests/it/fhir_priority_categories.rs): the category’s committed operational template builds a Web Template, its committed example composition flattens against that template to a non-empty map, and a mapping entry over a leaf taken from that map drives the reverse transform to a FHIR resource carrying the composition’s own value. The leaf is derived from the map rather than written into the test, so a corpus refresh moves it and the case still holds.

Three things it deliberately does not establish, each of which a reader could otherwise assume:

  • No profile mapping ships. The rightmost column says what a mapping for that category would target, not what exists. The test maps its leaf onto Observation.note.text, a free-text element: it proves the machinery carries real clinical content, not that an IPS-shaped Bundle or a MedicationRequest has been authored and reviewed. Mappings are data a deployment registers, as the section above describes.
  • It exercises the transform, not the endpoint. The test calls the reverse transform directly, so routing, authorization and the stored-mapping lookup are covered by other tests rather than by this table.
  • It is not conformance to the exchange format, which does not exist yet.

The connector this table measures is planned to leave: FerroBRIDGE (https://github.com/rubentalstra/FerroBRIDGE) is the FHIRconnect and OMOP bridge, and #3080 retires the in-tree connector once it ships. The EHDS readiness question does NOT leave with it — it is asked of the EHR system — so this table moves to the compliance chapter at that point rather than being deleted with the page it currently sits on.

Two of the example compositions this rests on — the patient summary and the imaging report — were patched by hand rather than regenerated against a running server, which their pack’s provenance records and #1724 tracks. They are real CKM templates either way; the caveat belongs beside a claim that leans on them.

The decision on profile mappings: wait for the implementing acts

No profile mapping ships for any priority category, and none will be authored here. That is a recorded decision (#3206), not an omission, and it rests on two things being unfixed at once.

The target format is unfixed. Annex II 2.1 to 2.3 require the categories in the European electronic health record exchange format, and that format’s content is set by implementing acts under Article 36 which have not been adopted. An IPS-shaped Bundle, a MedicationRequest or a DiagnosticReport authored today would be authored against a guess at what those acts require.

The place is unfixed too, and settled the other way. Mappings belong to FerroBRIDGE, which is the FHIRconnect and OMOP bridge, and #3080 retires the in-tree connector once it ships its first round trip. A mapping written here would be written against ehr.fhir_mapping rows and the FHIRPath-lite dialect this façade reads, both of which leave with the connector.

What reopens the question. The Article 36 implementing acts being adopted reopens it, because the target stops being a guess. FerroBRIDGE shipping its first round trip settles where the work lands. Either event is a reason to revisit #3206; neither has happened.

What stays in FerroEHR regardless is the readiness statement: which categories are carried, by what, and where the gaps are. That is the table above and the EHDS readiness page.

The two categories with no committed template

Electronic dispensations (Annex I 3) and discharge reports (Annex I 6) have no template in the curated CKM pack, so nothing demonstrates them end to end. That is an adjudicated boundary rather than a backlog item. A CDR stores whatever an operational template defines, so this is a corpus gap and not a storage one. The pack is vendored verbatim from the openEHR CKM, every file being CKM’s own export with its provenance recorded, so filling the two rows would mean authoring a template here and putting it in a pack whose value is that nothing in it was authored here. A deployment holding dispensations or discharge summaries uploads its own operational template, and the façade maps it like any other.

Outbound emission

Outbound emission publishes the mapped FHIR resource for every relevant commit, but the target is an AMQP broker (RabbitMQ), not an HTTP FHIR server. A background task drains the same commit outbox used by change events, reverse-maps each committed composition through every enabled mapping bound to its template, and publishes each resulting resource to a topic exchange (default ferroehr.fhir) with a routing key of <resource_type>.<template_id>, both segments sanitised the way the change-event keys are. Delivery is at-least-once.

Only composition versions produce messages: an EHR_STATUS or a FOLDER carries no mappable template. A row that fails to reverse-map deterministically (a defective stored mapping or template) is retried a few times and then parked: logged at error level, naming the row, and skipped, so one bad commit cannot head-of-line-block every later one. Broker and database failures are treated as transient and never park a row.

Warning

Outbound FHIR messages carry PHI: the payload is the mapped clinical FHIR resource, unlike the PHI-free change-event envelopes. That is exactly why they are a separate switch on a separate exchange (ferroehr.fhir, not ferroehr.events): broker access control can then isolate the PHI-bearing stream. Enable it only against a TLS, access-controlled broker, and treat every consumer as a PHI processor.

Note

The change-event publisher has a health indicator; the outbound emitter does not have one of its own today, so treat its broker as something to monitor at the broker rather than through the CDR’s readiness surface. See Operations.

Mappings are data you manage

There are no bundled mapping files. Each mapping is a stored definition managed through an admin API (classed under admin authorization):

MethodPathPurpose
GET/admin/fhir_mappinglist mappings
POST/admin/fhir_mappingcreate a mapping (201)
GET/admin/fhir_mapping/{mapping_id}get a mapping
PUT/admin/fhir_mapping/{mapping_id}update a mapping
DELETE/admin/fhir_mapping/{mapping_id}delete a mapping (204)

A mapping definition binds one FHIR resource type (optionally scoped to a meta.profile URL) to one openEHR template, and lists field bindings, each mapping an openEHR FLAT path to a FHIR path, or to a constant, shaped by a transform.

Resolution is two-step and deterministic. An incoming resource resolves by its type plus the first entry of meta.profile (only meta.profile[0] is consulted): an enabled mapping whose profile_url exactly matches that URL wins; otherwise the type’s enabled mapping with no profile_url (the type default) applies. A resource declaring no profile matches only the type default. When neither exists, the ingest (and $validate) answer 404.

The stored definition is the deployable artifact: the CDR stores it verbatim and interprets it at ingest time, so a mapping deploys, updates, and rolls back without a server release. Its shape (this is the whole contract, with no openEHR specification governs FHIR interop; the wire vocabulary follows HL7 FHIR R4):

{
  "resource_type": "Observation",
  "profile_url": "http://hl7.org/fhir/StructureDefinition/bp",
  "template_id": "blood_pressure.en.v1",
  "subject": {
    "reference_path": "subject.reference",
    "namespace": "fhir",
    "strip_prefix": "Patient/"
  },
  "context": {
    "ctx/language": "en",
    "ctx/territory": "US",
    "ctx/composer_name": "fhir-connector"
  },
  "entries": [
    { "openehr_path": "blood_pressure/blood_pressure:0/systolic",
      "fhir_path": "component.where(code.coding[0].code = '8480-6').valueQuantity.value",
      "transform": { "kind": "quantity",
        "unit_path": "component.where(code.coding[0].code = '8480-6').valueQuantity.unit" },
      "required": true },
    { "openehr_path": "blood_pressure/blood_pressure:0/diastolic",
      "fhir_path": "component.where(code.coding[0].code = '8462-4').valueQuantity.value",
      "transform": { "kind": "quantity",
        "unit_path": "component.where(code.coding[0].code = '8462-4').valueQuantity.unit" },
      "required": true }
  ]
}

The example binds the HL7 FHIR R4 core blood-pressure profile (systolic LOINC 8480-6, diastolic 8462-4, each a component of one Observation) to a blood-pressure template’s two quantity leaves. subject names where the patient identity lives in the resource and how it becomes the EHR subject (Patient/p-42 → subject id p-42 in namespace fhir); context supplies the FLAT ctx/ defaults every built composition carries (an omitted ctx/time defaults to the ingestion instant). The transforms:

TransformWhat it writes
plain textthe bare FLAT leaf
datean ISO 8601 date or date-time leaf
quantitythe magnitude and unit leaves, the unit read from the resource or fixed
codedthe code, the resolved openEHR terminology id, and optionally the display text

A coded transform carries its own FHIR-code-system-to-openEHR-terminology map, with * as the fallback for any unmatched system. An entry can be marked required, which turns an absent source value into an error instead of a skipped field.

A coded transform can also declare translate, asking for cross-terminology code translation at ingest time:

{ "openehr_path": "…/problem",
  "fhir_path": "code.coding[0].code",
  "transform": { "kind": "coded",
    "system_path": "code.coding[0].system",
    "translate": { "target_system": "http://snomed.info/sct",
                   "concept_map": "http://example.org/ConceptMap/my-map" } },
  "code_map": { "http://snomed.info/sct": "SNOMED-CT" } }

The server resolves each such code through a configured FHIR terminology server’s ConceptMap/$translate (routed by the openEHR terminology the code_map binds the target system to; concept_map optionally pins one map). Only a strictly equivalent match is taken; a wider, narrower, or relatedto mapping is treated as no translation, because writing a non-equivalent code would silently change clinical meaning. When no translation exists, a required entry refuses the ingest and an optional one writes nothing, and the untranslated source code is never passed through under the target terminology. A mapping that declares translate on a deployment with no terminology server configured is refused as a server configuration error rather than silently skipped.

The FHIR-path support is a deliberate subset of FHIRPath: object-field navigation, zero-based array indexing, first(), and single-condition where(path = literal) filters, for example code.coding[0].code, code.coding.where(system = 'http://loinc.org').code, and component.where(code.coding[0].code = '8480-6').valueQuantity.value, not the full FHIRPath language (no other functions, unions, or arithmetic). FHIRPath’s where() filters a collection; because a FLAT leaf holds a single value, this subset takes the first matching element. The mapping is symmetric (the same definition drives inbound ingest, the read façade, and outbound emission) so a field you can ingest is a field you can serve back (a translated entry serves back the stored, translated coding).

Each mapping also carries an enabled flag (default on). Only enabled mappings resolve, for ingest, for the façade, and for outbound emission, which makes disabling one a reversible way to take a resource type out of service.

Note

The template a mapping references must already be ingested (see Templates & validation); creating a mapping against an unknown template is a 400. A mapping’s name is immutable once set, because it is its deployable identity, and a duplicate name is a 409.

The one route here that is not the connector

GET /fhir/r4/AuditEvent sits under the same path prefix but is not part of the FHIR connector: it is the audit trail’s own retrieval surface, returning stored audit records as FHIR AuditEvent resources. It is gated by the local audit record repository rather than by the connector switch. The unscoped retrieval is admin-only; a caller holding the configured authz.rbac.subject_audit_role reads it scoped to one subject, with the patient parameter required. See Audit trail (IHE ATNA).

Enabling the connectors

Both switches are off by default. The inbound/read-façade switch lives under [fhir] and the outbound emitter under [fhir.outbound]; the full table with every default is on Integrations. The essentials:

Environment variableDefaultMeaning
FERROEHR__FHIR__API_ENABLEDfalsemount inbound ingest, the read façade, and the mapping API
FERROEHR__FHIR__OUTBOUND__ENABLEDfalserun the outbound emitter (carries PHI)
FERROEHR__FHIR__OUTBOUND__URLa local development brokeroutbound broker URL
FERROEHR__FHIR__OUTBOUND__URL_FILEunsetread the broker URL from a mounted file instead
FERROEHR__FHIR__OUTBOUND__EXCHANGEferroehr.fhiroutbound topic exchange, kept distinct from the event stream
FERROEHR__FHIR__OUTBOUND__TLSfalseupgrade an amqp:// URL to amqps://

Batch size, poll interval, and publish retries are tunable as well.

When the inbound switch is off, /fhir/r4/* and /admin/fhir_mapping answer 404 without touching the backend. When the outbound switch is off, no emitter task runs. With authentication on, an unauthenticated request to a disabled group is answered 401 first: the group gate sits behind authentication.

Note

The connectors are also a cargo feature (fhir), on in the published images and any default build, and enabling it also enables events because the emitter drains the commit outbox. A slim --no-default-features build contains none of their code and refuses to boot when fhir.outbound.enabled, a configured external FHIR terminology provider, or a FHIR AuditEvent audit sink asks for it. fhir.api_enabled is the exception: those routes are simply not compiled in, so the setting has no effect rather than failing loudly. See From source → Build features.

On Kubernetes

Both switches are reachable through the chart’s config passthrough (Any server setting is reachable). The outbound broker URL carries credentials, so it goes through secrets.fhirOutboundUrl, which the chart mounts as a file:

# values.yaml
config:
  fhir:
    api_enabled: true          # the read façade + mapping API
    outbound:
      enabled: true            # the emitter; carries PHI
      exchange: ferroehr.fhir
      tls: true
secrets:
  fhirOutboundUrl: "amqps://user:pass@broker.example:5671/%2f"

Before you enable outbound: a reachable broker, an egress rule that admits it if the chart’s default-deny egress policy is on, and a deliberate decision: it carries PHI off this system. To turn either off, set its switch to false and upgrade: the inbound routes go back to answering 404, and no emitter task runs.

S3 multimedia

Clinical records sometimes carry large binary attachments (scanned documents, images, waveforms) as DV_MULTIMEDIA values. Keeping big blobs inline in the database bloats storage and slows queries. FerroEHR can offload large multimedia blobs to any S3-compatible object store, keeping a small content-addressed reference in the composition, and re-materialize them on demand when a record is read back.

How offload works

Offload is a commit-path transformation applied to DV_MULTIMEDIA nodes anywhere in the tree, including a node’s nested thumbnail, which is itself a multimedia value:

  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 [server.limits] body_bytes (16 MiB by default); a composition that exceeds it is refused 413 before offload is ever considered. See [server.limits].

Security

A clinical data repository holds PHI, so its access controls and audit trail are part of the product. This chapter covers the four security surfaces you configure when you deploy FerroEHR: authentication (who is calling), authorization (what they may do), the pseudonymisation boundary (which role reads which domain), and the ATNA audit trail (recording what happened). Each is independently configurable, and each is described here in terms of the environment variables you actually set.

This chapter tells you how to configure each control. Its companions tell you the rest: the Threat model states what remains true after each control has done its job: the trust boundaries, the residual risk at each, and what this software explicitly does not defend against; and Verifying releases covers the artifacts themselves: how to establish that the binary, image, or chart you downloaded came from this project’s own build. Read the threat model before you decide a control is sufficient for your deployment.

Configuration follows the same pattern throughout: the server reads defaults, then the single ferroehr.toml file, then environment variables, with __ separating nested keys. The security configuration groups live in distinct sections of ferroehr.toml: [auth] (authentication), [authz] (authorization), [privacy] (the pseudonymisation boundary), and [audit] (the ATNA audit trail). Any key can be overridden with the matching FERROEHR_* environment variable shown below.

Authentication

Authentication is on by default (FERROEHR__AUTH__ENABLED=true). Setting it to false lets all requests through unauthenticated, a development-only mode.

There is no single “mode” switch. The server offers two mechanisms and enables each by the presence of its configuration block:

  • HTTP Basic is active when a basic block with a user list is configured. Each user has a username, an Argon2 password hash (a PHC string beginning $argon2id$) or a password_hash_file pointing at one, and a set of roles (defaulting to ["USER"]). Because it is a list of users, the Basic block is normally supplied through the TOML configuration file rather than environment variables.
  • OAuth2/OIDC bearer tokens are active when an oidc block is configured. The server validates the token’s signature, issuer, and audience.

Both mechanisms are validated at startup, and a configuration the server cannot honour refuses to boot rather than degrading at the first request:

ConfigurationBoot outcome
auth.enabled = true with no mechanismerror: a 401 challenge must name a scheme the server implements (RFC 9110 §11.6.1)
[auth.oidc] with no audienceserror: a server with no declared audience cannot reject another server’s token (RFC 7519 §4.1.3)
[auth.oidc] issuer not https, or carrying a query/fragmenterror: RFC 8414 §2, §6.2 (allow_insecure_issuer = true opts a dev issuer out of the scheme rule only)
[auth.oidc] clock_skew_leeway_seconds above 300error: leeway may be “no more than a few minutes” (RFC 9068 §4 step 6)
[auth.oidc] hmac_secret under 32 byteserror: RFC 8725 §3.5 forbids memorizable passwords as keyed-MAC keys
[auth.oidc] with both hmac_secret and jwks_json (or their *_file forms)error: two competing key sources, never resolved by silent precedence
[auth.oidc] algorithms naming none, or emptyerror: an unsigned token proves nothing
[auth.oidc] algorithms disagreeing with the key sourceerror: HS* verifies only against a symmetric secret, RS*/ES*/PS* only against public keys
[auth.oidc] hmac_secret set at allboot warning: a symmetric key is a development posture (see below)
a password_hash below m=19456,t=2,p=1 argon2iderror: the OWASP Argon2id floor

Successfully verified Basic credentials are cached for FERROEHR__AUTH__VERIFIED_CACHE_TTL_SECONDS (default 60; 0 disables the cache) so a busy client pays the deliberately-expensive Argon2 verification once per TTL instead of on every request. The cache stores only a SHA-256 digest of the presented credential, never a plaintext password, and an entry exists only after a successful verification; the TTL bounds how long a revoked credential can still authenticate, exactly like a session lifetime.

The OIDC settings:

Environment variableDefaultMeaning
FERROEHR__AUTH__OIDC__ISSUER— (required to enable OIDC)expected iss, and the OIDC discovery base; an https URL with no query/fragment
FERROEHR__AUTH__OIDC__AUDIENCES— (required, non-empty)accepted aud values
FERROEHR__AUTH__OIDC__ALGORITHMS["RS256"]accepted signing algorithms; must match the key source
FERROEHR__AUTH__OIDC__CLOCK_SKEW_LEEWAY_SECONDS60leeway on exp/nbf; capped at 300
FERROEHR__AUTH__OIDC__REQUIRE_AT_JWTfalserefuse a token that does not claim the RFC 9068 at+jwt access-token profile
FERROEHR__AUTH__OIDC__ALLOW_INSECURE_ISSUERfalseaccept a non-https issuer (development/testing only)
FERROEHR__AUTH__OIDC__HMAC_SECRETunsetan HS256 symmetric secret, min. 32 bytes (development/testing)
FERROEHR__AUTH__OIDC__JWKS_JSONunseta static JWKS document
FERROEHR__AUTH__OIDC__CONNECT_TIMEOUT_MS3000discovery/JWKS connect budget
FERROEHR__AUTH__OIDC__REQUEST_TIMEOUT_MS5000discovery/JWKS request budget
FERROEHR__AUTH__OIDC__NEGATIVE_CACHE_TTL_SECONDS10how long a failed key fetch is remembered (0 = off), so an issuer outage does not mean one discovery attempt per request

There is no separate JWKS or discovery URL to set: the server discovers the JWKS URI from the issuer’s .well-known/openid-configuration unless you supply a static JWKS_JSON or an HMAC_SECRET; setting both of those is a boot error rather than a precedence rule.

REQUIRE_AT_JWT is off by default because RFC 9068 §2.1 makes the at+jwt type a SHOULD for the authorization server, so requiring it would reject conforming issuers. A token that does claim the profile is held to the whole of §2.2 either way: iat, jti and client_id become mandatory for it.

Tip

Keycloak example. Point the issuer at your realm and let discovery do the rest:

export FERROEHR__AUTH__OIDC__ISSUER=https://keycloak.example/realms/ferroehr
export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-api

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. Prefer JWKS/discovery over a shared HS256 secret in production. User accounts, roles, and lifecycle are administered in the IdP; the CDR has no user API.

An unauthenticated request to a protected route is refused with 401; an authenticated request that lacks the required role is refused with 403. Two outcomes are neither: a malformed Authorization header is a 400 (the server never read a credential), and an unreachable token issuer is a 503 with Retry-After (no token can be validated, so the server cannot decide; it is not a statement about the caller’s credential). The per-status table for client authors is in Using the API.

The 401 body deliberately says nothing about why. Rendering the rejection told an unauthenticated caller whether a token was expired or forged, which is exactly the distinction an attacker probes for; the reason stays in the log, where the operator can read it and the caller cannot. The WWW-Authenticate challenge still carries the RFC 6750 §3.1 error code, and a request that carried no credential at all deliberately gets no code: it has not made a mistake yet.

Two limits worth planning around

A symmetric hmac_secret is a development posture, not a production one. The key is shared with the authorization server, so this CDR holds everything needed to mint the tokens it accepts (an asymmetric key source never gives it that power) and it cannot be rotated without a restart. The server logs a warning at boot whenever one is configured. Use the issuer’s OIDC discovery document (the default when no static key material is set) or jwks_json.

Revocation latency equals the access-token lifetime. Tokens are validated offline against the issuer’s published keys; the CDR calls no introspection endpoint (RFC 7662 defines that mechanism but does not require a resource server to use it), so a token revoked at the identity provider stays acceptable here until its exp passes. That is deliberate: introspecting per request would put the identity provider’s availability in the request path, and caching the results only shortens the lag. The control is therefore the token lifetime, which your authorization server owns: keep access-token lifetimes short (minutes, not hours) if prompt revocation matters, and use refresh tokens for session length. clock_skew_leeway_seconds adds at most its own value on top of exp.

Authorization

Authorization has three composable layers. The per-EHR EHR_ACCESS gate is the openEHR-specified base and is always on; the coarse role layer is active when authentication is enabled; the fine-grained attribute layer is opt-in. A request must clear every active layer. Deployments serving SMART apps can enable a fourth, token-scope layer on top; see SMART App Launch.

Per-EHR access control (EHR_ACCESS)

Every EHR carries a versioned EHR_ACCESS object, the openEHR access-decision authority for that record. A new EHR has no settings, and what that admits is a server-wide choice:

authz.rbac.ehr_access_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. Query (AQL) results are not filtered by privacy level, because query execution carries no per-row principal context; the per-EHR gate still applies to every query route that binds an ehr_id.

RBAC (role-based, coarse)

Every openEHR operation is classified as Public, Clinical, or Admin, and a role model gates each class. Roles are plain, case-insensitive strings; the defaults are USER and ADMIN.

Environment 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__USER_ROLEUSERnames the baseline clinical role
FERROEHR__AUTHZ__RBAC__READONLY_ROLEREADONLYrole marking a principal read-only: refused on every write
FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS["roles","groups","entitlements","realm_access.roles"]JWT claim paths mined for roles
FERROEHR__AUTHZ__RBAC__SUBJECT_AUDIT_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), followed by the widely deployed nested realm_access.roles. A claim path may be dotted, so an issuer that nests them differently is configuration rather than a code change; a claim carrying a single string and one carrying an array are both accepted.

A Clinical operation needs at least one role of any name; an Admin operation needs the admin role. USER_ROLE records what the baseline clinical role is called rather than being required by the gate: a Basic user with no roles list gets ["USER"], which satisfies the Clinical class and no Admin operation. Disabling RBAC restores authentication-only behaviour.

Important

The management surface is not configured here. /management/* is governed entirely by [management.endpoints], one level per endpoint, and nothing under [authz.rbac] changes it. There is no global default beside it: an endpoint you do not name 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 and template, and the operation id. So a rule can be written about one caller, a role, a scope, or a single operation; the shipped example policy shows a role-keyed break-glass permit and a scope-keyed write restriction.

A request whose patient or template resolves to several values is evaluated over the full cartesian product of them, and every combination must permit; the first deny short-circuits. A request that resolves to no combination at all permits vacuously, because there is nothing to decide about.

Warning

Authorization is fail-closed in two distinct senses, and the difference shows up in the status code.

Nothing permits by omission: a gate reached without an authenticated caller refuses, an unconfigured resource kind on the remote PDP denies (and the missing rule is a boot error), and Cedar is deny-by-default with 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 additionally carries the browser set with a real CSP, because it serves HTML and hydrates WebAssembly. Its Cache-Control has one scoped exception to no-store: the hydration bundle under /pkg/ is served public, max-age=31536000, immutable. Those filenames carry a content hash, so a rebuilt asset is a different URL and a cached copy can never be stale, and the bundle holds nothing clinical — the viewer reaches the CDR through its own server functions, never from the browser. Every document still carries no-store, because documents carry patient data and a per-request CSP nonce. A /pkg/ response that is not a served body (a 404, a redirect) is never cached either.

The published documentation site cannot carry response headers at all: it is static files on GitHub Pages, so its policy travels as a <meta http-equiv> element, which is weaker by specification (frame-ancestors, report-uri and sandbox are ignored in meta form). Anyone re-hosting these docs behind a real web server should send the headers properly instead.

Request limits and rate limiting

Four different protections, four different statuses, and an operator should be able to tell them apart from the status alone.

Connection bounds: [server.connection]. The limits that apply before a request exists, because a client that opens a socket and trickles headers reaches none of the others: an HTTP/1 header-read timeout, and an HTTP/2 concurrent-stream cap with keep-alive PINGs.

Body size: [server.limits]. Two tiers: the clinical surface, and the routes that accept bulk by design (template upload, /message/import, /message/tdd). Over-limit is 413. The defaults are sized against the largest operational template and example composition in the vendored corpus rather than round numbers, and a deployment whose compositions embed large DV_MULTIMEDIA data raises body_bytes deliberately.

Request rate: [server.rate_limit], on by default. The address tier sits outside authentication so a flood is refused before the server verifies a signature per request; the principal tier sits inside it, keyed on the authenticated subject, because a hospital behind one NAT is a single address and address-keying a clinical API would throttle a whole site for one busy client. Refusal is 429 with Retry-After and the x-ratelimit-* headers the limiter computed.

Concurrency: [server].max_in_flight, the admission cap. Refusal is 503 with Retry-After.

So: 503 means the server is full right now, 429 means you are asking too fast, 413 means your payload is too big. Full key tables are on the configuration page.

If you benchmark this server, turn the rate limiter off first, or you will measure the limiter. Our own measurement lanes compose an overlay that disables it, and both instruments refuse to write a record if the server answered any 429: a performance number that is really a configuration key is worse than no number.

Operational surfaces: what is reachable, and by whom

SurfaceDefaultNotes
/health, /health/livenessalways on, unauthenticatedDeliberate: orchestrator probes must not need credentials. Both answer a plain-text OK with no I/O behind them.
/health/readinessalways on, unauthenticatedA status per registered component, 200 while the aggregate is up or degraded and 503 when a required component is down. Each component’s detail is a fixed string, never a driver error, a DSN, or a panic payload. Causes are logged for the operator instead.
/management/*not mounted at all (management.enabled = false)With the master switch off, every route is 404.
/management/{info,metrics,prometheus,env,loggers,flamegraph}each off 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.

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 OIDC symmetric keyauth.oidc.hmac_secret_file
a static JWKS documentauth.oidc.jwks_json_file
the PGP signing-key passphrasesigning.key_passphrase_file
a terminology OAuth2 client secretterminology.external.oauth2_clients.<name>.client_secret_file
the object-store secret keymultimedia.secret_access_key_file
the AMQP URLs (events and FHIR outbound)events.url_file, fhir.outbound.url_file
the national-identifier root keydemographic.identifier_protection.key_file

(The TLS cert_file / key_file / client_ca_file settings are paths by nature and have no inline form at all, which is the same property arrived at from the other direction.)

That is deliberately the shape Docker Secrets and Kubernetes Secrets deliver: a file mounted into the container. So the recommended posture needs no extra machinery.

services:
  ferroehr:
    environment:
      # Point the key at the mount path; the VALUE never appears anywhere.
      FERROEHR__AUTH__OIDC__JWKS_JSON_FILE: /run/secrets/oidc_jwks
    secrets:
      - oidc_jwks

secrets:
  oidc_jwks:
    file: ./secrets/oidc_jwks.json   # or `external: true` in swarm

Why files rather than environment variables: an environment variable is readable from /proc/<pid>/environ by anything in the container’s namespace, appears in docker inspect output, is inherited by every child process, and is routinely captured whole by crash reporters and process listings. A mounted file is none of those, and it can be rotated without recreating the container.

Two properties worth knowing because they are not obvious:

  • Redaction is a property of the type, not a list. Secret-bearing fields are a Secret/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 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 demographic.national_identifier map by keyed digest, which returns a party without decrypting anything. That party is then looked up in linkage.subject_ehr on a second connection, with a second search path and, when [storage.linkage] url is set, a second database role. No statement performs the join, because no credential could: the linkage role holds no grant in demographic and the demographic role holds none in linkage, both revocations are explicit and in both directions, and the boot check refuses to serve a database where either has been given one. The crossing exists as an application step that can be audited, rather than as a query anyone holding one password could write.

Every resolution — and every write that opens, merges or splits a mapping — records an access event in the linkage domain, naming who asked, the purpose of use they declared, and whether anything matched. A miss is recorded like a hit: it says someone asked whether this deployment holds a record for that person. The event deliberately does not name the EHR that came back. The audit trail lives in its own schema, outside the linkage role, so a record pairing a party with its EHR would be a second copy of the map the split exists to hold apart.

Merges and splits are period-closing writes. A mapping that stops being true gets an end date rather than a deletion, and the linkage role holds no DELETE privilege to delete it with. “Which party was the subject of this EHR when that composition was written” therefore still has an answer after two person records have been merged. The database enforces one mapping in force per party with a temporal primary key, so the rule holds against any code path, not only the intended one.

Erasure is the one exception, and the role still cannot perform it: physically deleting an EHR calls a SECURITY DEFINER function that removes the rows naming that one EHR id, in force or historical. A row that survived would go on asserting whose record an erased EHR was.

Looking an EHR up by subject (GET /ehr?subject_id=…&subject_namespace=…) does not go through any of this. That operation matches the EHR’s own EHR_STATUS.subject.external_ref, which is the opaque pseudonym the privacy layer already constrains, so it resolves a pseudonym rather than an identity and consults no map. It records a linkage-domain access event all the same, because who resolved a subject to a record is worth knowing wherever it happened.

Declaring the posture

The separations above are each a configuration key, and a deployment that has made none of them is indistinguishable from one that has made all of them until something goes wrong. The top-level deployment_profile key (configuration) makes the posture explicit: production refuses to start while a separation is missing and not accepted by name, and reads the cluster each pool reached from pg_control_system() rather than trusting the DSN text; sandbox, the default, names every missing separation on the banner, in the boot log and on GET /rest/status, and must not hold real patient data. The database keeps its own line of defence for the subject pseudonym too: once privacy.subject_namespaces is declared, a trigger on ehr refuses a subject reference that is not a UUID, whichever code path or session writes it.

One instance, one organisation

FerroEHR is single-tenant, and that is where openEHR puts the boundary. BASE architecture_overview/master06-design_of_the_ehr.adoc §The EHR System defines a system as “a distinct logical repository corresponding to an organisational entity that is legally responsible for the management and governance of the healthcare data contained within”, and says it is “distinct from any underlying virtualisation infrastructure or cloud computing facility, which may house multiple logical EHR systems in a multi-tenant fashion”. Multi-tenancy belongs to the layer that hosts several systems, not inside one of them.

So several organisations are served by several instances: one instance, one database, one set of domain roles each. That is a stronger boundary than a row predicate — a defect in a query cannot cross it, because there is nothing to cross — and it is what the §System Identity rule needs, since system_id “becomes embedded in the version identifiers of committed – and possibly signed – content” and “cannot easily be changed afterwards”. One system_id shared across organisations mints version identifiers that cannot tell the responsible parties apart.

There is therefore no tenant column on any relation, no row policy, no session variable to set and no [tenancy] configuration. What separates the clinical record from the identity of its subject is the pseudonymisation boundary above, which is a different question and stays.

Version signing

Every version the server commits can carry a VERSION.signature, computed inside the write transaction over the canonical form of the version itself. Signing is on by default in digest mode, and read-time verification of the server’s own signatures defaults to strict, so a served version that no longer matches its stored signature is a 500 rather than a silently served record.

The chapter Version signing covers the mechanism in two pages: Digest signing (what is signed, when, what the stored value proves, and how to reproduce it yourself) and PGP signing (key configuration and rotation, client-supplied signatures, and the signature an import wrapper carries). The [signing] keys are in the configuration reference.

ATNA audit trail

Separately from openEHR’s own provenance, FerroEHR keeps an IHE ATNA security audit trail of API access: on by default, persisted in the local Audit Record Repository (the dedicated audit PostgreSQL schema), rendered in both official formats (FHIR R4 AuditEvent per IHE BALP, and the DICOM PS3.15 audit message for the classic syslog feed), retrievable via the RESTful-ATNA ITI-81 FHIR search, and optionally forwarded to an external ARR over syslog and/or the ITI-20 FHIR feed. Node authentication (ITI-19) is available as native mutual TLS on the listener.

Stored records are tamper-evident: each is linked into a SHA-256 hash chain maintained by the database, the table refuses every rewrite path except the forwarding stamp, and SELECT * FROM audit.verify_audit_chain() names any record that was modified or removed. That is detection, not prevention: the controls that make it hard to forge wholesale are the least-privilege database role and the off-box sinks.

The full chapter (record content, sinks, tamper evidence, the ITI-81 search, fail-mode semantics, and mTLS) is Audit trail (IHE ATNA); every [audit] key is in the configuration reference.

Note

The ATNA trail is orthogonal to openEHR’s own 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.

What a release publishes

Substitute the release tag you downloaded for <tag> (for example v4.3.0) and the architecture for <arch> (x86_64 or aarch64) throughout this page. Linux is the only published target.

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
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 rubentalstra/FerroEHR

Without reaching GitHub. Each release also carries its Sigstore bundles as assets, so verification needs nothing but the artifact and the bundle, useful on an air-gapped host, and the only form in which the signature travels with the download:

gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  --bundle ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sigstore.json \
  --repo rubentalstra/FerroEHR

The *.sbom.sigstore.json asset beside it is the same thing for the SBOM attestation, so “which dependency graph was this binary built from” is verifiable offline too. Verifying an SBOM attestation needs one extra flag: gh attestation verify enforces the SLSA provenance predicate by default, so a non-provenance attestation must name its predicate type explicitly:

gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  -R rubentalstra/FerroEHR \
  --predicate-type https://cyclonedx.org/bom

Fully offline, with no call to GitHub at all: fetch the trusted key material once on a connected machine and carry it across with the bundle:

gh attestation trusted-root > trusted_root.jsonl        # on a connected host
gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  --bundle ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sigstore.json \
  --custom-trusted-root trusted_root.jsonl \
  --repo rubentalstra/FerroEHR

Require the hardened signer. Without a signer constraint you are trusting that some workflow in this repository signed the artifact. Pin the one that actually did:

gh attestation verify ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz \
  -R rubentalstra/FerroEHR \
  --signer-workflow rubentalstra/FerroEHR/.github/workflows/release-build.yml

That workflow is the reusable release-build lane described under SLSA levels below.

The binary also describes itself. From v4.0.1 on, the shipped binary embeds its own compressed dependency list in a .dep-v0 linker section (built with cargo auditable; the section is allocated, so it survives the release lane’s strip). A scanner that reads binaries — syft, trivy, grype, osv-scanner — recovers the crate graph from the artifact itself, even when the release page’s SBOM never travelled with it:

syft ferroehr -o cyclonedx-json   # the extracted binary, not the tarball

Tip

gh attestation 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 rubentalstra/FerroEHR
# → Error: verifying with issuer "sigstore.dev"; exit status 1

# 2. Name a repository that did not build it — also non-zero.
gh attestation verify … --repo someone-else/something

Re-download the tarball afterwards. Only once you have seen both refusals does a passing run mean something.

Cross-check the digest. The same digest is produced independently in three places, so they are worth comparing against each other rather than trusting any one of them:

# the published checksum file
cat ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.sha256sum

# the digest the provenance statement was signed over
jq -r '.payload' ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz.intoto.jsonl \
  | base64 -d | jq -r '.subject[].digest.sha256'

# the bytes on your disk
sha256sum ferroehr-<tag>-<arch>-unknown-linux-gnu.tar.gz

A container image

The three images (ferroehr, ferroehr-viewer, ferroehr-postgres) each carry a Sigstore-signed SLSA provenance attestation, plus the SPDX SBOM and provenance the builder writes onto the image index itself.

gh attestation verify oci://ghcr.io/rubentalstra/ferroehr:4.3.0 \
  -R rubentalstra/FerroEHR

Important

Image tags carry no 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/rubentalstra/ferroehr:main and its two siblings) are signed the same way, so you can rehearse the command against them before a release.

Add --signer-workflow rubentalstra/FerroEHR/.github/workflows/build-image.yml to require the hardened image-build lane specifically rather than any workflow in this repository.

Verify by digest where it matters. A tag is mutable, so anything that GATES on verification (admission control, a deploy script) should resolve the tag once and verify the digest it resolved — otherwise the bytes verified and the bytes pulled can differ:

digest=$(docker buildx imagetools inspect ghcr.io/rubentalstra/ferroehr:4.3.0 \
  | awk '/^Digest:/{print $2}')
gh attestation verify "oci://ghcr.io/rubentalstra/ferroehr@${digest}" \
  -R rubentalstra/FerroEHR

The attestation is also pushed to the registry itself, so a host with registry access but no GitHub API path can verify from the registry alone with --bundle-from-oci. And the SBOM the builder wrote onto the image index is readable without any verifier at all:

docker buildx imagetools inspect "ghcr.io/rubentalstra/ferroehr@${digest}" \
  --format '{{ json .SBOM }}'

Enforcing this at admission time in a Kubernetes cluster is a separate job with its own machinery; see Images: build, provenance, scanning.

The Helm chart

The chart carries a keyless cosign signature in addition to its provenance attestation. The two are not redundant: the attestation says what the chart was built from, the signature says who signed the artifact you pulled, and the signature is what Helm-ecosystem tooling looks for.

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

Both flags are the point: without an identity and an issuer, cosign verify accepts a signature from anyone in the transparency log.

gh attestation verify oci://ghcr.io/rubentalstra/charts/ferroehr:<chart-version> \
  -R rubentalstra/FerroEHR \
  --signer-workflow rubentalstra/FerroEHR/.github/workflows/build-chart.yml

Note

The chart version is not the product version. The chart runs its own SemVer line, so a chart version and the 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.

Three SBOMs, three questions

A release involves three SBOM documents. They are not redundant: they describe different things for different readers, and both kinds of review genuinely happen for a clinical deployment:

DocumentWhereFormatAnswers
ferroehr-<tag>-<arch>-unknown-linux-gnu.cdx.jsona release asset, one per architectureCycloneDX 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 rather than guessed. This is what a vulnerability scanner consumes.
ferroehr-<tag>.spdx.jsona release asset, one 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 image SBOMwritten onto each container 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.

CycloneDX 1.5 is the highest version the generator emits (cargo-cyclonedx accepts 1.3, 1.4 or 1.5 and defaults to 1.3, so the release lane sets it explicitly); it carries everything 1.6 consumers read. The repository SPDX document is checked at publish time for being real SPDX, naming itself ferroehr, and listing at least one package: an SBOM that answers no question is worse than none.

What SLSA level each artifact reaches

The levels are the SLSA v1.2 Build track (the current spec; v1.0 is marked Retired, and the Build-track requirements are unchanged across those versions — GitHub’s own documentation still phrases the same construction against “SLSA v1.0”). They differ per artifact, so a table is the honest form:

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 eight specification crates publish to crates.io through Trusted Publishing: the workflow authenticates with a short-lived OIDC token, and no long-lived crates.io token exists anywhere. Be precise about what that does and does not give you: it is authentication, not provenance. crates.io records and displays nothing about how a crate was built, and the Rust RFC that introduced Trusted Publishing lists provenance verification as explicitly out of scope — there is no registry-side attestation to verify, from this project or any other. cargo checks the crate’s checksum against the registry index, and that is the whole registry-side story. The crates’ source of truth is this repository: the publishing lane, the tags, and the public history.

Findings a scanner will report

Run a scanner over a FerroEHR artifact and it will report findings. Every one this project has assessed and accepted is published as an OpenVEX document under security/vex/, carrying a controlled-vocabulary justification and an impact statement you can check, rather than an ignore entry that records only the verdict. Point your tooling at them (trivy --vex, and most SCA platforms take an OpenVEX feed).

DocumentCovers
rust-advisories.openvex.jsonthe Rust dependency advisories: the ones the advisory gate accepts, plus one that only a Cargo.lock-reading scanner reports
postgres-gosu.openvex.jsonGo standard-library findings in the gosu helper the upstream postgres image ships

The Rust document is generated from deny.toml (the gate that actually decides whether a build passes) joined with the published reasoning, and a CI job fails if the two disagree in either direction. So an advisory cannot be accepted without a justification reaching you, and a justification cannot claim something the gate does not do.

One asymmetry worth knowing, because it produces findings that are real reports of nothing. Cargo.lock records the union of every dependency any feature combination could pull, so a scanner reading the lock file alone reports crates this project’s feature set never compiles. cargo deny resolves features and does not. Where the two disagree in that direction the feature-resolving tool is the more precise instrument, and the VEX document carries that argument for the specific crate it applies to, so you do not have to take our word for it in prose.

The published images are additionally re-scanned on a weekly schedule at the tag an operator actually pulls, and a fixable high-or-critical finding both fails that run and files a tracker issue, because a red scheduled run nobody looks at is not a control.

If verification fails

A failing verification is a security report, not a support question. Do not run the artifact, and follow SECURITY.md: report privately, never as a public issue. Note that only the newest release receives fixes; there is no maintenance branch to backport to.

Threat model

This chapter says what FerroEHR defends against, how, and (more usefully) what remains true after the control has done its job. It exists because the parts of this system that actually protect patient data are the parts no external specification governs: openEHR’s REST specification makes authentication a SHOULD and mandates no scheme, and the Service Model places authorization out of band, so only the 401-versus-403 split is specification-grounded. Everything finer is this project’s own design, and a design nobody has written down asks every deployer to reconstruct it privately.

What this is, and what it is not

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 OAuth2/OIDC bearer tokens validated against the issuer. Configuration is validated at boot, not on the first request: a mandatory audience list, an https issuer, an algorithm set bound to its key source (with none refused outright and two competing key sources refused as a contradiction), an HMAC entropy floor, and the OWASP Argon2id parameter floor. A malformed Authorization header is a 400, not a 401: the server never read a credential. An unreachable issuer is a 503, never a silent pass. A 401 body names no reason, so it is not an oracle for expired-versus-forged.

Residual risk.

  • A valid token is a valid caller. FerroEHR verifies signature, audience, issuer and expiry; it does not and cannot know whether the human behind the token is who the token says. Token theft is defended against by your identity provider’s lifetimes and binding, not by us.
  • There is no token binding, no proof-of-possession and no replay window. A bearer token replayed from another network location is accepted.
  • The identity provider is unconditionally trusted. A compromised issuer, or an attacker who can mint tokens with the right audience, defeats every authorization stage below, all of which read their inputs from claims.
  • Basic authentication has no session, no lockout and no second factor. It is intended for machine callers and small deployments; the Argon2id cost is the only brake on offline cracking if a hash leaks.

B3 — Authorization

Control. Five stages, each of which can only narrow what the previous one allowed, which is what makes an optional stage safe to leave off:

  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.

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. ITI-81 is the retrieval side; ITI-19 mutual TLS is available for node authentication.

Residual risk.

  • Tamper evidence, not tamper proof. The chain proves alteration happened; it does not prevent it. An attacker with write access to the audit schema can rewrite the chain from the point of compromise forward, which is precisely why forwarding to an off-box sink matters, and why the sink should not be writable by the same identity.
  • A local repository shares the blast radius of the thing it audits. If the database is lost, so is the local audit trail.
  • The trail records what the server saw. A read performed directly against the database appears nowhere in it.
  • Audit records are themselves sensitive. They name patients, subjects and actions; ITI-81 retrieval is a PHI-disclosure surface and is authorized like any other.

B8 — The supply chain

Control. Releases are built by a reusable workflow that satisfies SLSA v1.0 Build L3, signed through Sigstore with a signer identity a consumer can pin, carrying a CycloneDX dependency SBOM, a SPDX repository SBOM, in-toto provenance and a plain sha256sum; the verification procedure is Verifying releases. The release is created as a draft and only published once its full asset set is present, because a published release is immutable. Container base images are digest-pinned, and the published images are re-scanned weekly at the tag an operator actually pulls, with a fixable high-or-critical finding both failing the run and filing an issue. Every action is pinned to a commit SHA, workflows are audited by zizmor on every pull request, and accepted advisories carry published VEX justifications. Commits and tags are signed and the tags are protected by a ruleset.

Residual risk.

  • Provenance proves where an artifact was built, not that the source was good. Build L3 says these bytes came from this workflow at this commit. It says nothing about what that commit contained.
  • The build is not hermetic and not reproducible. Those are separate SLSA tracks this project does not address. A dependency resolved at build time is trusted to be what the lock file names.
  • Review capacity is one person. No second human is structurally required between an idea and a released binary; the mitigation is machine enforcement and it is honestly recorded in GOVERNANCE.md.
  • Only the newest release receives fixes. There is no maintenance branch to backport to; see SECURITY.md.

What each database credential can reach

Every control from B1 to B5 is enforced at the API, so a database credential is past all of them. What one credential holds is therefore the boundary that survives a leaked connection string, and it is a property of the grants rather than of the server’s routing. The migrations under app/ferroehr/migrations/ apply those grants, revoke each domain from the roles that do not own it, and make every domain role NOINHERIT and a member of no other, so a privilege cannot arrive through a membership.

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 and created_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

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 SM’s own association metadata and a validity period. No name, no address and no plaintext attribute, because a row here is already the additional information that re-attributes a record. Its role is barred from both domains it joins and both of them from it. The one crossing, resolve_ehr_for_identity, runs in the application over two pools, so no single statement performs the join and no credential could issue one. Each resolve, link, merge and split writes a linkage-domain access event naming the actor, the declared purpose of use and whether anything matched, and that event deliberately does not name the EHR that came back. link_as_subject derives the subject pseudonym server-side and writes it onto EHR_STATUS itself, so no caller-supplied value becomes a subject reference on that path. A merge or a split closes a period rather than deleting a row, and the temporal key admits one mapping in force per party. Erasure is the one exception, and the role still cannot delete: linkage.erase_ehr is a SECURITY DEFINER function that removes the rows naming one erased EHR, which the admin delete calls once the clinical delete has committed.

Residual risk.

  • The crossing exists, and an actor entitled to call it re-identifies a record. That is its purpose. The access log records who did it; recording is not prevention.
  • Nothing populates the map. What it holds is what a consumer wrote, so a deployment that never calls link has no map and no crossing, and one that writes it from a batch job has whatever that job decided.
  • A miss is as informative as a hit. Asking whether this deployment holds a record for a person is itself a disclosure, which is why the miss is recorded too.

The sealed identifier and its lookup digest

Control. A protected national identifier leaves the versioned body and lives in demographic.national_identifier under AES-256-GCM with a fresh 96-bit nonce per record, with the scheme code bound in as associated data so a ciphertext moved between either fails to open. Beside it is an HMAC-SHA-256 digest under a separate subkey, which is what lets equality lookup work without the plaintext ever reaching the database. Both subkeys are derived per domain from one configured root key. The resolve function is SECURITY DEFINER, PUBLIC is revoked from it, and only ferroehr_party may execute it. Every resolution records an access event naming the scheme and whether it matched, never the value. The scheme registry is a closed set: an identifier whose scheme nobody registered cannot be stored as a protected one at all.

Residual risk.

  • The root key is the whole control. Held beside a dump, the ciphertext opens and the digest can be recomputed over a nine-digit space in seconds. It belongs in a secret store, not next to the backup.
  • Protection is off by default, and an identifier whose type the deployment did not list is left in the versioned body exactly as written.
  • Rotating the key is a re-encryption, not an edit, because every record is sealed under a subkey derived from it.

The subject pseudonym

Control. mint_subject_pseudonym derives the value from the party id under the linkage key domain, in the first declared pseudonym namespace. No caller input enters it, so the same party yields the same pseudonym and a national identifier cannot become a subject reference through that path.

Residual risk. A stable per-subject identifier across the whole clinical store is what makes it an EHR, and it is also a linkage key. Anyone holding the clinical domain can gather every record of one subject, without knowing who the subject is. Across instances the derivation differs with the root key, so the same person in two instances is two pseudonyms.

The audit trail as a re-identification surface

Control. Access records live in audit, outside all three domains, and retrieval is the admin-gated ITI-81 route. A linkage event names the party and the purpose, not the EHR that was returned, so the trail is not a second copy of the map.

Residual risk. The records name subjects, actors, organisations, roles and what they read, which is sensitive in its own right and is a disclosure surface like any other. Retention makes it durable on purpose: where one of the deployment’s active identifier rules names a jurisdiction with a registered retention floor, a shorter audit.store.retention_days is a boot error rather than a silent trim. The Netherlands is the one registered floor today, at 1830 days.

What is explicitly NOT defended against

Silence is never coverage, so these are stated rather than left to inference.

  • A compromised identity provider. Everything downstream reads claims it asserted.
  • A compromised host, container runtime, or cluster operator. Process memory holds decrypted PHI and key material by necessity.
  • Anyone with direct database credentials.
  • A malicious maintainer, or a compromise of the maintainer’s account or signing key. The bus factor is one; see MAINTAINERS.md.
  • Physical access, side channels, and speculative-execution attacks.
  • Traffic analysis. Request sizes, timings and error-code patterns can leak the existence of records; nothing pads or delays.
  • A legitimate user’s authorized misuse, beyond recording it.
  • Denial of service by a resource-holding authorized caller: an expensive query, a large commit, a slow client. The rate limiter is on but coarse and per-process; database statement timeouts and container limits are the operator’s.
  • Data remanence. Physical deletion removes rows; it does not scrub filesystem blocks, WAL segments, replicas or backups.
  • Clinical correctness. FerroEHR validates structure, invariants and terminology bindings. It does not know whether a recorded fact is true, and no control here is a substitute for clinical governance.

Reporting a finding

If you believe a control is weaker than stated here, or a residual risk is missing, that is a valid security report, including when the defect is in this document rather than in the code. Follow SECURITY.md: report privately, never as a public issue.

Data protection impact assessment

GDPR Art. 35 makes a data protection impact assessment mandatory before large-scale processing of health data. That assessment belongs to the controller, and it is about a deployment: its purposes, its users, its network, its organisation. What this page supplies is the half a supplier can supply, which is the technical description the assessment builds on.

FerroEHR is software. It is not a controller, not a processor and not a certified organisation, so nothing here says a deployment is compliant with anything. It says what the software does with personal data, which controls it ships, and what those controls do not reach.

How to use this page

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.
  7. The FHIR façade: inbound ingestion and outbound emission of mapped resources, when [fhir] is enabled.

Everything else the server does (templates, terminology lookup, conformance reporting) operates on definitions rather than on people.

2. Data categories, by schema

The database is split so that no single runtime credential holds two of the three parts. Each schema is a separate category of data with its own role.

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 /rest/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#3156Art. 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#3204Annex II 3.2(a)
The actor’s roles on every access record#3239NEN 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

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 ehr schema and its cold archival tier. Special-category health data under Art. 9. The subject appears as an opaque pseudonym once [privacy] subject_namespaces is declared
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 demographic schema with names, addresses and contacts as the template defines them; protected national identifiers sealed in national_identifier; the party-to-EHR map in linkage, which holds identifiers and a validity period and nothing else
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

7. The FHIR façade (off by default)

Art. 30 fieldWhat the software does
PurposeExchanging mapped resources with FHIR systems, when [fhir] is enabled
Categories of data subjectRecord subjects
Categories of personal dataWhatever the registered mappings project, which is clinical content and therefore Art. 9 data. Outbound emission is PHI-bearing, unlike the change-event envelope
RecipientsThe configured broker or FHIR peer
Third-country transfersyours
RetentionNothing is stored by the façade beyond the mappings themselves and an outbound cursor
Security measuresMappings are data an administrator registers, so what leaves is what someone configured rather than a default; the façade is a targeted surface with no free-text search and no _include; TLS to the peer

Fields no software can fill

The record is not complete until these are answered, and they are answered by the organisation:

  • The purpose of each activity and the lawful basis for it, which for health data means an Art. 9(2) condition beside the Art. 6 one.
  • The recipients, including every category of person inside the organisation who can reach the API.
  • Transfers to third countries and the safeguard relied on, which follows from where the database, the backups and every configured peer run.
  • Time limits for erasure per category, which the software will not apply for you: it deletes when an administrator asks.
  • The general description of security measures in Art. 30(1)(g), for which the technical half is the DPIA page and the threat model.

Next

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 /rest/status):
  - shared_credential: ...

The gaps are shared_credential, shared_cluster, open_subject_namespace, audit_off and migrate_on_runtime_credential. shared_cluster is read from pg_control_system().system_identifier on each pool rather than from the DSN text, so two names for one cluster do not pass it.

A gap you have decided to run with goes in deployment_accepts by name. It is then stated on every boot and on GET /ferroehr/rest/status, so it is run rather than hidden.

  • deployment_profile = "production" and the server starts.
  • Every entry in deployment_accepts is a decision someone recorded, not a leftover.
  • GET /ferroehr/rest/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. Where FerroEHR mints them itself, link_as_subject uses the first declared namespace.

  • subject_namespaces names at least one namespace.
  • The open_subject_namespace gap no longer appears on GET /ferroehr/rest/status.

5. Backups, one per domain

Backups leave the database with none of the grants attached, so one dump per cluster undoes the split. Configure one per domain, each with its own credential and its own volume. On Kubernetes that is the backup values:

backup:
  enabled: true
  clinical:
    existingSecret: ferroehr-backup-clinical
    persistentVolumeClaim: ferroehr-backup-clinical
  demographic:
    existingSecret: ferroehr-backup-party
    persistentVolumeClaim: ferroehr-backup-party
  linkage:
    existingSecret: ferroehr-backup-linkage
    persistentVolumeClaim: ferroehr-backup-linkage

Each credential is a read-only role on its own domain, and it cannot be the pool’s: each domain’s runtime role is revoked from the other domains, so a dump taken through one would be silently partial. The chart refuses to render when two domains name the same claim, which is the mistake that quietly puts the join back together.

Three things the chart does not do: it provisions no storage, it prunes no old dumps, and no network policy of ours selects the dump pods.

  • Three dumps, three credentials, three volumes.
  • The demographic and linkage volumes have the narrower audience.
  • A restore has been rehearsed, and the rehearsal is recorded.
  • Retention and pruning of the dumps is somebody’s job, and they know it.

6. The audit trail is on, durable, and kept long enough

[audit]
enabled = true
purpose_header = "x-purpose-of-use"

[audit.store]
enabled = true
retention_days = 0    # or a value at or above your jurisdiction's floor

Auditing is on by default with the local store as the sink. What a production deployment adds is a copy off the box, because a local repository shares the blast radius of the database it audits:

[audit.syslog]
enabled = true
transport = "tls"

# or, for a RESTful ARR
[audit.fhir_feed]
enabled = true

Decide fail_mode deliberately. open logs a dropped record and serves the request; closed refuses an auditable operation with 503 when the record cannot be taken. closed is the posture that makes “no unaudited access” true.

Retention has a jurisdictional floor. Where one of your active identifier rules names a jurisdiction with a registered floor, a shorter retention_days is a boot error naming the floor. The Netherlands is the one registered today, at 1830 days, from the Besluit vaststelling bewaartermijn logging. A deployment elsewhere sets its own floor by hand and records why.

Verify the chain works before you need it:

SELECT * FROM audit.verify_audit_chain();
  • [audit] enabled = 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_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 OIDC bearer tokens. This page records that posture and walks through connecting the two enterprise IdPs we are asked about most, Microsoft Entra ID (Azure AD) and AD FS, plus the answer for plain-LDAP directories.

The posture: users live in the IdP

A clinical data repository is the wrong place to store credentials. A user store would make the CDR an authentication product (password lifecycle, lockout policy, MFA, recovery flows, and the largest new attack surface the product could grow) duplicating what a dedicated IdP already does under your existing governance. So the split is deliberate and permanent:

  • The IdP owns identities: accounts, passwords, MFA, group/role membership, lifecycle (joiners/movers/leavers), and session policy.
  • The CDR owns authorization: it validates the token, mines roles from its claims, and enforces RBAC/ABAC and per-EHR access control on every request.

The HTTP Basic user list in ferroehr.toml is a bootstrap/dev convenience, not a user store; production deployments authenticate with OIDC bearer tokens.

Note

The viewer follows the same rule: it authenticates against the same credentials the CDR accepts (the same OIDC issuer, or Basic) and has no user-management screens. To create, disable, or re-role a user, use your IdP’s own administration surface.

How the CDR consumes an IdP

Two configuration groups do all the work:

  1. Token validation ([auth.oidc]): the server discovers the JWKS from the issuer’s .well-known/openid-configuration and validates each bearer token’s signature, iss, exp/nbf, and aud; the audience list is mandatory, so a server with none refuses to boot rather than accepting another service’s token. See the OIDC settings table.
  2. Role mining ([authz.rbac]): FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS (default ["roles","groups","entitlements","realm_access.roles"], the RFC 9068 §2.2.3.1 carriers, then the Keycloak shape) names the JWT claim paths whose values become the caller’s roles for the role layer. A path may be dotted to walk nested claims, and a claim holding a single string is accepted as readily as an array.

Everything below is just those two groups pointed at a different issuer.

Microsoft Entra ID (Azure AD)

Entra ID exposes a standards-compliant OIDC issuer per tenant.

  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__OIDC__ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
    export FERROEHR__AUTH__OIDC__AUDIENCES=api://ferroehr
    export FERROEHR__AUTHZ__RBAC__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 the operation needs: a clinical call needs at least one role, an admin call needs the admin role
    400the Authorization header itself is malformed; no credential was ever read
    503the CDR could not reach your issuer’s JWKS, so the token was never judged: check network egress and the discovery document, not the token

Important

A role must arrive in a role claim, not in scope. The 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. Do not reach for ALLOW_INSECURE_ISSUER or a shared HMAC_SECRET to get past them; both are development-only postures.

AD FS (on-premises Active Directory)

AD FS 2016+ speaks OIDC. This is also the supported path for on-premises Active Directory in general: front AD with AD FS (or another OIDC-capable broker) rather than pointing anything at LDAP.

  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__OIDC__ISSUER=https://adfs.example.com/adfs
    export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-api
    export FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS='["role"]'
    

    Discovery works out of the box (https://adfs.example.com/adfs/.well-known/openid-configuration).

  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.oidc] block does double duty: the CDR must be able to validate the tokens those apps come back with, so SMART cannot be enabled without it, and the issuer the CDR advertises to apps must be the same one it accepts tokens from; a mismatch is refused at boot. See SMART App Launch.

SMART App Launch

FerroEHR can act as the resource server in a SMART App Launch setup: a clinical app is launched with an OAuth2/OIDC token from your authorization server (Keycloak or any standards-compliant IdP), and the CDR advertises that server’s endpoints, understands SMART resource scopes in the token, and binds the launch context (the selected patient/EHR) to what the token may touch. FerroEHR never issues tokens, registers clients, or serves the OAuth2 endpoints itself; those remain your authorization server’s job.

Support is off by default. A stock server serves no discovery document and runs no scope gate, so the wire is byte-identical to a non-SMART deployment until you opt in.

Enabling it

Enabling SMART is not a single switch. The discovery document you start publishing is read by third-party applications to decide where to send an authorization request and where to exchange a code, so the server refuses to boot on a configuration that would publish an unusable or unsafe one. Six things must be true together:

export FERROEHR__SMART__ENABLED=true
# The external origin absolute service URLs are built from — required.
export FERROEHR__SMART__PUBLIC_BASE_URL=https://cdr.example.com
# Where apps obtain tokens. Both required, both absolute https.
export FERROEHR__SMART__ENDPOINTS__AUTHORIZATION_ENDPOINT=https://as.example/authorize
export FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT=https://as.example/token
# What the document claims the authorization server supports.
export FERROEHR__SMART__ENDPOINTS__RESPONSE_TYPES_SUPPORTED='["code"]'
export FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED='["client_secret_basic"]'
export FERROEHR__SMART__ENDPOINTS__CODE_CHALLENGE_METHODS_SUPPORTED='["S256"]'
# And the CDR must be able to validate the tokens apps come back with.
export FERROEHR__AUTH__OIDC__ISSUER=https://as.example/realms/ferroehr
export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-api

The boot rules, and why each one exists:

RuleWhy
smart.public_base_url is an absolute http(s) originevery services.*.baseUrl in the document is an absolute URL, and it cannot be built without knowing this server’s external origin
authorization_endpoint and token_endpoint are setan enabled platform without them publishes a document an app cannot act on
[auth.oidc] is configuredthe CDR directs apps to an authorization server, so it must be able to validate the tokens they return; without this every app would obtain a valid token and every request would be refused
smart.endpoints.issuer, when set, equals auth.oidc.issuerone says where apps get tokens, the other which tokens this server accepts; a mismatch is silently broken in the most confusing way available
every advertised endpoint (and public_base_url) is absolute and httpsa relative endpoint is unusable and a plaintext one exposes the authorization code and the access token (RFC 6749 §3.1.2.1, RFC 8414 §6.2). allow_insecure_endpoints = true opts a development authorization server out
response_types_supported is non-emptyRFC 8414 §2 marks it REQUIRED
token_endpoint_auth_methods_supported names at least one methodan empty list is not silence: it claims the authorization server supports none, and an app that reads it complies
code_challenge_methods_supported includes S256SMART App Launch requires PKCE (RFC 7636), and plain alone is not sufficient
grant_types_supported names neither implicit nor a password grantboth are deprecated in SMART and must never be advertised
the advertised issuer carries no query or fragmentthe RFC 8414 §2 issuer-identifier rules, the same ones auth.oidc.issuer is held to

SMART scopes ride only Bearer tokens, so the OIDC bearer requirement above is also what makes the scope gate able to see a scope at all (see Security).

Configuration keys

The full key set lives in the [smart] section of ferroehr.toml; each key can be overridden with the shown FERROEHR__SMART__* environment variable (__ separates nested fields).

KeyDefaultMeaning
FERROEHR__SMART__ENABLEDfalseMaster switch. Off = no discovery document (404) and an inert scope gate.
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 (the configured base path without its /openehr/v1 tail, i.e. /ferroehr/rest). A leading path is honoured (/gateway/v1/gateway/v1/.well-known/smart-configuration).
FERROEHR__SMART__EHR_ID_CLAIMehrIdToken claim carrying the launch context’s openEHR EHR id.
FERROEHR__SMART__PATIENT_CLAIMpatientFallback launch-context claim when the EHR-id claim is absent.
FERROEHR__SMART__REQUIRE_SMART_SCOPESfalseFail-closed switch; see Advisory vs required below.
FERROEHR__SMART__LAUNCH_BASE64_JSONfalseAdvertise the base64-JSON launch-parameter capability (experimental; consumed by the app, not the CDR).
FERROEHR__SMART__EPISODE__ENABLEDfalseAdvertise + accept episode launch context (experimental; advisory only, no episode filtering).
FERROEHR__SMART__ENDPOINTS__ISSUERunsetAdvertised token issuer. Unset = falls back to the configured OIDC bearer issuer; set, it must equal it.
FERROEHR__SMART__ENDPOINTS__JWKS_URIunsetAdvertised jwks_uri.
FERROEHR__SMART__ENDPOINTS__AUTHORIZATION_ENDPOINTunset (required when enabled)Advertised OAuth2 authorization endpoint.
FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINTunset (required when enabled)Advertised OAuth2 token endpoint.
FERROEHR__SMART__ENDPOINTS__REGISTRATION_ENDPOINTunsetAdvertised dynamic-client registration endpoint.
FERROEHR__SMART__ENDPOINTS__INTROSPECTION_ENDPOINTunsetAdvertised token introspection endpoint.
FERROEHR__SMART__ENDPOINTS__REVOCATION_ENDPOINTunsetAdvertised token revocation endpoint.
FERROEHR__SMART__ENDPOINTS__MANAGEMENT_ENDPOINTunsetAdvertised user management endpoint.
FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED[] (must be non-empty when enabled)Advertised client auth methods (e.g. client_secret_basic, private_key_jwt).
FERROEHR__SMART__ENDPOINTS__GRANT_TYPES_SUPPORTED[]Advertised grant types. implicit and the password grant are rejected at boot.
FERROEHR__SMART__ENDPOINTS__RESPONSE_TYPES_SUPPORTED[] (must be non-empty when enabled)Advertised response types (e.g. code).
FERROEHR__SMART__ENDPOINTS__CODE_CHALLENGE_METHODS_SUPPORTED[] (must include S256 when enabled)Advertised PKCE methods.
FERROEHR__SMART__ENDPOINTS__SCOPES_SUPPORTED[]Advertised scopes. Empty = a default list reflecting what the CDR enforces; set = emitted verbatim.
FERROEHR__SMART__ENDPOINTS__CAPABILITIES[]Extra HL7-defined base capabilities to advertise (e.g. launch-ehr, sso-openid-connect), the ones your external framework owns. Appended to the CDR’s own, deduplicated.
FERROEHR__SMART__ENDPOINTS__ALLOW_INSECURE_ENDPOINTSfalseAccept a non-https advertised endpoint. Development/testing only.

Warning

Everything under [smart.endpoints] is published to third-party apps, so an empty list is a claim, not a silence: it says the authorization server supports none of that thing, and a compliant app will believe it. Advertise only what your authorization server really offers.

The discovery document

When enabled, the server serves the standard SMART configuration document, unauthenticated, at:

GET /ferroehr/rest/.well-known/smart-configuration

(relative to the REST root, or to PLATFORM_BASE_URL when set). A launching app reads it to find your authorization server. It looks like:

{
  "issuer": "https://as.example/realms/ferroehr",
  "authorization_endpoint": "https://as.example/authorize",
  "token_endpoint": "https://as.example/token",
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"],
  "capabilities": ["context-openehr-ehr"],
  "scopes_supported": [
    "openid", "profile", "offline_access",
    "launch", "launch/patient",
    "patient/composition-*.cruds", "patient/aql-*.rs",
    "user/composition-*.cruds", "user/template-*.cruds", "user/aql-*.cruds",
    "system/composition-*.cruds", "system/aql-*.cruds"
  ],
  "services": {
    "org.openehr.rest": {
      "baseUrl": "https://cdr.example.com/ferroehr/rest/openehr/v1",
      "description": "The openEHR REST API baseUrl"
    }
  }
}
  • services is an object keyed by service type. It always names the openEHR REST service, and adds the FHIR façade (org.fhir.rest) when the FHIR routes are enabled. 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 always contains context-openehr-ehr (the CDR binds the ehrId launch context). openehr-permission-v1 is advertised only in fail-closed mode (REQUIRE_SMART_SCOPES=true): the capability announces fine-grained scope enforcement over openEHR resources, and advisory mode does not enforce against a scope-less caller, so advertising it there would over-claim. context-openehr-episode and launch-base64-json appear when their switches are on, and your own CAPABILITIES entries are appended.
  • scopes_supported is the default list above unless you configure your own, which is then emitted verbatim. Enabling episode context adds launch/episode to the default list.
  • Every unset optional endpoint is simply omitted from the document rather than emitted as null.

With SMART disabled the path is not mounted at all (404).

The scope grammar

SMART resource scopes have the form <compartment>/<resource>.<permissions>:

  • Compartment: patient (the launch context’s EHR only), user (what the user may see), 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 (order-free, e.g. .crud, .rs).

Resource ids accept wildcards: * matches within one ::-delimited namespace segment, ** matches across namespaces, and a bare * or ** matches every id. Every other character, :: and . included, is literal. The permission tail is split at the last dot, so template ids and query names keep their internal dots and versions.

ScopeGrants
patient/composition-*.crudCreate, read, update, and delete any composition, but only in the launched patient’s EHR.
patient/aql-*.rsRead and execute any stored query.
patient/composition-MyHospital::Template.v0.rRead compositions of exactly that template, in the launched patient’s EHR.
user/composition-MyHospital::*.rRead compositions of any template in the MyHospital namespace (not sub-namespaces).
user/template-*.crudsFull access to template definitions.
system/aql-org.openehr::bloodpressure.v1.rsA backend service may read and execute that one stored query.

Scopes the server does not recognise are retained but inert: never granted, never fatal; the identity scopes (openid, profile, offline_access, …) and the launch/launch/patient context scopes pass through untouched.

What the gate enforces

All three resource families are enforced. An operation is mapped to its family and to the CRUDS permission it exercises, and at least one of the caller’s scopes for that family must permit it:

FamilyOperationsResource id taken from
composition-…composition and versioned-composition reads and writesthe resolved template id
template-…operational-template operationsthe {template_id} path parameter
aql-…stored-query execution and AQL definition managementthe {qualified_query_name} path parameter

EHR, EHR_STATUS, CONTRIBUTION and DIRECTORY operations have no SMART resource type (the specification defines exactly three) so the SMART gate does not deny them; they stay governed by the RBAC/ABAC layers and the per-EHR EHR_ACCESS gate.

Note

An unresolved resource id matches only a broad wildcard. A template upload or list, and an ad-hoc (non-stored) query, carry no id, so a scope naming a specific id cannot match one, which is refused rather than assumed. Grant */** scopes for those operations deliberately.

Launch context: binding to one EHR

When an app is launched for a patient, your authorization server puts the resolved openEHR EHR id in the token: by default in an ehrId claim, with the standard SMART patient claim as fallback (both claim names are configurable). A composition operation permitted only by a patient/… scope is then bound to that one EHR: a request against any other EHR is refused, and a token holding only patient-compartment scopes but no launch-context claim is refused outright. user/ and system/ scopes carry no such binding.

Template and AQL operations are not per-EHR resources (templates are unscoped and queries are cross-EHR) so a patient/ scope permits them without a per-request EHR binding. Per-row patient scoping for queries is the ABAC subject-scope layer’s job, not the scope gate’s.

How scopes compose with RBAC and ABAC

The SMART gate is one more layer in the authorization chain, evaluated after authentication, the per-EHR EHR_ACCESS gate, RBAC, and ABAC (see Security). Every active layer must allow the request; SMART never overrides a denial from another layer, and it can only narrow. A scope denial is a 403 Forbidden.

Granted scopes are also visible to the ABAC layer as a subject attribute, so a Cedar or external policy can reason about them; the built-in gate is the floor, not the ceiling.

Advisory vs required

  • Advisory (default, REQUIRE_SMART_SCOPES=false): the gate enforces only when the token actually carries SMART resource scopes for the resource family in question. A non-SMART token, a Basic-auth caller (which has no scopes), and a server with authentication disabled are all unaffected. Once a token does carry, say, composition scopes, at least one of them must match the operation or the request is refused.
  • Required (REQUIRE_SMART_SCOPES=true): fail-closed. A caller with no matching SMART resource scope for a scope-governed operation is denied, including a caller with no token at all. Use this where every app is a SMART app. This is also the mode in which openehr-permission-v1 is advertised.

Note

Episode context is experimental: enabling it advertises the capability and accepts launch/episode, but the server applies no episode-scoped filtering: openEHR has no first-class episode resource yet.

Audit trail (IHE ATNA)

FerroEHR keeps a full security audit trail of API access (who did what to which resource, with what outcome, from where, and when) following the IHE ATNA (Audit Trail and Node Authentication) profile, the standard openEHR itself points at (the platform Service Model names the System Log component “IHE ATNA-compliant system log”). It is on by default: every deployment records a queryable audit trail with zero external dependencies.

Warning

Two postures are legitimate to run and wrong to run silently, so the server says them at 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:

  • FHIR R4 AuditEvent following the IHE BALP (Basic Audit Log Patterns) content profiles: the modern RESTful-ATNA form and the canonical stored form. Patient-centric operations carry the resolved EHR subject as the patient entity (PatientRead/PatientCreate/… profile claims); query executions carry the search expression; Bearer-authenticated requests record the token’s jti (and never the token itself) per OAUTHaccessTokenUse.Minimal.
  • DICOM Audit Message (DICOM PS3.15 §A.5 XML): the classic ATNA form, shipped over syslog per IHE ITI-20 when the syslog sink is enabled. Dedicated DICOM event ids are used throughout: Patient Record (110110), Query (110112), Export/Import (110106/110107, the EHR-Extract directions), User Authentication (110114, with the Login EventTypeCode 110122).

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.
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
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 ITS-REST operation id
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 source participant
On whose authority, and whypurpose and legal_basis
Which system, and from whereAuditSourceID and the source participant’s network address (client_ip)

Two of these are the deployment’s to supply. purpose is only as meaningful as the vocabulary you agree with your callers: set [audit] purpose_codes to that vocabulary and a code outside it is recorded as absent rather than as free text that reads at review time like an established purpose. legal_basis is a deployment-level fact and is recorded on every access once you set it.

Mapping the recorded fields onto your own retention schedule and review process stays yours: the software records the events and serves them back, and no supplier document discharges the obligation to review them.

The EHDS logging elements, mapped

Annex II 3.2 of Regulation (EU) 2025/327 lists five things the European logging software component must record “on every access event or group of events”. This is where each one lands, or does not. The elements were read from the published text on 2026-09-10; the regulation is the authority and the wording below is a paraphrase.

Annex II 3.2Access-event fieldDICOM PS3.15FHIR AuditEventState
(a) the healthcare provider or other individuals having accessedorganisation— (no such attribute)a second agent with who referencing Organization/…Recorded
(b) the specific natural person or persons having accessedprincipal (+ token_id, client_ip)ActiveParticipant/@UserIDagent.whoRecorded
(c) the categories of data accessedresource_class + domain (+ result_count)ParticipantObjectIdentification type and role codesentity.type / entity.rolePartial
(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 per origin, named “origin of the served data”Recorded

Read (a) beside (b): they are both about the accessing side, and the pair distinguishes the organisation on whose behalf the access happened from the natural person who made it. The principal answers (b); the organisation column answers (a), resolved from the same access-token claim the ABAC layer reads ([authz.abac] organization_claim) rather than from a second setting that could name a different claim. Sharing the setting is not sharing the switch: the organisation is recorded whether or not the ABAC gate is enabled, because the trail describes a caller rather than deciding anything about them. The FHIR rendering carries it as a second agent referencing an Organization, with no participation code, because AuditEvent.agent.type is optional and inventing a code would be worse than omitting one. The DICOM rendering carries nothing: PS3.15 §A.5 defines no organisation attribute on ActiveParticipant, and AuditEnterpriseSiteID is the reporting node’s own site rather than the caller’s organisation. Where no claim is configured or the caller authenticated with Basic, the field stays empty rather than being guessed.

Element (c) is partial for a vocabulary reason rather than a plumbing one. The record says what kind of object was read and which pseudonymisation domain it came from, so “was this clinical content or an identity” is answerable per event. What it does not say is which Annex I priority category the data falls in; openEHR resource classes are a different vocabulary, and the mapping between them is not one this project should invent.

Element (e) asks where the DATA came from, which is not where the request came from. openEHR models that provenance as FEEDER_AUDIT on the content itself, so every committed version records the distinct set of FEEDER_AUDIT.originating_system_audit.system_id values found in its body, or this server’s own system id when it carries none. A composition, status or folder read then records the origins of the version it served, and an AQL execution the union over the version rows its result page came from. The record carries the true distinct count beside a set capped at 32, so a page drawing on more origins than that says how many rather than looking complete. A version committed before the column existed is assessed when it is read.

One further element, adjacent to the five: the declared purpose of use reaches one export of the two, and only one of them could carry it. It is stored on the record, served by ITI-81, and rendered as agent.purposeOfUse on the requesting person’s agent in the FHIR export. The coding carries the code alone, with no system: the vocabulary is the one you agree with your callers in [audit] purpose_codes, which has no published code system URI, and attributing the code to a system it does not come from would misdescribe it. The DICOM Audit Message schema of PS3.15 §A.5 defines no purpose element at all — EventIdentification, ActiveParticipant, AuditSourceIdentification and ParticipantObjectIdentification, and none of them carries one — so on that side it is a limit of the format, not something this code withholds.

Note

Every row of this table is asserted by a test (app/ferroehr/tests/it/audit_ehds_mapping.rs), including the gaps: the tests that record an absence fail the day the field arrives, so the table and the code cannot drift apart in either direction.

The common specifications the regulation’s Article 36 provides for have not been adopted. When they are, this mapping is re-verified against them rather than assumed to still hold; see the EHDS readiness page.

Retention, and who chooses it

[audit.store] retention_days decides how long the repository keeps a record: 0, the default, keeps them forever, and any other value reaps hourly everything older. The default is deliberate. An access log is the evidence a data subject’s right of access is served from, and a repository that silently forgets is worse than one that grows — so the software keeps everything until an operator says otherwise, rather than choosing a horizon on their behalf.

Choosing that horizon is the deployment’s, and it is a legal question rather than a technical one: EHDS sets no retention period for the access log, while national law does — NEN 7513 is the Dutch reference for this log and the Wabvpz for how long it must survive. The setting is per node, and one node serves one organisation, so two organisations needing different horizons run two instances.

A floor is enforced where a jurisdiction sets one. The jurisdictions a deployment answers to are the ones its [privacy.identifier_scan] rules name, and where one publishes a minimum retention for the access log the server refuses to boot with a shorter retention_days, naming the configured value and the floor. 0 always passes. The floors registered:

JurisdictionFloorSource
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

The other shipped jurisdictions (DE, FI, GB, NO, SE) have no floor registered: an unknown requirement is never guessed at, and a deployment there sets its own. The effective retention is on the boot line beside the audit-enabled facts and on GET /management/info under audit.

Getting the log out

Two routes, and they answer different questions.

  • ITI-81 retrieval — the audit search serves the records back as FHIR 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, 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 over syslog (RFC 5424; UDP or TLS transport) 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.

Emission never blocks the request path: a record is handed to a bounded queue (queue_capacity) and written by a background drain.

The local store is the durability anchor. Under fail_mode = "closed", an operation whose audit record cannot be recorded answers 503 Service Unavailable: the deployment demanded an audit trail it cannot currently deliver, so no un-audited PHI access happens, and the same holds for the domain-level records the linkage, subject and identifier resolutions and the EHR-Extract transfers write. open, the default, drops-and-meters instead, and every loss path is metered (the atna_audit_* counters in Operations); the server announces that default at boot and on the readiness indicator as a stated caution, because a metered loss is still a loss. Login and rejection records never gate a response in either mode.

Tamper evidence

An audit trail that anything with the application’s database password can quietly rewrite is a log, not an accountability record. The local store is therefore tamper-evident: every record is linked into a SHA-256 hash chain maintained inside PostgreSQL, so each record commits to its predecessor and to its own content. The chain is built by the database itself, not by the server, which means it covers every writer: the per-event insert, the batched drain, and any statement typed by hand.

Three controls sit on top of it, and they are separate on purpose:

  • The table refuses the ordinary rewrite paths outright. The only permitted change to a stored record is the per-sink forwarding stamp; an 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, records deleted with no retention record for the removal, or records removed from the end of the chain where no successor would have noticed. Run it on a schedule and alert on any output.

Important

This is detection, not prevention, and the boundary is worth stating plainly. The chain is unkeyed, so a party with unrestricted write access to the 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 served at the FHIR façade:

GET /ferroehr/rest/openehr/v1/fhir/r4/AuditEvent

It returns a FHIR searchset Bundle of the stored AuditEvent documents, newest first, with the full match total. Supported search parameters:

ParameterMeaning
dateevent-time bound, ge/le-prefixed RFC 3339 instants, repeatable
patientthe recorded patient (EHR subject) id
agentthe authenticated principal
entitythe touched resource id
outcomethe outcome indicator: 0, 4, 8 or 12
actionthe action code: C, R, U, D or E
_count / _offsetpaging; page size defaults to 50, capped at 1000

Other FHIR search parameters are ignored (lenient search); a malformed value on a supported one is a 400 carrying a FHIR OperationOutcome. Under RBAC the unscoped retrieval is admin-only; a caller holding the configured authz.rbac.subject_audit_role reads it scoped to one subject, patient required, and is refused with a 403 that names the parameter when it omits it. Reading a subject’s log is itself recorded as an access naming that subject and the record count served. The surface answers 404 when the local store is disabled.

# Who accessed patient-42's data this month?
curl -u admin:pw \
  "https://cdr.example.org/ferroehr/rest/openehr/v1/fhir/r4/AuditEvent?patient=patient-42&date=ge2026-07-01T00:00:00Z"

This is also the openEHR “record demerging” instrument: when data lands in the wrong EHR, the patient-filtered audit search shows exactly who read it.

Note

Audit records are themselves sensitive (they name patients, subjects and actions) so this endpoint is a PHI-disclosure surface and is authorized like any other.

Node authentication (ITI-19, mutual TLS)

ATNA’s second half is node authentication. [server.tls] terminates TLS natively and can demand a verified client certificate:

[server.tls]
enabled = true
cert_file = "/etc/ferroehr/server.pem"
key_file = "/etc/ferroehr/server.key"
min_version = "1.3"               # 1.3 (default) | 1.2
client_auth = "required"          # off | optional | required
client_ca_file = "/etc/ferroehr/client-ca.pem"

The protocol floor is TLS 1.3 only by default, following the OWASP Transport Layer Security cheat sheet. Setting min_version = "1.2" enables 1.2 alongside 1.3, never instead of it, for a client that genuinely cannot do 1.3, such as an older integration engine or a pinned Java runtime. TLS 1.1 and 1.0 are not selectable at all.

With client_auth = "required", only clients presenting a certificate chaining to your explicit trust anchor complete the handshake: the IHE mutually-authenticated-node posture. Deployments terminating TLS at an ingress keep [server.tls] off and enforce mTLS there instead.

Warning

The separate-port management listener (management.port) stays plain HTTP even with [server.tls] enabled, and it binds all interfaces. Treat it as an internal surface and keep it off any publicly routed port.

Complete the posture with time synchronisation (IHE Consistent Time): run NTP/chrony on every node so audit timestamps align across systems.

Configuration summary

Auditing defaults to on with the local store only; see the configuration reference for every [audit] key and its FERROEHR__AUDIT__* environment form. The syslog sink’s own keys are host / port / transport / tls_ca_file / tls_identity_cert_file / tls_identity_key_file under [audit.syslog]; the switches shared by every sink stay directly under [audit].

Note

There are no alternative spellings for any of these: an unrecognized FERROEHR_* variable or an unknown TOML key is a boot error naming the spelling it should have had, never a silently ignored setting. Validate a deployment’s configuration up front 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 {base}/admin/integrity/verify re-derives every stored version from its decomposed rows and reports any that no longer match the stored document. It is an admin route, it runs outside the request path, and it reports by identifier rather than by content. The admin API reference documents the report and its five defect values.

Verification at read

signing.verify_on_read resolves to strict whenever signing is enabled, so the default deployment checks its own digests. On a VERSION read the server rebuilds the envelope from the stored row, recomputes the canonical form, recomputes the digest, and compares.

verify_on_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 by capability. If the certificate carries a subkey flagged for data signing (RFC 9580 §5.2.3.29, key flag 0x02), the server signs with that subkey; otherwise it signs with the primary key. Position in the file decides nothing, so an encryption subkey is never used to sign.

An RSA signing key is accepted with a warning. Every commit would then perform an RSA private-key operation, the operation the Marvin timing sidechannel (RUSTSEC-2023-0071 / CVE-2023-49092) concerns, and the underlying rsa crate has no fixed release. The server warns at boot and keeps working, because a repository whose history is RSA-signed still needs that key to verify it. Ed25519 or ECDSA keeps that code off the signing path.

Retired keys and rotation

A stored VERSION.signature records no key identifier, and it is an immutable committed fact that cannot be re-issued. Rotating a key therefore has to keep the old one verifiable. Two mechanisms do that:

  • A new signing subkey on the same certificate. The server picks the signing-capable subkey, the certificate retains the previous one, and verification tries the primary key and every subkey. Nothing else changes.
  • retired_key_paths. A replaced certificate’s public half is listed there and consulted during verification. A public key verifies and can never sign again, so a retired entry cannot become an active signer.

Server-generated versus client-supplied signatures

A VERSION.signature in this repository is one of two things, and the distinction is recorded on the row rather than guessed from the value.

Server-generated is the ordinary case. The direct write routes (POST/PUT/DELETE on a composition, the EHR status, the directory, a demographic party) carry no signature field at all, so every version they commit is signed by this server when signing is enabled.

Client-supplied reaches the server through one route: an UPDATE_VERSION.signature inside a CONTRIBUTION commit. When a member carries one, the server stores it verbatim and does not sign that version itself, whatever signing.mode says. openEHR models the signature as a fact created by the committer and carried with the data, potentially in another agreed serialization, so the server has nothing to recompute it against. Such a signature is never re-verified at read, and no verify_on_read setting changes that.

Note

A client-supplied value is stored as sent, so an opaque or invalid one is served back unchanged rather than refused. It is a claim by the committer, and this server neither vouches for it nor mistakes it for its own. Only signatures the server generated are covered by read-time verification.

Imported versions carry two signatures

An EHR Extract import wraps each received ORIGINAL_VERSION in an IMPORTED_VERSION, and openEHR is explicit about what happens to signatures there. The wrapped original “is never modified”, and the wrapper is signed like any other version: “all attributes of the object are serialised and then used to generate a signature. The result will be that the IMPORTED_VERSION instance will carry its own signature which signifies the act of importing and making available locally an ORIGINAL_VERSION from another system.”

FerroEHR implements exactly that:

  • The wrapper is signed by this server, over the whole IMPORTED_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 (Leptos SSR + WebAssembly); there is no hand-written JavaScript anywhere, including its browser tests.

Running it

The quickstart compose ships the viewer as the ferroehr-viewer service on port 3000, behind the viewer profile, so it is opt-in and does not start with a plain docker compose up:

docker compose --profile viewer up
# → http://localhost:3000  (log in with the quickstart user ferroehr/ferroehr)

Standalone, point it at any CDR:

docker run -p 3000:3000 \
  -e FERROEHR_VIEWER__CDR__BASE_URL=https://cdr.example.org \
  ghcr.io/rubentalstra/ferroehr-viewer

On Kubernetes

The Helm chart deploys the viewer as its own Deployment, Service and ServiceAccount, with an optional Ingress and a NetworkPolicy that confines its egress to the CDR and DNS: the viewer is a REST client of the CDR by mandate, so the chart enforces that rather than trusting it. Off by default, and off renders nothing:

# values.yaml
viewer:
  enabled: true
  ingress:
    enabled: true
    hosts:
      - host: viewer.example.org
        paths:
          - path: /
            pathType: Prefix
  auth:
    oidc:
      enabled: true
      issuer: https://keycloak.example/realms/ferroehr
      clientId: ferroehr-viewer
      publicBaseUrl: https://viewer.example.org
  # the OIDC client secret is MOUNTED from a Secret you create, never env-borne
  existingSecret: viewer-oidc

It needs no database credential and never reaches the database. Before you enable OIDC: a registered client whose redirect URI matches publicBaseUrl, and a Secret holding its client secret. To turn the viewer off, set viewer.enabled: false and upgrade; every viewer object is removed and the CDR is untouched.

Note

To scale the viewer past one replica, set the same session.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. Sign-in is served fully rendered and works with JavaScript disabled.

The viewer manages no accounts of its own: it authenticates you against the CDR (Basic) or your identity provider (OIDC), and there are no user, role, or password screens to find; those live in the CDR’s configuration and in your IdP.

The viewer ships a full dark theme (the toggle persists per browser), and every screen in this chapter has one, and Dark mode is the gallery. The user menu opens the access drawer:

The access drawer

“View scopes” answers what may this session do, and who says so:

  • the authenticated principal and how it signs in: a Basic session replays its CDR account (and carries no SMART scopes), an OIDC session carries an access token whose scopes are listed;
  • every scope on the session rendered as its parsed grant: the compartment it delegates to (patient / user / system), the resource family and id pattern it reaches, and the create/read/update/delete/search operations it permits, with a broad access marker on a bare *;
  • a previewer: paste any scope string, or a whole space-separated claim, and read the same rendering. A scope shaped like a resource scope but malformed explains what the grammar expected instead of quietly reading as nothing.

The reading is not the viewer’s own interpretation: it parses with the same module the CDR’s SMART scope gate enforces with, so the two can never drift. Scopes narrow access and never grant it; the CDR remains the enforcer, and a previewed grant is an upper bound.

When your session ends

A session ends when you sign out, when its idle window (session.idle_minutes) passes with no request from your browser, or when the CDR stops accepting the credential behind it (an expired or revoked token). The viewer notices on its own. Every call it makes to its backend is checked for a signed-out answer, and the shell re-checks the session on its own schedule as well, so a session that ends while a screen is open is detected within seconds even if you touch nothing. The authenticated screens then unmount and you land on the sign-in page with a notice that the session ended. Signing in again, with Basic or with OIDC, returns you to the page you were on. There is no in-between state where the header reports the CDR as offline while the rest of the screen still accepts input.

The idle window slides on any authenticated request, and the shell’s own status poll is one, so a tab you leave open stays signed in while it is open. Closing the browser ends the session.

The deployment profile

The header’s status chip names the profile the connected CDR declares (deployment_profile in its configuration, reported on GET /rest/status): CDR UP · v<server version> · production, or · sandbox. A production deployment says so quietly and nothing else changes. A sandbox deployment raises a persistent notice under the header on every authenticated screen: this deployment has not made the production separations and must not hold real patient data, followed by the separations the server reported as open (a shared database credential, a shared cluster, no subject pseudonym namespace, no durable audit trail, schema preparation on the runtime credential). The quickstart and the composed stacks run as sandbox, so the notice is what you see there; a CDR older than the profile reports none and the viewer claims nothing. See the deployment profile in the server configuration.

Configuration

One TOML file (ferroehr-viewer.toml, searched in the working directory and /etc/ferroehr/viewer.toml, or pointed at with FERROEHR_VIEWER_CONFIG), with FERROEHR_VIEWER__<SECTION>__<KEY> environment overrides. Unknown keys are refused at startup, exactly as on the CDR:

KeyDefaultMeaning
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 server.base_path: set the same value here when a deployment shortens it, or the viewer calls paths the CDR does not serve. The status document, the OpenAPI documents and the SMART discovery document are derived from it by the same rule the CDR uses.
cdr.request_timeout_secs30Per-request timeout toward the CDR.
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 the Operations panel.
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 / scopesThe 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.resolveA 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 (sliding; 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_filePath to a file holding the sealing key; wins over session.secret.

The viewer is stateless: it has no database and keeps no local files of its own. Everything it shows, including how stored queries are grouped, which is derived from the namespace in each query’s qualified name, lives in the CDR and is read over ITS-REST, so nothing here needs backing up and every replica shows the same repository. Sessions are a sealed cookie (AES-256-GCM, keyed by session.secret), so any replica holding the key can serve any signed-in visitor.

Login and sessions live in the viewer’s backend; the browser stores only the encrypted session cookie — CDR credentials and bearer tokens never reach it in readable form.

The screens

  • Dashboard: record counts, per-namespace stored-query match tiles, and a commit-activity trend. See Dashboard & queries.
  • Templates: upload and inspect operational templates. See Templates & EHR browsing.
  • Queries: the point-and-click Query Builder, the raw AQL editor, and stored-query management. See Dashboard & queries.
  • EHRs: browse EHRs, folders, compositions, version history, and the item tags on any of them. See Templates & EHR browsing.
  • Demographics: browse and edit the five demographic party kinds, their relationships, version history, and tags. See Demographics.
  • FHIR: the connector’s mapping-store editor, a read-path viewer, and a validate-only dry-run panel; appears only when the CDR’s FHIR API is enabled. See FHIR connector admin.
  • Terminology: browse the terminologies the CDR serves, define a code, expand a value set, and test membership or subsumption. See Terminology.
  • Audit log: browse the CDR’s ATNA security audit trail (see below).
  • System: CDR status, the openEHR conformance manifest (what the server advertises about itself through the System API: product, vendor, claimed conformance profile, and the API groups it actually mounts), SMART discovery, repository usage, the server’s own OpenAPI documents. Pick the complete surface or one API family, and the choice stays in the URL, so the redacted runtime configuration, and a shortcut into the audit browser.
  • Operations: dependency health, build and spec provenance, the metric registry, and runtime log control. Appears only when the CDR serves its management surface. See Operations panel.
  • Subscriptions: the event subscriptions that decide which committed versions the CDR publishes to a message broker. Appears only when the CDR serves its subscription API. See Event subscriptions.

Every one of them is themed twice; Dark mode shows the dark half of the viewer screen by screen.

Paging

Every listing is paged, and the page lives in the URL: a page is shareable and bookmarkable, a reload lands on the same rows, and the browser’s back and forward buttons walk the pages. The tables the viewer holds in full (Templates, Queries) share one footer under the table: which rows are on screen out of how many (26–50 of 137 templates), previous/next, and a rows-per-page choice of 25/50/100 (?page= and ?size=). The AQL-backed listings (EHRs, an EHR’s compositions) page through ?offset= links, and the audit browser through ?page= beside its filters. Every one of these controls is a plain link, so paging works before the page’s WebAssembly loads, and a page link carries the screen’s other parameters (the tab you are on, the filters you set) across with it.

Audit log

The Audit log screen browses the CDR’s security audit trail (who accessed what, with what outcome) through the standard IHE ITI-81 retrieval (GET /fhir/r4/AuditEvent; see the Audit trail chapter). Filter by event-time window, patient, principal, outcome, or action; every filter lives in the URL, so a filtered view is shareable and refresh-safe. Each row opens the full stored FHIR R4 AuditEvent record.

The audit trail is an operator surface: under role-based access control the screen requires the CDR’s admin role, and when the CDR’s local audit store is disabled the screen says so instead of erroring.

Each row’s view disclosure opens the full stored AuditEvent record: exactly what the ITI-81 API serves:

A filter that matches nothing renders a distinct empty state, so “no records” is always visibly different from “records you haven’t found”:

Dashboard & queries

The viewer’s query surfaces are three views of the same thing: the dashboard summarises what the repository holds, the Query Builder assembles AQL from a template’s own paths, and the raw editor runs AQL you wrote yourself. All three save to, and run against, the CDR’s own stored-query registry; nothing is kept on the viewer’s side.

Dashboard

The landing screen shows EHR / composition / template / stored-query counts, one tile per stored-query namespace (the summed match counts of the queries in it), and a commit-activity trend rendered as pure SVG.

The namespace tiles are live: each one runs every stored query in that namespace as a count. If one of them fails, the tile reports no number rather than a misleading total. Run that query on the Queries screen to read the CDR’s own diagnostic.

The Query Builder

Build AQL without writing it: pick a template, pick paths from its tree, and add typed conditions: each data type gets the right widget, populated from the template’s own constraints (coded value sets, ordinal scales, quantity units). Conditions combine into arbitrarily nested ALL/ANY groups with per-condition and per-group negation. The generated AQL is previewed live and is always grammatically valid, since the builder assembles the same query syntax tree the server parses, then prints it, so it never hand-builds query text.

A coded condition can go further than the template’s own list. Its terminology field is backed by the terminologies the CDR serves, offered as suggestions while staying free text: an AQL author must be able to name a terminology this server does not host. Beside the code box, look up asks the CDR what the code means and adds it as code — text; a code the terminology does not define is still added, marked unvalidated, because the builder never refuses a code the CDR happens not to know. Expand a value set and its members become one-click chips. Typing a code and pressing add works exactly as it always has, and whichever route a code took, the query carries the bare code, and the rubric is only how it reads on screen. When the CDR does not serve its terminology surface the field is simply a plain text box.

Choose what comes back: whole compositions, projected data points (with column aliases), a bare match count, or the distinct EHR ids matching the criteria tree (EHRs (cohort)). That shape is a plain AQL projection over the clinical record and is unrelated to the server’s cross-domain cohort query, which the viewer does not drive. Run pages through the result set; save the query to the CDR’s stored-query registry under a namespace and a name (see Grouping is the namespace).

The raw AQL editor

The same run/save surface for hand-written AQL: grammar validation before anything reaches the CDR, JSON parameter bindings, paged results. The builder’s “open in raw editor” hands its generated query across. When the editor was opened from a stored query, it also links back the other way, with Open in builder and Run with parameters, so the three surfaces are reachable from each other for the same stored definition.

Every non-empty result set offers a Table | Chart toggle. The chart draws one line per numeric column, named by that column’s alias, and its legend switches a series on and off; the last visible series stays on, so the chart never empties. When a column holds ISO-8601 date/times it is offered as the X axis and used by default: a real time scale, where points sit at their true distance apart whatever order the rows came back in. The row order stays available as the fallback axis, and a single numeric column still draws as one plain line. A result set with nothing to chart (no numeric column, or a single row) says so in the chart pane rather than showing a blank box.

Exporting results

Both results panes (the builder and the raw editor) offer Export CSV and Export JSON, a plain form download that works even before the page’s WebAssembly loads. The export runs the query’s own LIMIT window, or the server’s default fetch limit when the query has none. CSV cells hold scalar values verbatim; structured values are embedded as compact JSON.

Stored queries & namespaces

Fresh repositories start empty, with the action that fills the screen:

List the CDR’s stored queries, inspect a query’s AQL, and jump into the editor to run it.

The table is paged by the shared footer under it: rows on screen out of how many, previous/next, and 25/50/100 rows per page, all in the URL (see Paging). The namespace panel beside it is derived from the same listing and shows every namespace, whichever page you are on.

Each stored query row offers three hand-offs:

ActionWhat it opens
RunThe stored-query runner, which executes that stored query on the CDR, with its parameters prompted
Open in editorThe raw AQL editor, seeded with that version’s query text
Open in builderThe query builder, with the query loaded back into its controls, when it fits (see Opening a stored query in the builder)

Both editing hand-offs pre-fill the namespace, name, and version fields, with the version set to the next one, so saving again publishes a new version instead of colliding with the one you opened (see Versions).

Opening a stored query in the builder

The builder writes one shape of AQL, and it will only load a stored query back into its controls when it can reproduce that query byte for byte; it re-lowers what it recognised and compares the result against the stored text. Anything else opens with a notice naming exactly what the builder cannot express (a $parameter it has no field for, a query over other RM classes, an aggregate outside its output shapes) beside a link to work on it in the raw editor instead. There is no partial load: a builder that showed most of a stored query would quietly rewrite it on the next save.

A query that does load arrives complete (template, conditions including nested ALL/ANY groups and negation, output shape, ordering, limit) with its condition labels and value lists taken from the template’s own constraints, and the next version proposed in the save field.

Running a stored query

Run on a stored-query row opens the runner for that query. It shows the stored AQL, prompts one field per parameter the query declares, and executes the stored definition on the CDR, not a copy of its text, so what runs is what every other openEHR client would get.

Values are read as JSON first: 38.5 is sent as a number and true as a boolean, while at0037, 2026-07-01 and 1.0.0 are sent as text without any quoting. To force a numeric-looking value to stay text, quote it: "0123". A field left blank is not sent at all, so the CDR can apply its own default or say what is missing.

Results render in the same results pane as the other query screens: table or chart, with previous/next paging. A query that sets its own row window (an AQL LIMIT or TOP) is run as stored and the pane says so rather than paging it, because openEHR does not allow a request window and a query window together.

Choosing how the version resolves

openEHR defines three ways to name the version of a stored query you are reading, and the runner offers all three. The line under the picker always states the exact request your choice sends:

ResolutionRequestWhich version runs
Latest versionPOST /query/{name}the latest version of that query
Version prefixPOST /query/{name}/1 or …/1.2the latest version matching the prefix
Exact versionPOST /query/{name}/1.2.0exactly that version

A version that does not fit the chosen form (a full 1.2.0 typed as a prefix, or a bare 1 typed as exact) is refused with an explanation before anything is sent.

Grouping is the namespace

A stored query is identified by a qualified name, namespace::name, the namespace optional and, when present, a reverse domain name whose purpose in the openEHR REST specification is exactly “separation of use of stored queries by teams, companies, etc.”

The viewer therefore does not invent a grouping of its own: a query’s group is its namespace, chosen when you save it. The right-hand panel on Queries and the cohort tiles on the Dashboard are both derived live from GET /definition/query. There is nothing to create, edit, or remove: and nothing kept on the viewer’s disk, so the grouping is durable in the CDR and reads identically for every openEHR client and every viewer replica. Queries saved without a namespace collect under unqualified.

Both save surfaces (the builder and the raw editor) therefore offer the Namespace field beside the Query name, and show the exact qualified name the save writes. Typing the whole namespace::name into the name field works too.

Versions

A stored query is identified by its qualified name and a version, and the version is SEMVER-style, major.minor.patch. The save surfaces expose it as an optional Version field, and the line under the fields always states which of the two openEHR store operations a click will perform:

Version fieldWhat a save does
emptyPUT /definition/query/{name}; the CDR files it at the default slot 1.0.0 and replaces whatever was stored there
1.2.0PUT /definition/query/{name}/{version}; stores a new, immutable version; if that exact (name, version) pair already exists the CDR refuses it (409) and the viewer says so

Because an explicit version is immutable, Open in editor and Open in builder both propose the next minor version (opening 1.0.0 fills the field with 1.1.0): edit, save, and both versions are then listed side by side. Which part to bump is yours to change; the field is free text and only checks that a version you type is a complete triple.

A shorter pattern like 1 or 1.0 is a read form, not a store form: when fetching or executing a stored query, openEHR resolves a partial version to the latest one matching that prefix, and omitting the version entirely means the latest of all. The viewer therefore refuses a partial version in the save field (with an explanation) rather than filing a definition under a string that later lookups would treat as a pattern. The CDR refuses one too, with a 400: a prefix names no version a store could create, and openEHR assigns the write no other outcome.

The default slot 1.0.0 is the CDR’s own choice; openEHR does not say which version a version-less store mints, so the version-less form always writes and reports that one slot, even when higher versions of the same name already exist. If you need a specific version, type it.

Deleting a stored query

Delete from CDR (on a stored-query row) deletes that version of the query from the CDR’s stored-query store, for every client. The only destructive action on the screen. It appears only when the CDR’s admin API is enabled (admin.enabled / FERROEHR__ADMIN__ENABLED, off by default; see [admin]); the delete itself additionally needs the ADMIN role, and a session without it is refused with a message naming what is missing.

It opens a confirmation dialog that names the exact query and version before anything is sent, and a refused delete is reported with the CDR’s own diagnostic and the next action to take. Deleting the last query of a namespace simply makes that namespace stop appearing.

Templates & EHR browsing

These are the viewer’s read-and-write screens over clinical content: the templates the CDR validates against, and the EHRs, folders, compositions and contributions committed under them. Every screen here is a view of the CDR’s public API, so anything you change is a normal openEHR write that every other client sees.

Template Manager

Upload operational templates (the CDR’s validation diagnostics surface verbatim on rejection) and browse what is installed. The screen serves both archetype-model families, switched by the ADL 1.4 / ADL 2 pills under the title. The choice is in the URL (/templates is the ADL 1.4 listing and /templates?family=adl2 the ADL 2 one) so either is a shareable link, and the filter and the paging footer work the same in both.

Both families upload the same way: the button at the top right of the screen (Upload OPT or Upload ADL2, depending on the family you are looking at) opens a dialog where you either choose a file or paste the source. Both inputs feed one editor, so a chosen file can be read over, corrected, and sent from there. Upload template stays disabled until there is something to send, a refusal keeps the dialog open with the server’s diagnostic beside the source it rejected, and a successful upload closes the dialog and refreshes the list. Each time you open the dialog it starts empty, so a refused source is yours to correct while the dialog is up, and closing it discards the attempt.

The template detail screen shows the path catalog (the template’s tree with each node’s archetype path, RM type, and constrained value sets) plus the raw OPT XML and a CDR-generated example composition in any supported format.

The list filters as you type, matching a template’s id, its concept, or its root archetype id, and is paged by the shared footer under the table; rows on screen out of how many, previous/next, and 25/50/100 rows per page, all in the URL (see Paging). The filter narrows the rows; the footer counts what the filter left.

The list also shows each template’s root archetype id, and the detail screen opens with an identity card (concept, version, default language, languages, and the template UID) read from the operational template itself.

Deleting a template

When the CDR’s admin API is enabled, each list row and the detail screen offer Delete. It opens a confirmation dialog naming that template, and nothing is sent until you confirm there. The CDR refuses a template that a committed composition still uses; the refusal is shown with the referencing count, so delete or migrate those compositions first, and it likewise refuses a session without the ADMIN role, naming what is missing. If the admin API is off, no delete button is shown at all: the viewer asks the server which API groups it serves (the openEHR System API conformance manifest) before offering any of them.

Warning

This is a physical delete of the template registration, not a versioned one. The server-side switch is admin.enabled (FERROEHR__ADMIN__ENABLED), off by default; see [admin].

ADL 2 templates

The ADL 2 family lists the operational templates the CDR compiled from ADL 2 sources. An ADL 2 artefact is identified by its archetype HRID (openEHR-EHR-COMPOSITION.vitals.v1.0.0) whose trailing .v1.0.0 is the artefact’s own release version, so several versions of one template appear as separate rows and the list shows all of them.

Uploading works exactly as it does for ADL 1.4, through the same dialog; what differs is the artefact. The CDR ingests the ADL 2 source as plain text rather than an XML document, so the dialog accepts .adls files and the source you paste is sent verbatim. If the openEHR-ADL engine refuses it, its diagnostics (the AOM 2 rule codes with their line and column) appear in full under the editor as well as in the failure notification, so the source can be corrected in place and re-sent.

Opening a row shows the artefact’s three server-side representations plus a derived view:

  • Source: the stored ADL 2 text, exactly as the CDR holds it.
  • AOM2 JSON: the same operational template as canonical JSON (OPERATIONAL_TEMPLATE), which is where the constraint structure, node ids and occurrences are readable.
  • Example: a composition the CDR generates from the template, in canonical JSON, canonical XML, FLAT or STRUCTURED.
  • Path catalog: the same expandable tree and node inspector the ADL 1.4 detail shows, built by the viewer from the AOM2 JSON.

The Version bar above the panes pins the Source and AOM2 JSON reads to a particular release version. The chips are the versions the CDR actually holds for that HRID family, and As stored returns to the artefact the link named. The box beside them also accepts a prefix (1 or 1.0) which the CDR resolves to the highest matching version, so 1 on a family holding 1.0.0 and 1.1.0 shows 1.1.0. Whichever you pick lands in the URL as ?version=, so a pinned view is shareable. The example is generated from the artefact the link named and does not follow the version bar: the CDR publishes no versioned example resource.

Note

The ADL 2 path catalog is built by the viewer itself: the CDR serves no Web Template representation of an ADL 2 artefact, so the viewer reads the AOM2 JSON and derives the same tree the ADL 1.4 detail shows. The CDR wire stays exactly the released REST API.

Deleting an ADL 2 template

ADL 2 rows carry the same Delete affordance the ADL 1.4 rows do, and it behaves the same way: a confirmation dialog naming the artefact, nothing sent until you confirm, the refusal shown with its referencing count when a committed composition still uses the template, and no button at all when the CDR’s admin API is off.

What differs is the resource underneath. An ADL 1.4 delete removes the template registration from the Admin API’s template store; an ADL 2 delete removes the whole artefact (archetype, template or OPT) from the definition store, which keeps no version history of it, so the deleted release version is simply gone. Other versions of the same HRID family are untouched: each is its own artefact, deleted from its own row. The route is admin-gated like the rest, so a session without the ADMIN role is refused with a message naming what is missing.

EHR browser

Find an EHR by id (or browse the most recent), then work through its tabs: EHR status, the status version history, the folder directory, the composition list, contribution lookup, and the EHR’s item tags. Find-by-id is a plain form: it works in a browser with JavaScript disabled, and /ehrs?find=<ehr_id> is a shareable shortcut straight to an EHR.

The EHR detail screen opens with a summary header. Its top line answers “whose record is this, and what may be done with it”: the subject, the external id and namespace the EHR status references, or an explicit “self — no external subject reference” when the EHR is bound to no outside identity, next to the queryable and modifiable badges. Below it are the EHR resource’s own facts: its id, the system that created it, when it was created, and the reference to its current EHR status. A mistyped or unknown id is reported there, once, instead of once per tab.

The identity line and the Status tab read the same EHR status document, so they can never disagree: saving a status change updates both at once.

Filtering an EHR’s compositions

The compositions tab lists the EHR’s compositions newest first, with their template, context start time and composer, and narrows on four filters:

  • Template: matches anywhere in the composition’s template id.
  • From / To: bound the composition’s context start time. Each is a date and covers its whole UTC day, so a From and To of the same day keeps everything recorded during it.
  • Composer: matches anywhere in the composer’s name.

Every filter lives in the URL (?template=, ?from=, ?to=, ?composer=), so a filtered view is a link you can share or bookmark, a reload keeps it, and the browser’s back button walks the filters you tried. Applying a filter starts again at the first page. Leave them all empty for the plain list.

The filtering happens in the CDR, as one AQL query: what you type is bound as a query parameter, never pasted into the query text, so an id containing quotes, wildcards or anything else is matched literally. An empty result says whether the EHR holds no compositions at all or simply none that match.

Clicking a composition opens it in the viewer’s Rendered clinical reading (below) rather than the raw document; the other views are one click away.

Deleting an EHR

With the CDR’s admin API enabled, the EHR detail screen offers Delete EHR above the tabs. The confirmation dialog spells out the EHR id and what goes with it: this is the CDR’s physical delete, so every composition, contribution and audit record under the EHR is removed, and it cannot be undone. On success the viewer returns to the EHR list; a session without the ADMIN role is refused with a message naming what is missing. Without the admin API the button is not rendered at all.

Warning

Use this for test data. It is not the openEHR logical delete: nothing stays readable afterwards.

Creating EHRs and committing compositions

The EHRs screen can create an EHR (empty, or bound to an external subject: id + namespace) and find an existing one by subject id as well as by EHR id. The create card also takes an optional EHR id: leave it blank and the CDR mints one, or supply a UUID to create that exact EHR. A value that is not a UUID is refused before anything is sent (openEHR strongly recommends a UUID for a client-supplied EHR id), and an id that is already in use comes back as the CDR’s own conflict; nothing is silently overwritten.

The EHR detail screen’s compositions tab includes a Commit composition form: paste a canonical JSON, canonical XML, or FLAT document (FLAT requires the template id, sent as the openehr-template-id header) and the CDR’s validation diagnostics are shown verbatim on rejection.

The contributions tab opens with a contribution activity timeline: writes to this EHR per calendar day, bucketed from a wider window of the same contribution data the paged list below it shows.

Committing several changes at once

Each form above commits one thing. When changes belong together (a new composition and the EHR status that goes with it) the Commit tab commits them as one openEHR contribution: an atomic change set. Every staged change is committed together, or none of them is. Nothing is written halfway.

Build the change set one entry at a time. Each entry is one of three things:

  • Composition — create: pick the template and paste the canonical JSON document. This commits a brand-new composition.
  • Composition — amend: pick one of the EHR’s existing compositions. Its current version is loaded into the editor for you, and the change carries that version as the one it supersedes, so a concurrent write is refused rather than overwritten.
  • EHR status — modify: the EHR’s current status is loaded the same way; edit it and it commits as a new status version.

The change type offered for each entry is exactly what the openEHR wire accepts for it: a creation for a new composition, an amendment or a modification for anything that supersedes an existing version. The contribution audit below the list carries the change set’s description and, optionally, a committer name; leave it blank and your viewer identity is used. The commit button always states what it is about to do (“Commit 2 changes as one contribution”).

Note

Staged changes live in the open browser tab only. The viewer stores nothing of its own, so leaving the screen discards them, and nothing reaches the CDR until you press commit.

On success the tab names the new contribution and every version it created, and links straight to the Contributions tab, where the contribution opens with all of its versions. On refusal nothing at all was committed: the staging list is left exactly as it was, and the CDR’s own diagnostic is shown verbatim so you can correct the offending document and commit again.

Directory editing

The Directory tab creates the EHR’s FOLDER directory when none exists: it commits the empty root folder, which the tree editor then fills. There is no viewer-side library of folder shapes; the viewer stores nothing of its own, and every folder you build is an ordinary directory version the CDR owns and every other openEHR client can see.

Once the directory exists, the tab is a full structured tree editor: add, rename, and remove sub-folders at any node, and attach or remove OBJECT_REF items: a picker lists the EHR’s compositions, and a manual form covers arbitrary references. Edits accumulate locally until the sticky save bar commits them as one new version (If-Match concurrency: a concurrent change never silently overwrites; a conflict banner keeps your unsaved edits and offers an explicit reload-or-overwrite choice). An advanced mode still edits the canonical JSON directly.

The toolbar adds the read-side tools: version history (every directory version, read-only preview, one-click restore of an older tree), an At time lookup that resolves the directory as it stood at a chosen instant, a path query for one sub-folder, and the two-step directory delete (a logical delete: the history stays readable, and a new directory can be created afterwards).

Composition viewer

Any composition renders in canonical JSON, canonical XML, FLAT, or STRUCTURED. Switch freely; the CDR converts. The version dropdown walks the revision history, and each version’s audit (committer, time, change type) is shown alongside.

Every document pane in the viewer (the composition viewer, the EHR status tab, the directory raw mode, a contribution, a template’s OPT and example tabs) is the same viewer:

  • Highlighted (the default): the document exactly as the CDR returned it, with JSON and XML syntax highlighting. The highlighter is pure Rust, like everything else in the viewer; very large documents are shown unstyled rather than tokenized.
  • Raw: the same text with no highlighting.
  • Rendered: a template-free clinical reading of a canonical openEHR JSON document: RM section headings with their type and archetype node id, and one label/value row per ELEMENT (quantities with their units, coded text with its terminology code, a null-flavoured leaf saying so). It needs no operational template, so a composition whose template has since been removed still reads normally. The tab appears only for canonical JSON; bookkeeping (language, territory, category, uid) is folded away, and the raw views remain the complete record.
  • Copy puts the raw document text on the clipboard.

Which view a composition opens in is part of its link: ?view=rendered, ?view=raw or ?view=highlighted on the viewer’s URL. That is how the compositions tab’s rows land straight on the clinical reading, and it makes “open this composition the way I am looking at it” a shareable link.

Edit as new version opens the currently displayed canonical JSON in an editor and commits it as the next version (If-Match on the latest version, and a concurrent change is reported instead of overwritten).

A version timeline strip walks the revision history at a glance, and the At time picker resolves whichever version was current at a chosen moment (version_at_time).

A Versioned object card below the audit reads the versioned composition itself and the selected version directly: the versioned-object id, the owning EHR, when the object was first created, and (for whichever version the selector shows) its lifecycle state, its preceding version, the contribution it was committed under, whether it carries a signature, and whether it still carries content.

Tags on a composition

Below the versioned-object card, Tags lists the composition’s item tags (free key/value markers any openEHR client can attach) and sets or deletes one. Three things about them are worth knowing before you use them:

  • The panel edits the collection it names. The line under the heading says which one: with the version selector on Latest that is the versioned composition’s own collection; pin a version and the panel edits that version’s tags instead. openEHR keeps the two apart (a tag belongs to exactly one target) so a tag set on the container is not visible on any version, and vice versa.
  • Saving re-sends the whole collection, because that is what the openEHR tag update does. The viewer reads the current tags and merges yours in, so nothing is lost by accident, but the tag operations carry no version check at all, so a tag another client added between your load and your save can be. Reload before editing a busy composition.
  • A tag is identified by its key and target path together, so the same key on two different paths is two tags; deleting addresses the key alone and removes both.

A tag write is not a versioned write: it commits no contribution, mints no new version, and never appears in the revision history.

Deleting a composition

Delete composition on the viewer performs the openEHR logical delete of the composition’s latest version, with a confirmation dialog first. The CDR commits a deleted version on top of the current one: the composition stops resolving as current and leaves the EHR’s composition list, while every earlier version and the audit trail stay readable. It is a normal versioned write, so it needs no admin API, but it does need the version to still be the latest one: if it moved on since the screen loaded, the CDR refuses the delete and the message says to reload the history and retry.

Note

This is not the same operation as Delete EHR above, which is the CDR’s physical admin delete and leaves nothing readable.

The EHR detail’s contributions tab lists the EHR’s contributions (id, commit time, committer, change type) with the by-uid lookup kept underneath.

The commit form accepts canonical JSON, canonical XML, or FLAT:

EHR status

The Status tab renders the EHR’s current EHR_STATUS: the queryable and modifiable flags as badges, the subject, the version the document is, and the full document itself. A non-queryable EHR is called out: AQL over it returns nothing.

Below the document, Edit status changes the two flags and other_details:

  • tick or untick is_queryable to include the EHR in population queries (AQL), and is_modifiable to allow new content to be committed to it;
  • other_details takes a canonical-JSON ITEM_STRUCTURE (for example an ITEM_TREE); leaving it blank removes the attribute. A value that is not a JSON object is refused before anything is sent.

Saving commits a new EHR_STATUS version on top of the one the screen loaded, and every other attribute (the subject included) is sent back exactly as the CDR served it, so nothing the form does not show can be lost.

Note

The save is conditional on the loaded version. If another client committed a new status in the meantime, the CDR refuses the write and the viewer says so (“EHR status changed on the server”) instead of overwriting the change: reload the tab and reapply your edit. A rejected document keeps the CDR’s own diagnostic on screen, beside the form.

Tags on the EHR status

The Status tab ends with its own Tags panel, the same editor as the composition one. It always edits the versioned EHR status’s collection, so a tag stays put when you edit the status into a new version; the status tab has no version selector, and a tag pinned to a superseded version would quietly disappear. Saving re-sends the whole collection and carries no version check, exactly as on a composition.

EHR status history

The Status history tab is the versioned view of the same object: the VERSIONED_EHR_STATUS container and the selected version’s envelope facts (lifecycle state, preceding version, contribution, whether it is signed), the revision history newest-first, and a date-and-time lookup that resolves the version extant at that instant. Opening any row, or a resolved instant, shows that version’s EHR_STATUS document exactly as it stood at that commit.

Tags in this EHR

The EHR detail’s Tags tab is the whole EHR’s tag list in one place: every tag on every object under it (compositions, the EHR status, the directory) grouped by the object it sits on. Filter by key, value or target path; the filter lives in the address bar, so a filtered view is shareable and refresh-safe, and the shared footer pages the groups.

A tag names its target by identifier but not by kind, so each group’s Open asks the CDR which object holds that id before going there: a composition opens in the viewer, the EHR status and the directory open on their own tabs. If nothing in the EHR holds it any more (the object was deleted) the tab says so instead of guessing.

The container form and one version of the same object appear as two groups, because openEHR stores them as two separate collections.

Demographics

The Demographics screens work with the people, organisations, groups, agents and roles the CDR holds (openEHR calls all five of them parties) plus the relationships between them, the tags on them, and the commits that changed them. Everything here is a view of the CDR’s public API, so any change you make is an ordinary openEHR write that every other client sees.

Important

The openEHR demographic API is published in the development state within the REST release this server implements. It works, and this server implements it as specified, but the next openEHR release may change it, so treat these screens as less settled than the EHR ones, and expect the relationship surface in particular to move (see Relationships).

Finding a party

Pick a kind with the switcher across the top (People, Organisations, Groups, Agents, Roles) then open a party by its id.

There is no party list, and that is the API rather than a gap in the viewer: openEHR’s demographic API publishes no “list all people” endpoint, and AQL queries EHRs, not parties. A party is reached by its id. Two id forms work:

  • a versioned object uid (8849182c-82ad-4088-a07f-48ead4180515) opens the latest version;
  • a full version uid (8849182c-…::your.system::2) is reduced to the object it belongs to, because every screen here addresses the object.

Find-by-id is a plain form: it works before the page’s WebAssembly has loaded, and it works with JavaScript switched off entirely.

The one demographic collection the API does publish is the tag index, at the bottom of the screen; see Tags.

Creating a party

The create card opens with the smallest document that kind accepts. Every attribute in it is required by openEHR:

  • the party’s name, which openEHR uses for the party’s type (PERSON, ORGANISATION, …) rather than for a person’s name; the actual names live in identities;
  • an archetype_details block, and a root archetype_node_id equal to the archetype id inside it;
  • at least one identity;
  • for a role, a performer, the party playing it.

Replace the archetype ids and the identity details with the ones your own demographic archetypes use, then create. The document is sent exactly as you wrote it, so nothing the viewer does not display can be lost; if the CDR refuses it, the refusal is shown verbatim with the offending path.

On success the viewer opens the new party.

Reading and editing a party

A party opens on four tabs. The tab is in the address bar, so a link to a tab opens on that tab.

Party shows its facts (type, name, archetype, current version, how many identities and inline relationships it carries) the whole document, and the edit form. The document pane offers the same three views as everywhere else in the viewer (highlighted, raw, and a rendered reading) and a copy button.

The edit form changes exactly two things: identities (which openEHR requires, so it can never be emptied) and details (optional: clear the box to remove it). Everything else in the document travels back to the CDR byte for byte as it was served, so an edit can never silently drop an attribute this screen does not show. Saving commits a new version on top of the one loaded, and the CDR refuses the save if someone else committed in between, and you are told to reload and reapply rather than overwriting their change.

Delete party is above the tabs. It is openEHR’s logical delete: it commits a deleted version, so the party stops resolving as current while every earlier version stays readable in History. The dialog spells that out before anything is sent.

Version history

History walks the versions of one party (or one relationship):

  • the versioned object’s own facts, plus the selected version’s envelope: lifecycle state, preceding version, whether it is signed, and the contribution that committed it, linked to its own screen;
  • the revision history, newest first, each row opening that version;
  • an at a point in time lookup, which resolves an instant to the version that was current then and opens it;
  • the document exactly as it stood at the opened version.

The current party and its past versions come from different endpoints on purpose, which is why they live on different tabs: the Party tab is the one reader of “what this party is now”, and History never touches it.

Relationships

A relationship joins two parties, from a source to a target: an employment, an authority, a care relationship.

Note

Relationship endpoints are this server’s own extension. The openEHR release publishes no relationship API at all, so a different openEHR server will not serve them, and the viewer reports the resulting “not found” plainly rather than hiding it.

openEHR models a relationship in two ways, and both are visible here:

  • inside the source party. A party document carries the relationships it is the source of, and the party’s Relationships tab lists exactly those.
  • as its own object, with its own id, versions and history, the shape this server’s relationship endpoints serve.

The two are separate records; neither is a view of the other.

One consequence is worth knowing before you rely on the screen: the target side cannot be listed. openEHR defines “relationships pointing at this party” as a derived attribute, and the CDR does not populate it, so no request can answer “who is related to this person”. The tab says so where you would look for the answer. What you can always do is open a relationship by its own id, or follow it from either party it names.

Relate this party on the tab opens the create form with this party already filled in as the source. Give the relationship a type (openEHR stores it as the relationship’s name), name the target party and its kind, optionally add a details document, and create.

A relationship’s own screen shows both ends as links to those parties, its facts, an edit form for its type and details, its version history, and a delete, all with the same versioning behaviour as a party. The two ends are not editable: a relationship between different parties is a different relationship.

Tags

A tag is a free key/value marker a client can attach to a party: a follow-up flag, a migration marker, a local cross-reference. The party’s Tags tab lists what it carries, sets a tag, and deletes one.

Two openEHR behaviours shape the panel:

  • Saving a tag re-sends the whole collection, because that is what the openEHR tag update does. The viewer reads the current tags and merges yours in, so nothing is lost by accident, but a tag another client added between your load and your save can be. Reload before editing a busy party.
  • A tag is identified by its key and target path together, so the same key on two different paths is two tags. Deleting addresses the key alone and removes both.

The tag index at the bottom of the browser screen is the demographic space’s whole tag list, filterable by key, value and target path, with the filter in the address bar. It spans every kind, because a tag names its target without naming that target’s kind, which is why each row’s Open party asks the CDR where that id lives before opening it.

Contributions

Every write on these screens commits a contribution: openEHR’s record of one change set, with who committed it, when, and why. Open one from the contribution link on any version’s envelope in History.

The contribution screen shows those audit facts, the versions the change set touched (each linked to the party or relationship it belongs to) and the whole record. It is read-only: contributions are made by writing parties, not by authoring change sets here.

Terminology

The Terminology screen browses the coded vocabularies the CDR can answer questions about: which terminologies it serves, what a code means, which codes a value set holds, whether a given code is one of them, and whether one code subsumes another. Everything on it is a read of the CDR’s public API; nothing is stored on the viewer’s side.

Note

The terminology API is this server’s own extension. openEHR’s REST release publishes no terminology contract, so the operations come from the openEHR Service Model (I_TERMINOLOGY_SERVICE) while the URLs and JSON envelopes are this server’s. A different openEHR server will not serve them.

Switching it on

The surface is opt-in and off by default. While it is off the CDR answers its terminology routes as if they were not mounted, and the screen says so in one card instead of pretending to work:

[terminology]
api_enabled = true

or, as an environment override, FERROEHR__TERMINOLOGY__API_ENABLED=true.

Turning it on exposes only lookups over terminologies the server already holds: the bundled openEHR terminology plus the external code sets beside it. Binding an external FHIR terminology server is a separate setting, and it changes what these lookups can answer; both are covered in Terminology servers.

Picking a terminology

The list on the left is exactly what the CDR reports: the internal openehr vocabulary and the external code sets beside it (languages, countries, character sets, media types). Selecting one puts it in the address bar, so a terminology is shareable and survives a reload, and the choice works before the page’s WebAssembly has loaded.

The Descriptor card beside it shows what the CDR publishes about that terminology: publisher, identifying URI, available versions, and the meta-model attributes an extract request may ask for. Fields the server does not publish are not shown at all rather than filled in with a guess.

Defining a term

Type a code and the screen asks the CDR what it means. A defined term comes back as code — text, with the language it is written in and whether it is the preferred term among alternatives; a code the terminology carries without any display text comes back as the bare code, which is the honest answer rather than a blank.

An effective date is optional. Supplied, it asks for the definition as it stood on that date; left empty, it asks for the current one. The openEHR bundle is a single pinned release, so a date does not change its answer; an external terminology server can.

A code the terminology does not define is reported as a plain note on the card that asked, naming the code and the terminology. It is not an error: asking whether something exists is a legitimate question, and “no” is an answer.

Note

openEHR terminology codes are scoped to their group, not global: 532 is complete in one group and completed in another. A code lookup treats the openehr terminology as flat and returns the first group’s rubric, so use the value-set card below whenever the group matters.

Expanding a value set

A value set is addressed by its id: for the openehr terminology, an openEHR vocabulary group such as audit_change_type or version_lifecycle_state, by its identifier or its display name. Expanding one lists its members as code — text.

Under it, Validate answers one question: is this code a member of that value set? Both verdicts read as a sentence, and a value set the CDR does not know simply has no members, so a code is reported as not a member rather than the question being refused.

Testing subsumption

Subsumption asks whether one code is an ancestor of another. The test is strict, so a code never subsumes itself, and the openEHR vocabulary is flat (it defines no is-a hierarchy) so the honest verdict for any pair of openEHR codes is “does not subsume”. Hierarchical answers come from an external terminology server when one is bound.

Picking codes in the Query Builder

The same lookups back the Query Builder’s coded condition editor, so a coded criterion no longer has to be typed from memory; see Dashboard & queries. The model is unchanged: whatever route a code took into the criterion, the query carries the bare code.

Operations panel

The Operations screen is the viewer’s operator view of a running CDR: dependency health, what exactly is deployed, the live metric registry, and runtime log control. Everything on it comes from the CDR’s own operational endpoints over HTTP; the viewer has no privileged channel.

When it appears

The panel is probe-and-hide: on every page load the viewer asks the CDR for GET /management/info, and the sidebar entry appears only if that endpoint exists. A 404 (the CDR’s answer when the management surface is off, which is the default) hides the entry entirely; any other answer counts as present, so a refusal reaches you as a message on the card that asked rather than as a missing screen.

To get the panel, enable the surface on the CDR and give each endpoint you want an access level (Operations → The management surface). Every endpoint ships off, so nothing is mounted until you name it:

[management]
enabled = true

[management.endpoints]
info = "admin_only"     # the availability probe — enable it alongside the rest
metrics = "admin_only"
env = "admin_only"
loggers = "admin_only"

The levels are off (not mounted, 404), private (any authenticated principal), admin_only, and public (served outside authentication). A card whose endpoint is off says so in place instead of failing, but info is the probe, so leaving info at off hides the whole panel.

If the CDR serves management on its own internal listener (management.port) or under a renamed base path (management.base_path), point the viewer at it with one setting, the full prefix, including the path:

FERROEHR_VIEWER__CDR__MANAGEMENT_BASE_URL=http://cdr.internal:9100/management

Unset, the viewer derives {cdr.base_url}/management.

Note

The management endpoints are gated server-side at the level you chose. The viewer shows the panel whenever the surface exists; being allowed to read a particular endpoint is the CDR’s per-request decision, and a refusal is reported on the card that asked, naming what to do about it.

Dependency health

The health card reads the CDR’s public readiness probe (GET /health/readiness, always served, no configuration): the aggregate state plus one row per dependency the server checks (the database ping, the migrations probe, and any optional components) with the CDR’s own detail text where it gave one.

This is deliberately a different question from the status pill in the topbar, which polls the product status document (GET /ferroehr/rest/status):

ReaderQuestion it answers
topbar pillis the API answering at all, at which version, and under which deployment profile?
health cardare the CDR’s dependencies healthy enough to serve?

The card states that split on screen, and nothing else in the viewer re-reads either claim.

Build & spec provenance

Straight from GET /management/info: the CDR’s version, the git commit the binary was built from, its build timestamp and rustc version, the PostgreSQL target, the audit posture the deployment runs (whether auditing is on, its fail_mode, the local store and the effective retention), and under their own heading the deployment’s active spec_profile with the openEHR specification versions it selects. This is the card to screenshot into an incident report: it says exactly what is running.

Metrics

Four headline tiles (in-flight requests, compositions committed, AQL queries, database connections in use) sit above a browser over the CDR’s whole metric registry: pick a metric, and the panel renders its current samples with their labels: the same numbers Prometheus scrapes, without a Prometheus.

The selection lives in the URL (/operations?metric=db_pool_connections), so a view is shareable and survives a refresh, and the picker works before the browser app has loaded. A tile reads when the deployment records nothing for that metric yet.

For dashboards and alerting, scrape GET /management/prometheus instead; the panel is for looking, not for collecting.

Runtime configuration

The CDR serves its redacted effective configuration on both its management surface and its admin API, and it is the same snapshot, so the viewer reads it in exactly one place: the System screen. This card links there rather than rendering a second copy.

Log level

The log card shows the filter in effect right now and the boot filter a reset restores, and lets an operator change the live filter without a restart (POST/DELETE /management/loggers):

Type tracing-style directives (ferroehr=debug,sqlx=warn), press Apply filter, and confirm: the dialog spells out that logging changes immediately for every request the instance serves. Reset to boot filter puts the startup value back. Both outcomes are reported as a toast, and the card re-reads the CDR’s answer, so what you see is what the server confirmed, not what was asked for.

Important

A log-filter change applies to that CDR instance only and lasts until it restarts. Behind a load balancer, each instance is set separately; for a permanent change, set log.filter in the server configuration.

FHIR connector

The FHIR screen administers the CDR’s FHIR mapping store (the definitions that translate between FHIR resources and openEHR compositions) and gives you two ways to check a mapping does what you meant, without writing anything. Everything on it comes from the CDR’s own FHIR API over HTTP; the viewer has no privileged channel and keeps no mapping state of its own.

Note

No openEHR specification governs FHIR interoperability; the connector is FerroEHR’s own extension, and its wire vocabulary follows HL7 FHIR R4. What the connector does with a mapping, and every status code it answers, is described in FHIR connectors; this page is about driving it from the viewer.

The viewer never sends a resource for real

The connector’s inbound door (POST /fhir/r4/{type}) maps a FHIR resource, validates it, and commits it as an openEHR composition. That is an integration act (something a sending system does, with its own credentials and its own audit trail) so the viewer deliberately offers no path to it. You cannot ingest a resource from this screen, by design.

What the viewer does offer is the two read-only ways to verify a mapping:

  • the read path, which shows what a stored mapping produces when openEHR data is read back out as FHIR;
  • the dry run, which runs a resource through the whole ingest pipeline including validation and reports the verdict, committing nothing.

When it appears

The screen is probe-and-hide: on every page load the viewer asks the CDR for GET /admin/fhir_mapping, and the sidebar entry appears only if that route exists. A 404 (the CDR’s answer when the connector is off, which is the default) hides the entry entirely; any other answer counts as present, so a refusal reaches you as a message on the screen that asked rather than as a missing screen.

To get the screen, turn the connector on ([fhir]):

[fhir]
api_enabled = true

Reaching /fhir on a deployment without it is not an error either: the screen renders one card naming that switch instead of a store that cannot be read.

Note

The mapping store is mounted under /admin, so the CDR’s role-based access control classes every call here as admin work. The screen renders whenever the surface exists; being allowed to use it is the CDR’s per-request decision, and a session without the ADMIN role is refused with a message naming what is missing.

The mapping store

The table lists every stored mapping with its name, the FHIR resource type and profile it binds, the openEHR template it builds under, whether it is enabled, and its store id, newest first, paged by the shared footer under the table (see Paging).

A mapping is edited as a JSON document, and that is deliberate. The definition is a deep, open-ended structure (the subject binding, the commit context, and one entry per mapped field with its own path and transform) whose shape the CDR owns. A form built out of boxes here would be a second model of it that drifts the moment the connector grows a field, so the viewer sends the document you wrote, verbatim, and shows the CDR’s answer, verbatim. The definition’s shape is documented under Mappings are data you manage.

  • Store a mapping with the card above the table: a name and the definition document. The button stays disabled until the name is addressable (letters, digits, _, . and -) and the definition parses as a JSON object; that much the viewer checks before spending a round trip; everything else is the CDR’s judgement, and its rejection is shown in full beside the failure notification. The most common one on a fresh deployment is an unknown template_id: upload the operational template first.
  • Enabled decides whether the connector resolves the mapping at all. A disabled mapping stays stored and editable but takes part in nothing, the way to retire a mapping without deleting it.
  • Edit on a row opens the document in an editor, seeded with what is currently stored. The name is not editable: the CDR treats it as the mapping’s deployable identity, so a rename is a new mapping.
  • Delete on a row asks for confirmation naming the mapping, and nothing is sent until you confirm there. Deleting a mapping stops the connector accepting and serving that resource type; data already committed through it is untouched, and stays readable through the openEHR API as normal.

Every one of those writes reports both outcomes, and a success and a failure are equally visible, so a refused change never looks like nothing happened.

Warning

Two enabled mappings for the same resource type resolve by meta.profile: an exact profile_url match wins, and a mapping with no profile is the type’s default. A resource that declares no profile only ever matches the default, so a store with profile-scoped mappings and no default answers 404 for plain resources.

Read path

Enter a resource type and a patient, then Read. The viewer calls the CDR’s read façade (GET /fhir/r4/{type}?patient=…) and shows the FHIR Bundle it answers with, each entry produced by running a stored mapping in reverse over a committed composition.

The scope lives in the URL, so a read is shareable, survives a reload, and works before the page’s WebAssembly has loaded. Both fields are required: the façade serves this explicit scope only, never a general FHIR search.

Two answers are worth recognising:

  • An empty Bundle ("total": 0) means the mapping resolved and nothing is stored for that patient: an answer, not a failure. Check the patient identifier against the mapping’s subject binding: the connector strips the configured prefix (Patient/) and matches the remainder in the configured namespace.
  • An OperationOutcome is the connector refusing the read: an unsupported resource type, a missing scope. It is rendered exactly as the CDR wrote it, inline, because its diagnostics text is the answer to what went wrong.

Dry run

Paste a FHIR resource, name its type, and Validate. This calls the CDR’s $validate operation, which runs the full ingest pipeline (mapping resolution, the composition build, the provenance stamp, and the same validation the real commit runs) and then throws the result away.

Nothing is committed: no EHR, no composition, no version. The target EHR is resolved and reported, never created.

The panel reports one of three verdicts, and the difference matters:

VerdictWhat it means
ValidThe validation ran and the resource maps to a composition the CDR would accept. The outcome names the resolved template and says whether the commit would land in an existing EHR or create one.
InvalidThe validation ran and the CDR would refuse the mapped composition. The outcome carries the openEHR validator’s own rejection, the exact text a real ingest would fail with.
Not validatedNo verdict was reached at all: no mapping matches the type, the type is outside the connector’s set, or the resource could not be mapped. The outcome says which.

The full OperationOutcome is shown under the verdict either way. This is the loop mapping development is meant to run in: edit the definition above, dry-run a real sample resource, read the rejection, repeat, and only point the sending system at the real ingest door once the verdict reads valid.

Event subscriptions

The Subscriptions screen administers the CDR’s event subscriptions: the server-side filters that decide which committed versions are published to your message broker, and to which queue. Everything on it comes from the CDR’s own subscription API over HTTP; the viewer has no privileged channel and keeps no subscription state of its own.

When it appears

Change eventing is an extension: no openEHR specification governs event publication or a subscription resource, so the whole surface (its paths, its payloads, its status codes) is this CDR’s own design, and it is off unless a deployment turns it on. See Change events (AMQP) for what the stream carries and how a queue is bound.

The screen is probe-and-hide: on every page load the viewer asks the CDR for GET /admin/event_subscription, and the sidebar entry appears only if that route exists. A 404 (the CDR’s answer while the subscription API is off, which is the default) hides the entry entirely; any other answer counts as present, so a refusal reaches you as a message on the screen that asked rather than as a missing screen.

To get the screen, turn the subscription API on ([events]):

[events]
admin_api = true     # mount /admin/event_subscription

Reaching /subscriptions on a deployment without it is not an error either: the screen renders one card naming that switch instead of a table that cannot be read.

Note

admin_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 four 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. The viewer says so in every cell (an unset predicate reads any, never a blank) and each row carries a plain-words line saying what it selects, so “matches every committed version” is visible rather than inferred from four empty boxes.

The name is unique on the CDR and is also the suffix of the queue the server declares for the subscription (ferroehr.events.<name> with the default exchange), so it may hold only letters, digits, and _, . or -. The create button stays disabled until the name you typed is one the CDR can accept, and the name cannot be changed afterwards; a rename would be a different queue.

State is the enabled flag: an enabled subscription is one the server binds and delivers to; a disabled one is kept, exactly as you defined it, and not delivered. Disabling is therefore the reversible way to stop a feed.

Administering them

The table lists every stored subscription, newest first, paged by the shared footer under the table (see Paging).

  • Create with the card above the table: a name, and as many predicates as you want to narrow by. Leave them all empty for a feed of everything.
  • Edit on a row opens an editor seeded with that subscription’s current values. Saving replaces every predicate, so a field you clear becomes any on the CDR: that is the whole update, not a patch. The name is shown and never editable.
  • Delete on a row asks for confirmation naming the subscription and what it matches, and nothing is sent until you confirm there.

Every one of those writes reports both outcomes, and a success and a failure are equally visible, so a refused change never looks like nothing happened, and the CDR’s own words (a duplicate name, a rejected value) are shown in full beside the failure notification.

Warning

Deleting a subscription stops the server binding its queue; it does not delete the queue from your broker. A durable queue left behind keeps whatever it already holds and stops receiving new messages, so reap orphaned queues on the broker side as part of the same change.

Dark mode

Every viewer screen is themed twice, and the pictures below are the proof: the same screens the rest of this chapter documents, captured with the topbar’s dark-mode toggle on. The choice persists per browser (localStorage), so it survives a reload and follows you across screens; it is a display preference and nothing else: no screen, control, or permission changes with it.

Dashboard

Templates

Query Builder

EHRs

Demographics

Terminology

Operations

System

Audit log

Subscriptions

FHIR connector

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 and EXECUTE on the ext helper functions, and nothing else: it is not a superuser, does not bypass row-level security, and cannot create, alter or drop a table, index, schema or role. On the audit trail it is narrower still: it may record an event and stamp it forwarded, and it holds no privilege that can rewrite or remove one (see Audit).

Turning the schema split into a role split

The schema separation is unconditional: the server always reads and writes parties in party, whatever it authenticates as, and each of the four pools carries only its own schema on its search_path. The role separation is a deployment choice, and it is one configuration table per domain:

[storage.party]
url = "postgres://ferroehr_party:***@pg:5432/ferroehr"

[storage.linkage]
url = "postgres://ferroehr_linkage:***@pg:5432/ferroehr"

(or url_file, for a mounted secret). With a domain’s url set the server opens that pool on that credential, and a flaw that reaches one of them reaches one domain. Left unset, the domain uses [db].url and the separation is schema-only. linkage holds which party is the subject of which EHR — the one map that re-joins the other two — so ferroehr_linkage is barred from both of them and both of them from it. Set every domain and no credential the server uses can perform that join in SQL; the crossing happens in the application, over two connections, and is recorded as an access event (see Security → Resolving across the boundary).

A DSN that names another host moves the domain to a database or cluster of its own, which is the separation a schema cannot make: a base backup, WAL archiving and physical replication carry every schema of a database together (PostgreSQL 18, Backup and Restore). One constraint: the linkage migration set revokes a function the party set creates, so those two are prepared in the same database, and a layout that splits them is refused at boot with the remedy rather than failing partway through a migration.

Two boot checks make the posture honest. Two domains configured on different DSNs that authenticate as the same database role are refused — a separation that exists only in the configuration reads as one that holds, which is worse than none. And a domain role that does not exist is a warning under deployment_profile = "sandbox" and a refusal under production: absent roles mean absent grants, and the boundary check would otherwise pass by having nothing to measure.

Which credential prepares the schema

None of the domain credentials. Preparing a database spans every schema in it at once: the DDL of each resident migration set under db.migrate = "apply", and their _sqlx_migrations bookkeeping tables under "verify" — a read, but a read across the whole database. Each runtime role holds exactly one domain, so none can do it, and verify is not the exception: a role that is a member of ferroehr_clinical and nothing else is refused on the very first set: it cannot read the ext, party, linkage or audit bookkeeping.

So the credential that prepares the schema is named separately, and the server uses it for that one boot step:

[db]
url = "postgres://ferroehr_clinical:***@pg:5432/ferroehr"
migrate_url = "postgres://ferroehr_migrator:***@pg:5432/ferroehr"

[storage.party]
url = "postgres://ferroehr_party:***@pg:5432/ferroehr"

[storage.linkage]
url = "postgres://ferroehr_linkage:***@pg:5432/ferroehr"

migrate_url prepares every domain that reaches the same DATABASE it does, which is all of them in the posture above: three credentials, one database. A domain whose DSN reaches a DIFFERENT database is prepared on its own DSN instead, because migrate_url names one database and a relocated domain is not in it. Which case a domain is in is read from the server itself (pg_control_system() and current_database()), never from the DSN text — two DSNs that differ only in their credential reach the same database.

(or migrate_url_file, for a mounted secret). The connection is opened for preparation and closed again: no pool is held on it, and no request is ever served through it. Left unset it falls back to url, which is exactly what a single-credential deployment has always run — its behaviour is unchanged by this key existing.

A credential that cannot read a set is told which schema and which role, so the fix is visible from the message:

database role `app_ehr` cannot read the migration state of schema
`ext`: … point `[db] migrate_url` (or `migrate_url_file`) at the credential
that prepares the schema — unset, it falls back to `[db] url`

Either way the server refuses to boot when the grants themselves are wrong: a self-check enumerates every table, view, sequence and function in each domain and fails, naming the role and the object, if either runtime role can read across the boundary. ferroehr db verify runs the same check. When the five roles do not exist at all (the development, compose and test-harness case, where the migrator holds no CREATEROLE) the check warns rather than inventing a failure — under deployment_profile = "production" it refuses, because absent roles mean absent grants and there is then nothing to measure.

On Kubernetes the same choice is chart values, mounted as files the same way the clinical DSN is, so no credential enters the pod’s environment:

database:
  existingSecret: ferroehr-db                 # postgres://ferroehr_clinical:…
  party:
    existingSecret: ferroehr-db-party         # postgres://ferroehr_party:…
  linkage:
    existingSecret: ferroehr-db-linkage       # postgres://ferroehr_linkage:…
  audit:
    existingSecret: ferroehr-db-audit         # the audit repository's own DSN
  migrateExistingSecret: ferroehr-db-migrator # postgres://ferroehr_migrator:…

Leave a domain’s block unset and that pool uses the shared DSN, which is the schema-only posture.

Provisioning the five roles is yours in both cases. The migrations create them only when the migrator holds CREATEROLE and skip them with a NOTICE otherwise, so on a managed database — where the migrator usually does not — create them before the first deploy:

CREATE ROLE ferroehr_clinical NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_party NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_clinical_reader NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_party_reader NOLOGIN NOINHERIT;
CREATE ROLE ferroehr_linkage NOLOGIN NOINHERIT;

then give each login role membership of exactly one of them. ferroehr db verify tells you whether the boundary holds afterwards.

The local Audit Record Repository is written on its own pool ([storage.audit]), and the audit schema is granted to ferroehr_clinical (record an event, stamp it forwarded, run the retention reaper, verify the chain) and to ferroehr_clinical_reader (read it and verify the chain). A clinical login role that is a member of ferroehr_clinical alone writes its own access log; no extra membership is needed. The audit trail is not a pseudonymisation domain, so this grant adds no reach into party or linkage.

The compose stacks create all five and grant the clinical and party domains to the single dev login role, which owns the database. That demonstrates the schema separation and exercises the boot self-check; it is deliberately not the credential separation, because one container with one DSN cannot show that half honestly.

Which of these postures is exercised, and which is not

A recommendation nothing runs is a guess. This is what the suites cover.

Exercised against a real PostgreSQL 18:

  • Two runtime credentials serving both domains through the assembled router, each of the server’s own pools refused every relation of the other domain (app/ferroehr-rest/tests/it/credential_separation.rs), and the single-DSN fallback still serving both.
  • Three runtime credentials across the whole identity-to-party-to-EHR crossing, with the linkage credential refused the identifier map (app/ferroehr/tests/it/pseudonymisation_boundary.rs).
  • All five domain roles refused every relation in the domains they do not own, enumerated from information_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 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 domain actually relocated to another database, as opposed to a second credential on the same one. The layout, the per-database preparation and the refusals are covered in process; no suite runs two PostgreSQL databases.
  • A real deployment on separated DSNs: a container booting with the DSN files mounted, and the chart wiring them through database.party.existingSecret, database.linkage.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

{"status":"DOWN","components":{"db":{"status":"UP"},
 "migrations":{"status":"DOWN","detail":"core schema tables missing (migrations not applied)"}}}

on /health/readiness (503), leaves the load balancer’s rotation, and keeps passing liveness (correctly, since the process is healthy) so nothing restarts it. Under Kubernetes that is a Deployment sitting 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.

Recovering a partially wiped database

A wipe that removes some of the server’s schemas is not a fresh start, and the server refuses to migrate over one rather than doing something plausible with it.

The server owns five schemas: ext, clinical, party, linkage and audit. A fresh start drops all five. Each domain’s cold archival tier is a partition of the relation it archives, inside that domain’s own schema, so a DROP SCHEMA clinical CASCADE takes the archived rows with the live ones. There is no separate mirror schema left to survive a wipe of the tier it mirrors.

Dropping the clinical schemas (clinical, ext, audit) while party and linkage survive is a supported reset: the clinical migration set rebuilds its schema on the next boot.

A database written by a release before the storage rewrite is refused outright:

this database predates the storage rewrite: schema `ehr` carries its own
migration bookkeeping, which only a release before the rewrite wrote.

The new migration sets replace the old ones rather than upgrading them, so there is no in-place path. Dump anything worth keeping, recreate the database, and start the server against it.

Tip

When you wipe a FerroEHR database deliberately, drop the database, not a schema. DROP DATABASE cannot leave half a repository behind, and it is the only wipe with no partial-state failure mode.

TLS and database security

These are database-side settings that belong to whoever provisions PostgreSQL; the deployment references them but cannot enforce them:

  • TLS in transit. Require 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.

# The clinical domain
pg_dump --dbname="$CLINICAL_DSN" --format=custom --no-owner \
  --schema=clinical --schema=ext --schema=audit \
  --file=/backups/clinical/clinical-$(date -u +%Y%m%dT%H%M%SZ).dump

# The identities, into a different directory, owned by a different group
pg_dump --dbname="$PARTY_DSN" --format=custom --no-owner \
  --schema=party \
  --file=/backups/party/party-$(date -u +%Y%m%dT%H%M%SZ).dump

# The party-to-EHR map, into a third directory with the narrowest audience
pg_dump --dbname="$LINKAGE_DSN" --format=custom --no-owner \
  --schema=linkage --extension=btree_gist \
  --file=/backups/linkage/linkage-$(date -u +%Y%m%dT%H%M%SZ).dump

Important

--extension=btree_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 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.

Warning

There is no automated rotation command yet. Until there is, the rotation is a scripted job you run against the demographic database with both keys available, and this page is the procedure it has to follow. Treat the absence of a command as a reason to rehearse the rotation on a copy first.

The subkeys are derived from the root key rather than stored, so rotating the root rotates all of them together. The pseudonymisation domain is part of that derivation: a subkey derived for the clinical domain opens nothing in the party one, so the per-schema backups above are separate artefacts under separate keys even where one root key is configured.

The container image and pod hardening

The published image is distroless and non-root (shell-less, with no package manager) and is multi-architecture (amd64 and arm64) on GHCR. It is not a static binary: the server links glibc and libgcc_s dynamically, which is why the base image is the cc distroless variant rather than the smaller one, and why the runtime needs no OpenSSL, no JVM and nothing else from a package manager.

Under Kubernetes the pod runs the Kubernetes restricted profile: non-root uid/gid 65532, read-only root filesystem with one writable /tmp, no privilege escalation, an empty capability bounding set, RuntimeDefault seccomp, and no service-account token (the workload never calls the Kubernetes API). The chart’s own gates assert that per container on every render, and the posture read back from a running container, plus the two things the chart cannot do for you (applying the namespace enforcement label, and narrowing the NetworkPolicy’s ingress sources: the shipped policy narrows the ports and, until you set networkPolicy.ingressFrom, admits every source on them; set networkPolicy.ingressAllowAll: false, and viewer.networkPolicy.ingressAllowAll for the viewer, to have that state refused at render instead), are covered in Installation → The workload: security context & admission and Namespaces, network & policy → Ingress.

The Compose path carries a comparable floor, and one guard keeps it: scripts/checks/compose-hardening.sh runs over every committed compose artifact and fails on a service definition without cap_drop: [ALL] and no-new-privileges:true, on any privileged: true, on a seccomp=unconfined or apparmor=unconfined override, on a mounted Docker daemon socket, and on a published port that names no host address. The compose files go further than the guard checks (capabilities are added back one at a time only where an entrypoint provably needs them, file-descriptor limits are bounded, ports default to the loopback interface, and most services run a read-only root filesystem) but those extras are conventions, not enforced properties, so check them when you adapt a file.

Two services in the quickstart file deliberately do not run read-only, and each says why in place. The S3 gateway writes its volume store; the server cannot, because Compose refuses an inline config in a read-only service and that inline config is what makes the file standalone. A deployment that mounts its configuration from a file instead can add read_only: true and a tmpfs at /tmp.

What the host owes, and we cannot enforce

Four controls belong to whoever runs the daemon. They are stated here because a container hardening story that ignores them is misleading: the strongest pod security context in the world sits on top of these.

Keep the host kernel and Docker Engine current. A container is a kernel namespace, not a virtual machine: a kernel privilege-escalation bug is a container escape, and every runtime hardening above assumes the kernel enforcing it is patched. Track your distribution’s kernel updates and the Docker Engine release notes with the same urgency you would give a public-facing service.

Prefer rootless mode. Running the daemon as a non-root user means a container escape lands as an unprivileged user rather than as root on the host (https://docs.docker.com/engine/security/rootless/). The images here need no privileged operation, no host networking, and no daemon socket, so nothing in this deployment prevents rootless: the constraints are usually the host’s (cgroup v2, newuidmap/newgidmap, and no privileged ports below 1024, which is why the server binds 8080 rather than 80).

Set the daemon log level to info (the default) and keep it. Docker’s debug level records request payloads and can put secret material into daemon logs, which are typically world-readable to anyone with host log access and are shipped wholesale to log aggregators.

Control who can pull and push your images. GHCR access is the deployment’s authorization boundary for what runs in production: whoever can push a tag your manifests reference can run their code with your database credentials. Restrict push rights, prefer digest pins over mutable tags for anything you deploy (the compose files pin the third-party images by digest for exactly this reason), and verify the published attestations before rollout; the verification command is in Installation → Kubernetes & Helm.

None of these four is something this project can assert on your behalf, which is why they are written as your checklist rather than as our claim.

Upgrades

  • Upgrade in place; the migrations are append-only. A released migration is never edited again, so a database created by an earlier release takes the new release’s migrations on top of the ones it already carries. The server records the checksum of every applied migration and refuses to start against a database whose recorded checksums differ from the files it carries (migration 1 was previously applied but has been modified), which is why editing one would lock every existing installation out of its own database rather than revise its history. A correction ships as a new migration, and a CI guard fails any pull request that modifies, renames or deletes one the base branch already has. A rolling upgrade must still stay compatible with the previous schema for the window in which both versions run: additive changes first, destructive changes a release later.

    Releases up to and including 4.0.18 predate this policy and did edit migration files in place. A database created by one of those and never upgraded since is recreated rather than migrated.

  • Sweep for stale decomposition after a release that changes how content is decomposed. The version body is unaffected and every read serves it correctly; what is stale is the decomposed index over it, which a row-level query predicate depends on. POST {base}/admin/integrity/verify reports each such version as stale_decomposition and POST {base}/admin/integrity/rebuild-nodes, run once unscoped, rewrites the rows from the stored document. The changelog names the affected object type at each release that needs it; 4.2.0 is one, for demographic parties. Both routes are on Admin & messaging APIs.

  • Bound the DDL yourself. Every pooled connection carries the db.statement_timeout_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, because it is short and it is deliberate:

  • Only the most recent release receives security fixes. There are no maintenance branches, no long-term-support line, and no backports. A version stops receiving fixes the moment a newer release exists.
  • A fix normally arrives as the next patch on the current minor, so taking it does not oblige you to take new behaviour, but that is the usual case, not a promise. Where a fix is only correct alongside a behavioural change, the release carrying it carries the change, and the changelog entry says so.
  • A published release is never repaired in place. Release immutability means the assets and the tag of a published release cannot be modified at all, so the remedy for any defect is a new version.
  • The Helm chart and the published crates follow their own version lines, each supported at its newest published version only. A chart-only fix ships as a new chart version between server releases.

The consequence for change control: budget for taking every release, or budget for maintaining a fork. There is no third option, and the full policy (with the reasoning and what to do if you need something stronger) is SECURITY.md.

Observability

tracing is the single instrumentation API. From it, three signal families fan out, and identified data never enters any of them: telemetry uses only closed-set labels and opaque request/trace ids, so correlation to a patient is possible only through the audit trail.

  • Logs go to stdout (JSON when not attached to a terminal, pretty on a TTY) each line stamped with the trace and span id. Shipping and rotation are the platform’s job. FERROEHR__LOG__FORMAT (auto/json/pretty) and FERROEHR__LOG__FILTER (or RUST_LOG, default info,ferroehr=info) control them, and the level can be changed at runtime through the loggers endpoint below. On boot the server prints a one-time ASCII banner (version, maintainer, project URL, and spec pins) to stdout ahead of the logs; it is suppressed under FERROEHR__LOG__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 an endpoint; with none set, the tracing layer is not installed at all (zero overhead). Root spans are named by route template, never by a path containing ids.

  • Metrics come from one OpenTelemetry meter provider with up to two readers: a Prometheus reader behind /management/prometheus, and (when telemetry.metrics_push is on) a periodic OTLP reader. Every instrument reaches both by construction, so a family can never exist on the scrape surface and be missing from the push. The catalogue covers HTTP request duration, active requests and body sizes; authentication failures and authorization decisions (Cedar and remote-PDP); database pool state, acquire latency and transaction counts; AQL query counts, latency and plan- cache events; compositions committed (by openEHR audit change type); validation failures and version-signature faults; WebTemplate cache events; events published; the whole ATNA audit pipeline; Tokio runtime gauges; process start time; and 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 (layer not installed)OTLP collector endpoint
FERROEHR__TELEMETRY__SERVICE_NAMEferroehrreported service name
FERROEHR__TELEMETRY__ENVIRONMENTdevreported deployment environment
FERROEHR__TELEMETRY__TRACES_SAMPLE_RATIO1.0head sampling ratio (start at 0.1 in production)
FERROEHR__TELEMETRY__METRICS_PUSHfalseadd the periodic OTLP metrics reader beside the Prometheus one
FERROEHR__TELEMETRY__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 both otlp_endpoint and metrics_push. A server with neither still records every instrument; nothing exports it.

Tip

A single-container dev stack (grafana/otel-lgtm, bundling an OTLP collector, Prometheus, Tempo, Grafana, and Loki) ships as a Compose overlay, together with a provisioned Grafana dashboard (request rate/errors/duration, database pool, AQL latency, validation failures, audit health) and a starter alert pack; point the server at it with the two OTLP variables above.

On Kubernetes, the same keys arrive through the chart’s config passthrough, and the metrics half has a second switch that is easy to miss:

# values.yaml
config:
  telemetry:
    otlp_endpoint: http://otel-collector.observability:4317
    environment: production
    traces_sample_ratio: 0.1
    metrics_push: true
  management:
    enabled: true
    endpoints:
      prometheus: admin_only   # the scrape endpoint is off until you name it
metrics:
  enabled: true                # adds the prometheus.io/* pod annotations
  serviceMonitor:
    enabled: false             # or true, with the Prometheus Operator CRDs installed

metrics.enabled only adds the scrape annotations; the endpoint itself is opened by config.management.endpoints.prometheus. Both are needed for an annotation-discovering Prometheus, and neither is needed if you push over OTLP instead. To turn telemetry off, drop otlp_endpoint; the tracing layer is not installed at all when it is unset.

The default dashboard. The “FerroEHR — service overview” Grafana dashboard (request rate/errors/latency, AQL rate and phase latency, plan-cache hit ratio, database pool, Tokio runtime, audit throughput — every query written against the served metric names) reaches Grafana three ways:

  • Compose: the observability overlay provisions it automatically; nothing to do.
  • Kubernetes: set metrics.grafanaDashboard.enabled: 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 admin and messaging APIs

Two operator-facing HTTP surfaces have a page of their own, because each route carries its own switch, authorization class and status-code contract:

  • The admin API ({base}/admin/…, off by default): physical deletion, the activity report, archiving to the cold tier, and whole-repository dump and load.
  • The messaging API ({base}/message/…, always mounted): EHR Extract export and import, and Template Data Document import.

The full reference is Admin & messaging APIs.

Warning

Enabling the admin API puts irreversible, whole-repository operations on the wire: DELETE {base}/admin/ehr/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 /healthconstant 200 OK (plain text OK), touches nothingload balancers, docker HEALTHCHECK, anything that must never be auth-gated
GET /health/livenessidentical to /health, the same constant answer under the orchestrator-conventional pathKubernetes livenessProbe and startupProbe
GET /health/readiness200 when the aggregate is up or degraded, 503 when a required component is down; JSON body with every indicator, each bounded to one secondKubernetes readinessProbe, ops dashboards
GET /ferroehr/rest/statusproduct status document: status, server_version, openehr_rest_api_version, timestamp, licence (the grant in force: state, use, licensee, not_after, configured_token) and deployment (the declared profile, the separations still open and the ones accepted by name)version/identity checks; the URL the container’s ferroehr healthcheck subcommand probes
GET /management/*ops introspection; see 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. /health and /health/liveness are two conventional names for the same constant answer (a load balancer wants the bare path, an orchestrator wants the liveness/ readiness pair); /ferroehr/rest/status is a different contract, and no health endpoint exists under the REST root.

Not every indicator blocks readiness, and the distinction is deliberate:

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
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
fhir_outboundthe FHIR outbound emitter’s broker delivery (present only when the emitter is enabled)no: reports DEGRADED, never 503, since unemitted rows are retained and re-emitted

An instance whose event broker is unreachable therefore keeps taking traffic and says so in the body; an instance that cannot reach its database, or whose database lost the schema, leaves rotation. The detail strings are written to carry no connection information (no DSN host, database name or role) because this surface is unauthenticated by design.

Important

Liveness and readiness are deliberately different: liveness never touches a dependency, so a database outage takes the instance out of rotation (readiness 503) instead of getting the container killed and restarted in a loop. 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.

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, and the audit posture (enabled, fail_mode, local_store, retention_days)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 → Operations panel.

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 and accepts documents in the template-data (TDD) form. This page is the operator’s reference for both: every path, what gates it, what it accepts, and every status code it answers.

Where these routes live, and what gates them

Both groups are mounted under the API base path, so every path below is relative to it. With the default server.base_path, {base} reads /ferroehr/rest/openehr/v1, and {base}/admin/dump is /ferroehr/rest/openehr/v1/admin/dump.

The two groups are gated differently, and the difference matters operationally:

GroupSwitchAuthorization classWhile switched off
{base}/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
{base}/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 {base}/admin/template/{template_id}204404 unknown template, 409 a committed version still references it
DELETE {base}/admin/query/{qualified_query_name}/{version}204404 unknown name, or a known name with no such version
GET {base}/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; the subject proxies of a subject whose only record this was go in the same transaction, and an erasure tombstone is appended to the change-event stream so a consumer that derived anything from the EHR is told to delete it (GDPR Art. 19). The cross-reference row that names the EHR’s subject is then erased in the linkage domain, and the externalized multimedia blobs no surviving version still references are removed from the object store.

What it keeps: the audit records naming the ehr_id. Art. 17(3)(b) withholds erasure where processing is necessary for compliance with a legal obligation, and the national access-logging periods are that obligation. Data outside the running database (backups, replicas, exports) is reached by your own rotation, not by this call.

Details that decide behaviour:

  • The bulk delete’s parameter is optional, and its absence means everything. DELETE {base}/admin/ehr/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, HMAC and signing-key secrets and S3 secret keys render as ***; connection URLs (database, AMQP) keep their host and path and mask the embedded credentials (postgres://***@host:5432/db). Non-secret identifiers (usernames, roles, an OIDC issuer) stay visible. Redaction is a property of the configuration’s secret types, so no secret value can reach this response.

Note

The template delete, the stored-query delete and the config view are FerroEHR extensions: the openEHR admin API defines only the two EHR deletes. They share the same switch and the same authorization as those deletes.

The activity report

Four read-only counters over the repository’s change history, behind the same switch and role as the deletes.

Every route takes two query parameters:

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 {base}/admin/report/contributionthe matching CONTRIBUTION ids as a JSON array, ordered by commit time then id
GET {base}/admin/report/contribution/counthow many there are, as a bare JSON number
GET {base}/admin/report/versioned_composition/counthow many distinct COMPOSITION version containers had a version committed in the interval
GET {base}/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 {base}/admin/archive/ehrs{"ehr_ids": ["…"]}204: every named EHR and all its versioned content is archived
POST {base}/admin/archive/parties{"party_ids": ["…"]}204: every named demographic party is archived
POST {base}/admin/archive/ehrs/restore{"ehr_ids": ["…"]}204: every named EHR’s archived content is back in the primary tier
POST {base}/admin/archive/parties/restore{"party_ids": ["…"]}204: every named party’s archived content is back in the primary tier

All four are all-or-nothing and idempotent: a body of the wrong shape or a malformed id is 400 and an id that names nothing is 404, in both cases before anything is moved; re-archiving an already-archived record, or restoring one that is not archived, changes nothing. An empty list succeeds and moves nothing. A body sent without Content-Type: application/json is 415. A party that is currently archived is still found by its restore call (the existence check spans both tiers) so an archived party is never reported missing.

A party’s PARTY_RELATIONSHIPs are not carried along: each is an independently addressable versioned object, archived in its own right.

What “cold storage tier” means here: the archived rows are physically moved out of the primary tables into a separate schema in the same database, so the tables and indexes that serve everyday traffic shrink by exactly what was archived: no extra tablespace, volume, or external service to operate. Reads of unarchived records never touch the cold tier; a read that addresses an archived record is served from it. Writing to an archived record brings it back to the primary tier first, so a versioned object is never split across tiers; a physical delete clears both tiers; and {base}/admin/dump still exports archived content.

Two consequences to plan for:

  • AQL queries see the primary tier only, so an archived record stops appearing in query results; that is exactly what shedding the query tables’ rows and indexes buys you. Everything addressed by id (an EHR, a composition, a folder, a party, a version, a revision history) keeps working as before. Restoring puts the record back in query results.
  • There are three ways back, and only one of them is deliberate. The …/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.

Eight routes behind the same switch and role, carrying the three marks the law asks a repository to hold beside its clinical content. None of them deletes anything.

RouteBody or parameterSuccess
POST {base}/admin/restriction{"ehr_id": "…", "vo_id": "…"?, "ground": "…", "note": "…"?}204: the restriction is recorded and in force
GET {base}/admin/restriction?ehr_id=…200: the register, newest request first, lifts included
POST {base}/admin/restriction/lift{"ehr_id": "…", "vo_id": "…"?}204: every in-force restriction at that grain is lifted
POST {base}/admin/research-objection{"ehr_id": "…", "objected": true, "ground": "…"?}204: the objection, its override, or its withdrawal is recorded
PUT {base}/admin/retention/policy{"kind": "…", "jurisdiction": "…", "period": "…", "anchor": "…", "source": "…"}204: the period is declared
GET {base}/admin/retention/policy200: the whole retention register
PUT {base}/admin/retention/anchor{"ehr_id": "…", "jurisdiction": "…", "anchored_at": "…"?, "hold_at": "…"?, "hold_ground": "…"?}204: the EHR’s anchor and any whole-record hold
POST {base}/admin/retention/hold{"vo_id": "…", "held": true}204: the per-object exemption is placed or released
GET {base}/admin/retention/due?limit=100200: what has run out, oldest first, with the due and held object counts

A body of the wrong shape or a malformed id is 400, an id that names nothing is 404, and a body without Content-Type: application/json is 415, in each case before anything is recorded. Setting or lifting a mark is an access record of its own.

Restricting a record changes what every other route answers: reads of a restricted object become 403, it leaves AQL results at every scope, exports skip it and the event stream withholds it, and writes to it are refused. What each mark means, which provision it serves and what the deploying organisation still has to decide are on Retention, restriction and objection.

Storage integrity

Two routes that check the stored data against itself and repair what they find, behind the same switch and role. FerroEHR stores every version’s content twice: once as the materialized document a point read serves, and once as the decomposed rows the AQL engine queries. A commit writes both in the same transaction, so they always agree; anything that changes one of them behind the server’s back breaks that agreement.

Route200 body
POST {base}/admin/integrity/verifythe sweep report
POST {base}/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 {base}/admin/integrity/rebuild-nodes is the repair: it re-derives the decomposed rows of every damaged version from that version’s stored document, one transaction per version.

curl -s -X POST "$BASE/admin/integrity/rebuild-nodes?ehr_id=$EHR_ID"

It runs the same sweep first and writes only the versions that sweep reports damaged, so a run over healthy data writes nothing at all. The scope parameters are the same four, so a repair can be as narrow as one version:

curl -s -X POST \
  "$BASE/admin/integrity/rebuild-nodes?vo_id=$VO_ID&sys_version=2"

Which copy wins is not a choice the route makes. The stored document is the version’s canonical serialized form, the bytes a point read serves and the bytes a digest was taken over; the decomposed rows are an index derived from it. So the repair only ever runs in that direction, and a version whose document is the damaged copy is refused rather than having the damage copied into the index.

Each version is repaired inside one transaction: the document is read under a row lock, decomposed, the old rows deleted, the new set inserted, and the version re-derived from what was just written and compared with the document before the commit. Anything that fails rolls that transaction back, so the version’s rows are left exactly as they were. One refusal never stops the run that found it.

An archived object is thawed for the repair and re-archived in the same transaction, marker and all, because a write to a versioned object always happens in the primary tier.

A logically deleted version stores no document, so it rebuilds to no rows, which is the repair for unexpected_nodes.

The same route is the upgrade step after a release that changes how content is decomposed. Rows written by the older release hold the right content, so nothing is damaged, but they are the wrong shape for the new one and the sweep reports them stale_decomposition. Rebuilding writes them again from the stored document, which is all the upgrade is. Run it unscoped once and the next sweep is clean:

curl -s -X POST "$BASE/admin/integrity/rebuild-nodes"

The changelog names the affected object type at each such release.

{
  "versions_checked": 128,
  "versions_damaged": 2,
  "versions_rebuilt": 1,
  "versions_refused": 1,
  "records": [
    {
      "domain": "clinical",
      "vo_id": "8849182c-82ad-4088-a07f-48ead4180515",
      "sys_version": 2,
      "kind": "COMPOSITION",
      "defect": "content_differs",
      "outcome": "rebuilt",
      "node_rows": 41
    },
    {
      "domain": "demographic",
      "vo_id": "1f0b7d64-6b2a-4a1f-9f0e-6b1d2a3c4d5e",
      "sys_version": 1,
      "kind": "PERSON",
      "defect": "content_differs",
      "outcome": "refused",
      "reason": "the stored body does not decompose: ..."
    }
  ],
  "truncated": false,
  "elapsed_ms": 812
}

defect is the sweep verdict that selected the version, from the five values above. outcome is rebuilt, carrying the node_rows the version now has, or refused, carrying the reason. records is capped at 1000 entries with truncated saying whether the cap was reached; every record is logged at info level with its identifiers whatever the cap does.

A refusal is not a request failure, so the status stays 200. Check versions_refused: anything above zero means a stored document is itself damaged, and that is a restore-from-backup question rather than a rebuild one.

Unlike the sweep, this route writes, so a server in read-only mode refuses it.

Dump and load

Two routes that move the whole repository to and from an archive on the server’s file system, behind the same switch and role. Both answer 200 with a JSON array of per-entity failure reports; an empty array means everything succeeded.

POST {base}/admin/dump

Writes an archive of every EHR and of every standalone demographic container: the parties and party relationships that live outside any EHR. The body is {"file_sys_loc": "…"} plus the optional export settings:

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 {base}/admin/load

Populates the repository from an archive. It takes the location and nothing else: the container (loose files, a single archive.zip, or a single archive.7z) is detected from what the location holds, and the logical format is read from the archive’s own manifest, so a load never has to be told how the dump was written. Archives written before the demographic wave existed simply carry no demographic entries and load unchanged.

The repository being loaded into need not be empty. An EHR whose id is already present is reported and skipped rather than failing the load, so the response array names each one; and a standalone demographic container that already exists is reported the same way, under its own kind (PERSON, ORGANISATION, GROUP, AGENT, ROLE, or PARTY_RELATIONSHIP) as the entity_type:

[ { "entity_type": "EHR",
    "entity_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
    "dump_status": false,
    "error": "an EHR with this id already exists" } ]

Both directions are lossless: a dump and a load reproduce every record, whichever format and container you choose.

Refusals

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 and TDD import

Six routes under {base}/message that move whole records between systems and accept documents in the template-data form. Unlike the admin extensions above, these are not admin-gated; see the gate table.

EHR Extract

  • GET {base}/message/export/{ehr_id}: export one whole EHR. 200 with a JSON array holding 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 {base}/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 {base}/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 {base}/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.

TDD import

  • POST {base}/message/tdd/{ehr_id} with an application/xml body — import one Template Data Document. It is converted against the operational template its root names and committed through the ordinary validated composition path, so 201 with {"uid": "<version_uid>"}. The template must already be uploaded through the definition API (404 otherwise, as for an unknown EHR); a root that is not in the template-data namespace, carries no template id, or does not conform to the template is 400; a document that is not well-formed XML (or a produced composition that fails validation at commit) is 422. A body sent as anything but application/xml is 415.
  • POST {base}/message/tdd/{ehr_id}/batch with a JSON array of TDD documents — import several at once. 201 with the created version ids in input order. The batch is all-or-nothing: every document is converted before any is committed, so one bad document rejects the whole batch and commits nothing. An empty array is a fulfilled no-op (200 with [], since nothing was created) but the target EHR is checked for every batch, the empty one included, so an unknown one is 404 whatever the batch holds. The batch has no limit on how many documents it may carry; the only bound is the server-wide request-body limit, which answers 413 when exceeded.

Note

The whole {base}/message group is a FerroEHR extension: the openEHR service model defines a Message component, but the released REST API publishes no message, extract, or TDD endpoint at all. These URLs are our own and gate no openEHR conformance claim; see Conformance. For the workflow-level view (what an extract is good for and how to drive an import) see EHR Extract & messaging.

Other routes under {base}/admin

Three further families share the /admin path prefix but not the admin.enabled switch: each has its own, and each answers 404 (not 405) while its own switch is off, because the group is simply not serving:

RoutesOwn switchDocumented in
{base}/admin/event_subscription…events.admin_apiChange events (AMQP)
{base}/admin/fhir_mapping…fhir.api_enabledFHIR connectors

They are still admin-class routes for authorization, so the same 401 / 403 split applies.

For everything else an operator needs (migrations, health probes, the management surface, observability and upgrades) see Operations.

Conformance

FerroEHR makes a measured claim: it is an openEHR-spec-conformant Clinical Data Repository, and that claim is backed by a test run you can reproduce. This chapter explains what conformance means here, how to run the suite (against this server, against another CDR, or against any deployed endpoint you point it at) and how to read the artefacts it produces: the report, the statement, the certificate, and the cross-server comparison matrix.

What is measured

Conformance is checked by Veredictum, an independent CNF 2.0 reference runner developed as its own project and consumed here at a pinned version. It is a data-driven interpreter over a committed, machine-readable catalogue authored from the openEHR Conformance framework itself: protocol-neutral case cores anchored on the official platform test schedule (case ids follow the schedule’s own naming, e.g. I_EHR_SERVICE.create_ehr-main), per-operation bindings mapping every outcome to its cited wire expectation, closed vocabularies for outcomes/selectors/captures, a provenance-stamped corpus (the official openEHR Robot data sets re-adjudicated to spec-text-only evidence), and a typed ambiguity register; a specification silence is never resolved privately. Every expectation traces to specification text, never to any server’s observed behaviour. The catalogue spans the schedule’s chapters:

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-09-15).
  • Profile verdicts — Core: PASS. Standard: PASS. Options: PASS. Security (SEC-BASIC): PASS.
  • 43/43 capabilities satisfied (passed, or excused by a schedule-registered ambiguity — an unrealizable wire on this technology profile is an explicit scope exclusion, never a silent pass).

Cases that did not execute are not-applicable with a machine-readable citation (an unrealized wire on this technology profile, an undeclared option branch, or a ground a shared server cannot establish), never silent omissions. Options aggregates optional capabilities under the Profiles book’s “any passes” rule.

Any server can be assessed

The runner is deliberately not tied to FerroEHR — it is a separate project with its own release line, so the instrument and the system it judges do not share a build. It assesses any openEHR CDR reachable over HTTP and emits the same artefact set for each system under test, into its own directory:

  • FerroEHR (the default): the composed stack built from the current sources. This is the project’s own gate: the committed artefacts are regenerated and diff-checked, so a change that moves a verdict cannot land quietly.
  • EHRbase: CONF_SUT=ehrbase composes the official ehrbase/ehrbase image (with its companion PostgreSQL) on fresh volumes and runs the same catalogue with EHRbase’s own committed party set. Its measured artefacts feed the comparison page.
  • Bring your own endpoint: point the runner at any deployed CDR by URL and credentials, with its own party set: an ixit naming the instances and credential environment variables, and a statement (the ICS) declaring the capabilities and ambiguity-register options the vendor claims. Option branches the ICS does not declare are excused as not-applicable with a citation, in the ISO/IEC 9646 tradition of test selection. No code or adapter is needed; a target is a configuration entry.

The ixit is also where a deployment declares the facts no openEHR operation exposes, each of which switches on the cases that depend on it:

DeclarationWhat it tells the runner
environmentThe hardware, cores, memory, storage and topology a measured run happened on — mandatory for a performance run
containersThe composed containers, enabling database-side attribution and deterministic maintenance settling
system_idThe identifier the server stamps into commit audits and the version ids it mints
dump_locationA path on the server’s own file system the admin dump/load operations may use
signingThe version-signing mode the deployment realizes (digest or openPGP)
smartThat the deployment runs the SMART resource-server role, and which test issuer it trusts
terminologyThe terminology query servers it is wired to, the namespaces each answers for, and what it does with a bound value set it cannot resolve

A party that declares none of these has the dependent cases recorded not-applicable rather than checked against a guess.

That silence has a cost, and the pipeline makes it visible before a run. The instrument’s ixit schema also defines parameters per instance (the administrative posture of a principal, an instance’s own signing, terminology or spec_profile when it differs from the party’s), and an absent one is undeclared, never a default: every case that requires it is guarded out. scripts/checks/ixit-declarations.sh reads that parameter set from the pinned schema itself and refuses to run the catalogue while an instance leaves one undeclared, unless ixit-undeclared.json beside the ixit records why that instance leaves it so (an unauthenticated instance has no principal to be administrative, for example). A pin bump that introduces a parameter therefore fails the pipeline until the party declares or adjudicates it, instead of quietly losing the cases it guards.

Running the suite yourself

The suite runs against a real, deployed server (the same container image and stack a deployment uses) so the wire under test is always the production artefact, never a re-wired in-process stub. From a checkout with Docker available:

# our server, from the current sources (the default)
bash scripts/conformance.sh

# EHRbase, from the official images
CONF_SUT=ehrbase bash scripts/conformance.sh

# any deployed CDR, by URL (credentials via the SUT_* variables the
# ixit references)
CONF_SUT=byo CONF_BASE_URL=https://your-host/ferroehr/rest/openehr/v1 \
  SUT_USER=user SUT_PASS=password bash scripts/conformance.sh

The script brings up the selected stack on fresh volumes (for byo it manages nothing), executes the committed catalogue, computes the verdicts through the pure pipeline, and writes the artefacts to docs/conformance/<sut-name>/ before tearing the stack down.

Useful knobs: a case-id filter as the first argument, CONF_IXIT / CONF_STATEMENT for a custom party set, CONF_OUT for a different artefact root, CONF_NO_COMPOSE to run against an already-deployed stack, SKIP_BUILD to compose a published image instead of building from source, and the runner’s own verdicts subcommand to recompute the documents from a previous results.json without re-running.

The postures a run covers

Some behaviour exists only in a particular server configuration. Rather than splitting those into separate runs whose records would have to be merged by hand (which is exactly how a claim stops being reproducible) the pipeline brings up two deployments of the same image and covers both postures in the one committed record:

  • the primary deployment runs the SMART resource-server role with fail-closed scopes and a trusted test issuer, digest version signing, and an external FHIR terminology server in the fail-open posture;
  • a second deployment, in its own compose project on remapped ports, runs openPGP version signing and the fail-closed terminology posture.

The reason is the same in both cases: a running server realizes exactly one signing depth and exactly one unresolvable-value-set behaviour, so testing both claims means running both deployments. The ixit declares the second one as its own instance, and the cases that check those properties address it by name.

Two consequences worth knowing:

  • SMART is the standard posture. The SMART discovery document, the resource-scope grammar, and the fail-closed 403 are executable cases in the same record as everything else, driven by principals presenting minted Bearer tokens with the roles and resource scopes each case needs. The tokens are signed by a committed test issuer: public test key material for the harness, never usable for anything else. A system under test whose ixit declares no SMART block (EHRbase) records those cases not-applicable with the citation instead.
  • External terminology is part of the standard posture too. An archetype can constrain a coded element to a value set only an external terminology query server can resolve, so the pipeline composes a real FHIR R4B server beside the CDR, seeded with synthetic test code systems and value sets. That covers the terminology-routed surface — AQL TERMINOLOGY() resolved through the routed server, and commit-time validation of a bound value set, accepted for a member code and refused for a non-member. What a deployment does when the value set cannot be resolved at all is not decided by any openEHR text, so it is a declared posture rather than a verdict; both branches execute, one per deployment.

A measured performance or stress run adds one more posture: rate limiting is turned off for the duration, because the instruments deliberately offer load past the server’s knee and a throttled request would measure the limiter instead of the server. Both instruments refuse to write a record if the server answered any 429, so a measurement can never be silently limiter-shaped.

Reading the artefacts

A run writes machine records and three human-readable documents to docs/conformance/<sut-name>/. Each has a distinct job.

The machine records

results.json is the party results record: one outcome per case with its rows-driven coverage, failing step and reason where applicable, and the excusing citation for every not-applicable entry, alongside the system-under-test identity, the runner’s verification-pack status, the technology profile, and the ixit digest. verdicts.json is the computed verdict report, and run-exceptions.json registers anything the interpreter itself could not cover. Every other artefact (the three documents, the badges, the charts) is generated from these; nothing downstream is hand-edited.

The conformance report

CONFORMANCE_REPORT.md is the honest, scoped record of this run: the system under test, the outcome counts, the per-capability evidence rollup, the machine-computed profile verdicts, and every not-applicable entry with its excusing citation. Read this when you want to know exactly what happened and why any case did not run.

The conformance statement

CONFORMANCE_STATEMENT.md is the concise, generated claim: the supported specification versions, the declared external data formats (JSON and XML), and the profile results. Every line is a pure function of the machine verdicts, so the statement can never claim more than the run proves.

The conformance certificate

CONFORMANCE_CERTIFICATE.md follows the structure of the openEHR conformance certificate template: the system under test, the scope of test, and a per-capability profile report showing which capabilities are required in each profile, what each was verified against, and whether each passed. The Realization column separates capabilities verified over released ITS-REST operations from any verified over routes a product serves of its own design — the latter never gate an openEHR profile tier. Where the certificate carries a measured run, its Workload Coverage table additionally shows which claimed capabilities the hospital-simulation load actually exercised; a capability the simulation does not reach must carry an adjudicated exclusion, printed with its reason, and the runner’s validation gate refuses an artefact tree that leaves such a row undecided. It is emitted for every assessed system — FerroEHR, EHRbase, or your own — and always identifies itself as a framework assessment with the claim computed from the attached run; it is never an official openEHR certification. This is the document to hand to a procurement or evaluation reviewer who wants the capability-by-capability picture.

The comparison matrix

The multi-system record is fully generated from the two committed results/verdicts sets (ours and EHRbase’s): profile verdicts, the capability-by-capability evidence matrix, and failure tables in both directions: measured numbers only, no editorial adjustment, both directions always published. It renders as the comparison page.

Tip

The conformance badges in the project README are generated from the same run and carry the measured amounts (per-profile capability counts, the overall driven-case count, the earned performance class). A badge can never show a pass unless the machine verdict does, so a green badge is a claim you can immediately reproduce with scripts/conformance.sh.

What conformance does not cover

The catalogue measures the openEHR platform surface, including the simplified (FLAT/STRUCTURED) formats chapter of the ITS-REST specification. It deliberately does not stand in for a performance benchmark: durations recorded during the functional run are telemetry only, and the measured classes own that claim (Performance).

The other honest boundary is the gap between openEHR’s service model and its released REST wire. Several service operations were never surfaced as endpoints (listing an EHR’s contributions, counting stored templates or queries, deleting a template or a stored query) so a case that addresses one has no wire to drive on this technology profile. Those cases are excused through the schedule’s typed ambiguity register, with the citation printed in the report, and reported as an explicit scope exclusion on the certificate; never a silent pass and never an unavoidable failure. FerroEHR does serve routes of its own design for several of them (see Admin & messaging APIs and the archetype routes in Templates & validation); those are marked extension on the certificate and never gate an openEHR profile tier.

Performance

FerroEHR applies the same discipline to performance that it applies to conformance: a class is a verdict a server earns by measurement on a stated environment, or does not. The performance chapter of the CNF suite runs an open-loop clinical workload at a published offered-load floor, records the result as a re-checkable histogram, and lets the verdict pipeline recompute (earned or not earned) from that artefact. Nothing on this page is hand-typed; every number comes from the committed measurement records or the generated assets below.

The volumetric class ladder

Performance conformance is graded on a small, closed ladder of deployment classes: proof-of-concept (POC), small (S), large (L), and regional (R). Each class fixes an offered-load floor (the peak API arrival rate the server must sustain), a latency budget (a p99 service-level objective, uniform across classes), and an error budget (zero: a failed request under load is a failed class). A class is earned only when a measured run holds every threshold; a class is never declared.

Crucially, a class verdict is environment-bound: it is meaningful only alongside the hardware, core count, memory, storage class, and topology it was measured on, which the runner records in the measurement’s environment block and stamps into every asset. The same binary earns different classes on different hardware, and the artefact always says which.

Where the floors come from

The offered-load floors are anchored to population, so a class corresponds to a real catchment a deployment might serve. The derivation is a short chain of published, official activity statistics:

  • Clinical documents per person per year. Summing the major encounter types that each commit a clinical document gives roughly forty-six documents per capita per year: primary- and specialist-care consultations (OECD, Health at a Glance 2023), inpatient discharges (OECD/Eurostat hospital discharge statistics), emergency-department visits (OECD emergency-care indicators), laboratory reports (Royal College of Pathologists activity data), diagnostic-imaging events (NHS England Diagnostic Imaging Dataset, 2023/24), and dispensed prescriptions (NHS Business Services Authority Prescription Cost Analysis, 2024/25). The result sits between the major-document exchange rates Denmark and Estonia report and Finland Kanta’s all-inclusive figure, which is the sanity check that keeps it arguable.

  • Average write rate. Multiplying a class’s served population by that per-capita rate and dividing by the number of seconds in a year gives the average sustained document-write rate for the class:

  • Busy-hour peak. Real clinical traffic is not flat: it concentrates in ward rounds and clinic hours. Following the ITU-T E.500 busy-hour engineering convention, the average is scaled to a busy-hour peak by a peak factor of eight:

  • Read multiplier. A CDR is a read-heavy OLTP system: charts are read far more often than they are written. Following the read-heavy OLTP convention used by standard database benchmarks (YCSB, OLTP-Bench), the offered load applies a read-to-write ratio of 10:1 on top of the write rate; audit-log evidence of how many record interactions one encounter actually generates bounds the read-heavy band from above.

The floors that fall out of this chain are the published defaults the runner enforces; the concrete rates per class are carried in the class ladder above and the summary table below, never re-typed into this prose.

The durability floor — what a single write can never beat

Every committed version is durable: the transaction’s WAL records are flushed to disk before the server answers, so an acknowledged commit survives a crash. That flush (one fsync on the WAL device per commit) is a physical lower bound on single-client write latency, and no storage design takes a lone sequential client below it. On a laptop-class Docker setup the flush alone dominates a single write’s budget; on server NVMe it is typically an order of magnitude smaller. FerroEHR’s optimization target is therefore everything around the flush: the database’s own per-commit work (one folded commit statement, one merged placement read) stays far below the flush itself. That is also what makes concurrent throughput scale: PostgreSQL group-commits, amortizing one flush across every transaction that reaches it in the same window.

The knob behind this boundary is PostgreSQL’s synchronous_commit. Setting it off makes commits return before the WAL flush: single-client write latency drops to the statement work alone, and a server crash can lose the most recent acknowledged commits (up to three times wal_writer_delay, per the PostgreSQL documentation). The database stays consistent; the tail of acknowledged writes is what is at risk. FerroEHR never defaults this off and does not recommend it for clinical data. It is an operator decision, made per deployment on the database side, defensible only where the record of loss is acceptable (a load-test rig, a reseedable sandbox, an analytics replica). All published FerroEHR numbers are measured with full durability on.

The hospital simulation

The measured workload is not a flat operation mix: it simulates a hospital, end to end. Load arrives as clinical journeys (ordered, time-offset operation sequences drawn from a committed journey catalogue):

  • ADT flow: an admission creates the EHR, sets its status, commits the admission problem list and summary, and opens the per-episode directory folder tree; a discharge writes the discharge summary and closes the episode out.
  • Monitoring: nursing observation rounds commit vital-signs documents at ward cadence.
  • The medication loop: an order is followed by scheduled administration commits at the drug-round interval; medicines reconciliation reads the standing medicines list and amends it as a new version.
  • Order → result pipelines, asynchronous: a laboratory or imaging request is committed at one instant and its result lands as its own arrival after a realistic turnaround drawn from the catalogue; the ordering clinician’s chart review follows later still. Nothing ever blocks on anything else.
  • Clinical review: ward-round chart reads (at version, current, and the revision history), per-patient AQL trends, cross-EHR ward worklists, and a registered stored query executed continuously.
  • Governance: versioned amendments, the occasional logical delete, contribution inspection (the audit trail’s read side), and workflow tagging of hot documents.
  • The platform surface: template listing and retrieval (the integration-engine poll), specialist synoptic reports, registry submissions, and statutory public-health notifications.

Every stage of every journey instance is its own planned arrival instant on the global open-loop schedule (an order at , its administrations at , the result at ) so many patients’ journeys interleave exactly as wards do, and cross-operation state effects (status transitions during active commits, folder consistency under parallel writes, version chains under interleaved amendments, AQL against a mutating corpus) are exercised under load, which a flat four-operation mix can never reach. A dependent stage whose prerequisite has not landed when its instant fires (a stalled server) records honestly as an error; that is the measurement.

The journey payloads commit against published openEHR CKM templates (vital signs, laboratory results, ePrescription, medicines list, problem list, the International Patient Summary, imaging and cancer synoptic reports, registry and public-health forms), vendored with provenance and committed as byte-identical example skeletons so every measured server receives exactly the same bytes.

The envelope stays population-anchored. The derivation above still fixes the aggregate operation arrival rate (the class floor) and the read:write ratio; the journey catalogue only decomposes those totals into many more operation kinds. Each journey cites the activity statistic that grounds its shape (the same register the floors derive from) and the runner’s artefact validator recomputes the expansion on every load: the catalogue-expanded write share must reconcile to the derivation’s read-heavy band (between the 10:1 floor convention and the audit-log-evidenced ceiling), so the mix stays arguable, never arbitrary.

For the extended eight- and twelve-hour holds, the schedule can follow a diurnal day curve (morning and afternoon peaks, shift-change bumps, a night-time trough) applying the same ITU-T E.500 busy-hour convention the peak factor cites: the class floor is then the busy-hour rate, and the off-peak troughs are the design, not a shortfall.

Finally, the conformance certificate prints the workload coverage: the set of claimed capabilities the simulation actually exercised, joined against the claims matrix. A claimed capability the hospital never touches must carry an adjudicated exclusion printed with its reason: a design-time operation like archetype provisioning is not a sustainable per-patient arrival, and saying so beats pretending the load reached it.

How a measured run works

A performance run is deliberately open-loop: the runner plays a seeded arrival schedule (request is due at a planned instant computed before the run starts) rather than a closed loop of virtual users that would slow its own offered load down whenever the server stalls. This makes the run coordinated-omission-free: each request’s latency is measured from its planned arrival instant, so a server that pauses cannot hide the queue it built up behind a handful of fast replies.

A run has two phases: a discarded warmup window (caches fill, pools warm, the JIT of a compared server settles) followed by the sustained measurement window at the class’s offered-load floor. Latencies are accumulated into per-operation HDR V2 histograms, which are base64-encoded verbatim into the measurement record in results.json. Because the full histogram is embedded (not just a handful of pre-computed percentiles) any consumer can re-derive every percentile and re-check every threshold from the committed artefact. The class verdict itself is then recomputed by the verdict pipeline from those records: the runner never writes a verdict it cannot reproduce from the data.

The measured corpus is seeded strictly through the public write path (create EHR, commit composition) and never a database backdoor, so what the run reads is exactly what the server’s own write path produced. The database’s maintenance debt is settled deterministically, and identically for every system under test, outside the measured windows, so neither a stale-statistics plan nor an autovacuum firing mid-window ends up in the record.

Reproducing it

The measured run is a stage of the conformance pipeline. Selecting a class seeds the matching scale corpus, plays the open-loop schedule against the composed server, and merges the measurement records into results.json:

# seed the class corpus, run the open-loop schedule, merge the record
CONF_PERF_CLASS=POC bash scripts/conformance.sh

# hold the same offered load for longer — the hours ladder is 1 (the case's
# normative window and the default), 2, 4, 6, 8, or 12
CONF_PERF_CLASS=POC CONF_PERF_HOURS=8 bash scripts/conformance.sh

There is deliberately no shortened run: the measurement record always covers at least the case’s normative window, so nothing sub-normative can ever be mistaken for a measured result. A longer hold is a stricter demonstration of the same class, not a different one. The record carries the actual warmup and window it held, and the verdict machinery re-derives everything from the embedded histograms. Every run seeds a freshly composed server from empty; there is no seed reuse, so no run ever measures another run’s leftovers.

The runner can also be driven directly against a running server, which is how you point it at a deployment the pipeline does not compose. Its perf subcommand takes the catalogue root, the ixit topology file (whose environment block is mandatory: a measurement without the deployment described is not a measurement), the results.json to merge into, and the class:

veredictum perf --root <catalogue-root> --ixit <ixit.json> \
                --results <results.json> --class POC --hours 8

Stress testing — the second instrument

Beside the class runs sits a deliberately different instrument: a step-load stress test. Where a class run holds a real-life, population-anchored rate for at least an hour, the stress test climbs a geometric ladder of short, intense load steps (about two minutes each, doubling the offered rate every step) until the system leaves the stress envelope, then bisects between the last stable rate and the breached one to locate the boundary precisely, the point performance engineering calls the knee of the latency-throughput curve. The headline it finds is the maximum sustainable throughput: the highest offered rate held inside the latency budget (the same idea TPC benchmarks report as maximum qualified throughput).

veredictum stress --root <catalogue-root> --ixit <ixit.json> \
                  --out <stress.json> --corpus-class POC

The two instruments never blur. A stress report earns no class, never touches results.json, and carries no class vocabulary at all; the class ladder belongs to the measured class runs, and the stress chart shows one thing only: where the system breaks. Their envelopes differ on purpose too: a conformance class demands a zero error budget, while a stress step allows the small error tolerance standard load testing works with, because the instrument’s job is to find the knee rather than to certify anything. Every load step embeds its own re-checkable histograms and its own resource telemetry (the same per-container CPU/memory/I/O series the measured runs record, so a breached step shows where it saturated), a breached step is reported with the exact envelope violation, and a step where the load generator topped out before the server is flagged as such rather than counted against the system.

The optimization probe

Between the two verdict-bearing instruments sits a third, purely diagnostic one: the AQL probe. It seeds the same class corpus fresh, fires the measurement machinery’s own AQL set repeatedly, and records each query’s wire-latency percentiles alongside the database-side cost per SQL statement, so an optimization is argued from attributed evidence on a realistically seeded database, never from a hunch on an empty one. Its report is exploration evidence for the optimization loop: it earns nothing and never touches the conformance record.

veredictum aql-probe --root <catalogue-root> --ixit <ixit.json> \
                     --out <aql-probe.json>

The published assets on this page are rendered from the committed results.json by the runner’s perf-assets subcommand (wrapped by scripts/render/perf-assets.sh); the docs CI job re-renders and diffs them, so a hand-edited or stale asset fails the build.

The storage benchmark harness

A fourth instrument measures the storage layer directly, below the wire:

STORAGE_BENCH_CLASS=poc cargo bench -p ferroehr --bench storage

It seeds a corpus through the ordinary write path, then times the storage layer’s hot paths one at a time: a composition commit, a supersession, the supersession that states the version it replaces, a point read by version uid and one by versioned-object uid, the version at an instant, the revision history, an AQL CONTAINS chain over one EHR and over the whole population, archive and restore of an EHR, and one retention prune.

Beside each operation’s wall-clock it records what the database did for it. The per-relation tuple counters (inserted, updated, updated in place, dead, live) and the relation sizes come from pg_stat_user_tables, the buffer hits and misses from pg_stat_database, and the write-ahead-log bytes from the WAL position either side of the measurement. One representative commit is probed on its own, and the population query is explained with EXPLAIN (ANALYZE, BUFFERS, WAL) inside a transaction that is rolled back, so the plan shape and its index choices land in the record too.

Everything runs through the service and the public storage API, and the relations are discovered from the catalogue, so no table name appears anywhere in the harness and the same file measures a rewritten schema. That is the point: a storage change is argued from a before-and-after pair taken with one instrument. It earns no class and touches no conformance artifact; the record’s shape is documented in docs/benchmarks/storage/README.md. Records are local by default, and the committed baseline is what this page renders.

Schema generation generation-1, class s

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.

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 ms0not measured
Lcnf.scale.1m150/s≤ 1000 ms0not measured
Rcnf.scale.10m1500/s≤ 1000 ms0not 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: the built-in CNF runner. There is no separate benchmark harness: every published number is measured by a committed, re-runnable instrument, reported in both directions, and regenerated from committed artefacts. There is no chart in this project that you cannot regenerate yourself with one command.

The three instruments

  • Measured class runs (CONF_PERF_CLASS=… bash scripts/conformance.sh, or the runner’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 (the conformance pipeline's perf stage)
CONF_PERF_CLASS=POC bash scripts/conformance.sh

# the step-load stress ladder (fresh compose + seed, then the climb)
veredictum stress --root <catalogue-root> --ixit <ixit.json> --out <stress.json>

# the AQL optimization probe
veredictum aql-probe --root <catalogue-root> --ixit <ixit.json> --out <aql-probe.json>

Every instrument seeds a freshly composed, empty server through the public API and the stack is torn down afterwards; there is no seed reuse, so no run ever measures another run’s leftovers. Committed records land under docs/conformance/<sut-name>/; the published charts regenerate from them (scripts/render/perf-assets.sh for one system, scripts/render/comparison.sh for the cross-system overlay, which calls the runner’s stress-compare renderer) and are diff-guarded in CI.

Fairness rules

The comparison methodology is enforced by construction:

  • The same runner drives both servers against the same committed catalogue and the same ladder, each on its own freshly composed stack with its own committed party statement.
  • Payload skeletons are byte-identical: both servers receive exactly the same bytes for the same journey stage.
  • Database maintenance is settled deterministically before every measured window, by the same procedure on both sides, so neither run pays the other’s maintenance debt or trips an autovacuum mid-measurement.
  • Rate limiting is off for measurement runs on the side that has it, because the instruments deliberately offer load past the knee; both instruments refuse to write a record if the server answered any throttled request.
  • Configuration differences are explicit and labelled, never quiet. Version signing is the clearest case: it is a FerroEHR extension EHRbase does not perform, so it is switched off through its documented toggle for throughput comparisons and the record says so. Any other setting raised or lowered for parity is likewise a visible part of the composed posture, which is why the postures live in committed compose files rather than in a shell variable someone may or may not have exported.
  • Both directions publish on equal footing. Where EHRbase sustains more, its curve says so exactly like the reverse; see Comparison.

Comparison with EHRbase

FerroEHR and EHRbase are two independent open-source openEHR CDRs. This page measures them side by side with the instruments FerroEHR applies to itself: the CNF 2.0 conformance runner executes the same committed catalogue against both servers, and the step-load stress instrument drives both with the same seeded clinical workload. Both directions are always published; a result that favours either server is reported exactly like one that favours the other.

EHRbase is prior art here, never an oracle. Every expected outcome comes from the openEHR specification text, so a row is red because the specification says otherwise, never because the two servers disagree.

Note

The two systems are not built or hosted alike, and the generated tables below say so. FerroEHR is built from this repository’s current sources; EHRbase runs from its official published container images. The two runs were also measured on different machines; each committed report records its own environment. Read the conformance columns as comparable (identical catalogue, identical runner, each side’s own declarations) and the performance columns as two separate measurements rather than a like-for-like hardware race.

Each side runs with its own committed party set: an ixit describing the reachable instances (EHRbase’s Basic auth carries one clinical and one admin principal and no read-only one, so its ixit declares none) and a statement (the ICS) declaring the capabilities, specification versions, and ambiguity-register options that party actually claims. A capability a party does not claim is dropped from its verdict scope and can never count against it; a case whose ground cannot exist on a party’s topology or technology profile is recorded not applicable with a machine citation, never fail. That is how the comparison stays fair without weakening a single case.

Every number and every curve on this page is generated at build time from the committed run records (results.json, verdicts.json, stress.json under docs/conformance/); nothing here is hand-typed, and a CI stale-numbers gate rejects any attempt to hand-type it. To reproduce either side yourself, see Conformance and Benchmarks.

One difference between the two projects is not something a runner can score, so this page states it rather than tabling it. FerroEHR aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture, and an EHDS conformity self-assessment is on its roadmap: the compliance overview names the legal sources, the control matrix is generated from the tracker, and the shared-responsibility page says which obligations the software cannot carry. None of that is a certification claim, and none of it is a statement about EHRbase, which publishes its own documentation on its own terms.

Conformance

Systems under test

ferroehrEHRbase
Productferroehr 4.3.0ehrbase 2.34.0
Run date2026-09-152026-09-15
Party statementcommitted with the runner (declares ITS-REST pin, signing, terminology posture)committed with the runner
Stackthe project’s own compose stack, built from the current sourcesa dedicated compose stack of the official EHRbase images

Methodology

Both systems execute the same committed CNF 2.0 catalogue (1145 case-by-format executions) through the same instrument (veredictum), each on fresh volumes with its own committed party set: the ixit names the reachable instances (EHRbase declares no readonly principal), and the statement (the ICS) declares the claimed capabilities, spec versions, and ambiguity-register options — ISO/IEC 9646-style test selection excuses undeclared option branches, unclaimed capabilities, and release-dated behaviour outside the declared versions as N/A with a citation, never as silent skips. Verdicts are pure functions of (statement, results, catalogue, capability matrix).

The declared-version delta matters and is stated, not hidden: ferroehr declares ITS-REST 1.1.0 while EHRbase declares ITS-REST 1.0.3 — the catalogue realizes 1.1.0, so every Release-1.1.0-dated behaviour (the Demographic API, ITEM_TAGs, Simplified Formats on the wire, the admin EHR delete, the weak-ETag/Location header forms, …) is cited N/A for the 1.0.3 declaration rather than driven against a release EHRbase never claimed. The verdict-bearing comparison below is therefore each party’s in-scope subset, never the raw record.

Profile verdicts

ProfileferroehrEHRbase
COREpassfail
STANDARDpassfail
OPTIONSpassnot claimed
SEC-BASICpassnot claimed

In-scope outcomes

Runs compared: ferroehr (run of 2026-09-15) vs EHRbase 2.34.0 (run of 2026-09-15) — the SAME catalogue through the same runner, each with its own committed party statement. Per the presentation rule, the headline is each party’s VERDICT SCOPE (the cases its own declarations select), never the raw record: a raw count would book release-dated and unclaimed surfaces against a party that never claimed them.

verdict scope (selected)drivenin-scope passedin-scope failedin-scope inconclusive
ferroehr11451104110400
EHRbase692571148148275

An inconclusive row’s wire answered outside the operation’s bound outcome map, or its required ground could not be established (e.g. a refused provisioning exchange) — never counted as a failure of the behaviour under test. Every not-run row in the full committed record (docs/conformance/<sut>/results.json) carries a machine-readable citation: an undeclared option branch, an unclaimed capability, a release-dated behaviour outside the declared spec versions, or a ground the party’s topology cannot establish.

Capability-by-capability

Evidence tokens from each party’s computed verdicts: passed (every gating case green), failed (at least one gating case red), inconclusive (a gating case neither passed nor failed cleanly), not_evidenced (claimed, but no gating case produced a verdict — there is no excused state: a required capability without passing evidence fails its tier, whichever party claims it), or not_claimed (absent from that party’s ICS, so no case naming it was ever selected — it can never count for or against that party). The whole capability matrix is listed for both columns, because the matrix is the profiles book as data, not a claim list.

CapabilityferroehrEHRbase
ActivityReportpassednot_claimed
Adl14ArchetypeProvisioningpassedfailed
Adl14OptProvisioningpassedfailed
Adl2ArchetypeProvisioningpassednot_claimed
Adl2OptProvisioningpassednot_claimed
AdminApipassednot_evidenced
AnonymousEhrspassednot_claimed
AqlAdvancedpassedinconclusive
AqlBasicpassedfailed
AqlTerminologypassednot_claimed
ArchetypeValidationpassedfailed
AuditAccountabilitypassednot_claimed
AuthenticatedAccesspassedpassed
AuthorizationSeparationpassednot_evidenced
BulkEhrLoadpassednot_claimed
ChangeSetspassedfailed
CompositionOpspassedinconclusive
DefinitionApipassedfailed
DemographicApipassednot_claimed
DemographicArchetypeValidationpassednot_claimed
DemographicArchivepassednot_claimed
DirectoryOpspassedfailed
EhrApipassedfailed
EhrArchivepassednot_claimed
EhrDemographicSeparationpassedpassed
EhrDumpLoadpassednot_claimed
EhrExtractpassednot_claimed
EhrOperationspassedfailed
EhrStatuspassedfailed
ItemTagspassednot_claimed
MessageApipassednot_claimed
PartyOperationspassednot_claimed
PartyRelationshipOperationspassednot_claimed
PhysicalDeletionpassednot_evidenced
QueryApipassedfailed
QueryProvisioningpassedfailed
Signingpassednot_claimed
SimplifiedFormatspassednot_claimed
SmartAppLaunchpassednot_claimed
SystemApipassednot_claimed
Tdspassednot_claimed
TemplateExamplespassednot_evidenced
Versioningpassedfailed

Failures — both directions

ferroehr failures (with the EHRbase outcome on the identical case)

CaseFormatFailureEHRbase outcome
none — zero failing cases

EHRbase failures by schedule chapter

Chapterfailed cases
CONT67
I_EHR_STATUS27
I_EHR_CONTRIBUTION13
I_EHR_DIRECTORY13
I_DEFINITION_QUERY10
I_DEFINITION_ADL148
I_EHR_SERVICE4
I_QUERY_SERVICE4
I_EHR_COMPOSITION1
I_ITS_REST_REVISION_HISTORY1
Every EHRbase-failed case, with the ferroehr outcome on the identical case
CaseFormatEHRbase failureferroehr outcome
CONT-COMP-content_card_1plus-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_1plus-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_3plus-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_3plus-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_3to5-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_3to5-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_any-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_any-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_mand-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_mand-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_opt-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_opt-context_mandexpected created, observed validation_failedpassed
CONT-COMPOSITION-content_cardinality_count6expected created, observed validation_failedpassed
CONT-COMPOSITION-context_existenceexpected created, observed validation_failedpassed
CONT-DV_CODED_TEXT-validate_openexpected bad_request, observed validation_failedpassed
CONT-DV_DATE-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_DATE-validate_rangeexpected created, observed validation_failedpassed
CONT-DV_DATE_TIME-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_DATE_TIME-validate_rangeexpected created, observed validation_failedpassed
CONT-DV_DURATION-validate_fieldsexpected created, observed validation_failedpassed
CONT-DV_DURATION-validate_fields_rangeexpected created, observed validation_failedpassed
CONT-DV_DURATION-validate_rangeexpected created, observed validation_failedpassed
CONT-DV_IDENTIFIER-validate_all_listexpected created, observed validation_failedpassed
CONT-DV_IDENTIFIER-validate_all_patternexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_rangeexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_rangeexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DURATION-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DURATION-validate_rangeexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_ORDINAL-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_PROPORTION-validate_ratio_rangeexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_SCALE-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_rangeexpected created, observed validation_failedpassed
CONT-DV_MULTIMEDIA-validate_media_typeexpected created, observed validation_failedpassed
CONT-DV_PARSABLE-validate_value_formalismexpected created, observed validation_failedpassed
CONT-DV_TEXT-validate_openexpected bad_request, observed validation_failedpassed
CONT-DV_TIME-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_TIME-validate_rangeexpected created, observed validation_failedpassed
CONT-EVENT-state_ex_mandexpected bad_request, observed validation_failedpassed
CONT-EVENT-state_ex_optexpected bad_request, observed validation_failedpassed
CONT-EVENT-type_anyexpected created, observed validation_failedpassed
CONT-EVENT-type_interval_eventexpected created, observed validation_failedpassed
CONT-EVENT-type_point_eventexpected created, observed validation_failedpassed
CONT-HIST-events_card_1plus-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_1plus-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_3plus-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_3plus-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_3to5-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_3to5-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_any-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_any-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_mand-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_mand-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_opt-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_opt-summary_ex_optexpected created, observed validation_failedpassed
CONT-HISTORY-events_cardinality_count6expected created, observed validation_failedpassed
CONT-ITEM_STR-type_anyexpected created, observed validation_failedpassed
CONT-ITEM_STR-type_item_listexpected created, observed validation_failedpassed
CONT-ITEM_STR-type_item_singleexpected created, observed validation_failedpassed
CONT-ITEM_STR-type_item_tableexpected created, observed validation_failedpassed
CONT-ITEM_STR-type_item_treeexpected created, observed validation_failedpassed
CONT-OBS-state_ex_mand-protocol_ex_mandexpected bad_request, observed validation_failedpassed
CONT-OBS-state_ex_mand-protocol_ex_optexpected bad_request, observed validation_failedpassed
CONT-OBS-state_ex_opt-protocol_ex_mandexpected bad_request, observed validation_failedpassed
CONT-OBS-state_ex_opt-protocol_ex_optexpected bad_request, observed validation_failedpassed
I_DEFINITION_ADL14.delete_archetype-clinical_forbiddenexpected forbidden, observed not_foundpassed
I_DEFINITION_ADL14.upload_opt-invalid_optexpected validation_failed, observed not_acceptablepassed
I_DEFINITION_ADL14.upload_opt-largest_publishedexpected created, observed not_acceptablepassed
I_DEFINITION_ADL14.upload_opt-valid_optexpected created, observed not_acceptablepassed
I_DEFINITION_ADL14.upload_opt-valid_opt_twice_conflictexpected created, observed not_acceptablepassed
I_DEFINITION_ADL14.upload_opt-valid_opt_twice_no_conflictexpected created, observed not_acceptablepassed
I_DEFINITION_ADL14.validate_opt-invalid_optexpected validation_failed, observed not_acceptablepassed
I_DEFINITION_ADL14.validate_opt-valid_optexpected created, observed not_acceptablepassed
I_DEFINITION_QUERY.list_queries-prefix_all_versions[0]/name: path resolves to nothingpassed
I_DEFINITION_QUERY.list_queries-version_get_xml_not_acceptableexpected not_acceptable, observed okpassed
I_DEFINITION_QUERY.list_queries-xml_not_acceptableexpected not_acceptable, observed okpassed
I_DEFINITION_QUERY.store_query-default_slot_with_higher_versionheader Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/orgpassed
I_DEFINITION_QUERY.store_query-dotted_nameexpected stored, observed bad_requestpassed
I_DEFINITION_QUERY.store_query-unqualified_nameexpected stored, observed bad_requestpassed
I_DEFINITION_QUERY.store_query-update_in_placeheader Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/orgpassed
I_DEFINITION_QUERY.store_query-version_duplicate_case_variant_nameexpected conflict, observed storedpassed
I_DEFINITION_QUERY.store_query-version_prefix_rejectedexpected bad_request, observed storedpassed
I_DEFINITION_QUERY.store_query-version_prerelease_rejectedexpected bad_request, observed storedpassed
I_EHR_COMPOSITION.get_versioned_composition-malformed_uidexpected bad_request, observed not_foundpassed
I_EHR_CONTRIBUTION.commit_contribution-delete_directoryexpected created, observed not_foundpassed
I_EHR_CONTRIBUTION.commit_contribution-deleted_member_with_dataexpected validation_failed, observed createdpassed
I_EHR_CONTRIBUTION.commit_contribution-ehr_status_incomplete_lifecycleexpected validation_failed, observed createdpassed
I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_typeexpected conflict, observed validation_failedpassed
I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_type_deletedexpected conflict, observed not_foundpassed
I_EHR_CONTRIBUTION.commit_contribution-ehr_status_valid_combinationsheader Last-Modified: expected present, got nonepassed
I_EHR_CONTRIBUTION.commit_contribution-fail_modify_non_existing_directoryexpected validation_failed, observed precondition_failedpassed
I_EHR_CONTRIBUTION.commit_contribution-full_ehr_statusheader Last-Modified: expected present, got nonepassed
I_EHR_CONTRIBUTION.commit_contribution-minimal_ehr_statusheader Last-Modified: expected present, got nonepassed
I_EHR_CONTRIBUTION.commit_contribution-non_exiting_optexpected template_not_found, observed validation_failedpassed
I_EHR_CONTRIBUTION.commit_contribution-update_existing_directoryheader Last-Modified: expected present, got nonepassed
I_EHR_CONTRIBUTION.commit_contribution-valid_directoryheader Last-Modified: expected present, got nonepassed
I_EHR_CONTRIBUTION.get_contribution-version_ref_shapeheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.create_directory-ehr_not_modifiableexpected updated, observed precondition_missingpassed
I_EHR_DIRECTORY.create_directory-root_archetype_id_mismatchexpected validation_failed, observed createdpassed
I_EHR_DIRECTORY.create_directory-versioned_id_itemsexpected created, observed bad_requestpassed
I_EHR_DIRECTORY.create_directory-wide_id_itemsexpected created, observed bad_requestpassed
I_EHR_DIRECTORY.delete_directory-ehr_with_directoryheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.delete_directory-empty_ehrexpected not_found, observed precondition_failedpassed
I_EHR_DIRECTORY.delete_directory-etag_names_new_versionheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.get_directory-deleted_headheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.get_directory_at_time-deleted_at_timeheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.get_directory_at_version-deleted_versionheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.update_directory-empty_ehrexpected not_found, observed precondition_failedpassed
I_EHR_DIRECTORY.update_directory-root_archetype_id_mismatchexpected validation_failed, observed updatedpassed
I_EHR_DIRECTORY.update_directory-stale_if_matchheader ETag: expected the latest version uid, got nonepassed
I_EHR_SERVICE.create_ehr-committal_headerscommit_audit/description/value: path resolves to nothingpassed
I_EHR_SERVICE.create_ehr-invalid_statusexpected validation_failed, observed createdpassed
I_EHR_SERVICE.create_ehr-wrong_methodheader Allow: expected a value matching “.*(GET.*POST|POST.GET).”, got nonepassed
I_EHR_SERVICE.get_ehr-malformed_ehr_idexpected bad_request, observed not_foundpassed
I_EHR_STATUS.clear_ehr_modifiable-bad_ehrexpected not_found, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_modifiable-existing_ehrexpected updated, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_modifiable-invalid_bodyexpected validation_failed, observed bad_requestpassed
I_EHR_STATUS.clear_ehr_modifiable-stale_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_queryable-bad_ehrexpected not_found, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_queryable-existing_ehrexpected updated, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_queryable-invalid_bodyexpected validation_failed, observed bad_requestpassed
I_EHR_STATUS.clear_ehr_queryable-stale_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_ehr_status-at_time_futureexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_ehr_status-at_time_omittedexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_ehr_status_at_version-addressed_versionexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_versioned_ehr_status-at_time_futureexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_versioned_ehr_status-at_time_omittedexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_versioned_ehr_status-contained_uid_formheader Last-Modified: expected present, got nonepassed
I_EHR_STATUS.get_versioned_ehr_status-container_shapeowner_id/type: “ehr” != expected “EHR”passed
I_EHR_STATUS.get_versioned_ehr_status-xmlcanonical-xmlheader Last-Modified: expected present, got nonepassed
I_EHR_STATUS.set_ehr_modifiable-bad_ehrexpected not_found, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_modifiable-existing_ehrexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_modifiable-invalid_bodyexpected validation_failed, observed bad_requestpassed
I_EHR_STATUS.set_ehr_modifiable-missing_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_modifiable-stale_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_queryable-bad_ehrexpected not_found, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_queryable-existing_ehrexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_queryable-invalid_bodyexpected validation_failed, observed bad_requestpassed
I_EHR_STATUS.set_ehr_queryable-missing_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_queryable-rm_version_emptyexpected validation_failed, observed bad_requestpassed
I_EHR_STATUS.set_ehr_queryable-stale_if_matchexpected updated, observed precondition_missingpassed
I_ITS_REST_REVISION_HISTORY.versioned_ehr_status_revision_history-two_versionscanonical-jsonexpected updated, observed precondition_missingpassed
I_QUERY_SERVICE.execute_ad_hoc_query-bare_ehr_scope_headerrow count 100 != expected 1passed
I_QUERY_SERVICE.execute_ad_hoc_query-bare_ehr_scope_paramrow count 100 != expected 1passed
I_QUERY_SERVICE.execute_ad_hoc_query-unknown_ehr_scopeexpected not_found, observed okpassed
I_QUERY_SERVICE.execute_stored_query-fetch_with_topexpected stored, observed bad_requestpassed

Both servers’ capability conformance, from each party’s committed verdicts (generated, diff-guarded; see Conformance for how to read the grid):

And the per-chapter outcomes side by side. Both charts render the same chapter-and-band taxonomy, so they read band-for-band: a band EHRbase did not exercise shows as an explicit no cases row in the same position. Compare the printed counts, not the bar lengths: each chart scales its bars to its own widest band, and the legend states that scale.

How to read the EHRbase column

Three mechanisms decide what the EHRbase column can say, and all three are visible in the committed record:

  • Selection. A case is in EHRbase’s verdict scope only if its capabilities intersect what EHRbase claims and its behaviour is dated at or below the specification versions EHRbase declares. EHRbase declares ITS-REST 1.0.3 while the catalogue realizes 1.1.0, so every Release-1.1.0-dated behaviour is cited out of scope rather than driven. Unclaimed surfaces drop out the same way, and they drop out before the request: a case whose capabilities a party’s statement does not claim is recorded not applicable with its citation instead of being driven, so an unclaimed surface cannot produce a red row at all. Anything a record still carries from before that rule reached the driver is excluded from the in-scope tally above and is not listed as a divergence below. The headline is the verdict scope for exactly that reason.
  • Inconclusive, not failed. A row is inconclusive when EHRbase answered with a status the operation’s specification-cited outcome map does not contain, or when the exchange that would have established the case’s ground was refused. The runner never guesses a verdict, and an inconclusive row is never counted as a failure of the behaviour under test.
  • Report-only grounds. Where a case rests on an openEHR silence our ambiguity register marks report-only (an upstream problem report still open) the row is recorded but does not gate either party’s verdicts.

Where the red rows do concentrate is the content chapter: no content case passed (each one either failed or never established its ground) and the direction is the opposite of a permissive server. The catalogue’s content cases are accept/reject decision tables committed against a template the case itself provisions, and in the ones that ran, EHRbase’s rejected rows pass while its accepted rows fail: it refuses documents its own template admits. Some rows require a plain bad request rather than a semantic refusal (the document is malformed, not merely invalid), and there EHRbase refuses too, in the wrong class.

The principal EHRbase divergences, stated plainly

Each item below is a red row in the committed EHRbase record, restated from what that record holds: the outcome the specification-cited expectation required versus the outcome observed. Except where marked, every one was selected for EHRbase’s own declared ITS-REST 1.0.3; the Release-1.1.0-dated behaviours never reach this list. The record carries the case id, the failing step, the rows driven and the reason string. It is not a wire transcript, so nothing is quoted here beyond what it actually captured.

  • Every EHR_STATUS write is refused. Setting or clearing is_queryable/is_modifiable is realized as a read-modify-write: read the status, flip the flag, PUT it back with the version identifier from the server’s own ETag in a quoted If-Match, the exact form the released overview’s own example shows. Every one of those rows, including the plain happy path, answers 400 instead of updating: the status the same released section reserves for a client that did not provide If-Match at all. The rest of that path is therefore unreadable: a row meant to distinguish a semantic refusal from a malformed request gets that same 400, so the record cannot say what EHRbase’s validation would have done.
  • No Last-Modified anywhere. The released overview says both ETag and Last-Modified SHOULD be included for versioned resources; EHRbase sends no Last-Modified on any of them: contribution commits, contribution reads, directory reads, updates and deletes, and the versioned EHR_STATUS reads in JSON and XML alike. It is the single widest header difference in the record. Relatedly, a stale If-Match on a directory update is refused without the ETag naming the latest version that the refusal is supposed to carry.
  • Some model-invalid content is accepted. POST /ehr with an EHR_STATUS that is an archetype root carrying no archetype_details is created rather than refused, while the four sibling rows in the same case (missing mandatory members, an undecodable polymorphic slot) are refused correctly, so this is a validation gap, not a missing validator. A directory whose root archetype identifier does not match the one required is likewise accepted, on both the create and the update path, and a CONTRIBUTION member marked deleted that still carries data commits instead of being refused.
  • Contribution refusals land in the wrong family. A CONTRIBUTION whose EHR_STATUS member carries an invalid change type is refused as a semantic validation error where the catalogue requires a conflict; the variant that deletes the mandatory EHR_STATUS answers not-found instead. A CONTRIBUTION naming a template that does not exist answers a generic validation error rather than the template-not-found branch, and a modification against a directory that does not exist answers a failed precondition.
  • A missing directory is dressed as a failed precondition. Both the update and the delete of a directory on an EHR that has none answer 412 Precondition Failed where the released text requires not-found. HTTP requires the opposite order, since a failure detectable before the precondition is evaluated takes precedence over evaluating it. On the same operation a genuinely stale If-Match answers 409, a status the operation’s outcome map does not contain at all, so that row is recorded inconclusive rather than red.
  • A malformed identifier in the path answers not-found, not bad request. Given a path segment that is not a UUID at all, both the EHR read and the versioned-COMPOSITION read report a miss instead of a client error. The treatment is not uniform: the malformed version-identifier rows pass.
  • Stored-query names the released grammar admits are refused. The released qualified-name grammar makes the namespace optional (it lists a plain my_compositions among its valid examples) and puts the dot inside the query-name character set, with :: as the only separator. Both a plain unqualified name and a namespace-less dotted name are refused as bad requests. A third store (a fully qualified name whose AQL carries a TOP) is refused as well, so the paging conflict that case exists to test never got driven.
  • The stored-query listing does not honour the prefix. The released list operation’s own worked example lists “all versions of all queries with names starting with org.openehr”. After storing two names under one prefix, the case’s prefix listing comes back with no first row to read a name from at all, so a client cannot discover what a namespace holds.
  • Version-grammar refusals are missing at the store. A version that is only a prefix and a pre-release version are both stored where the catalogue requires a bad request, and a second store under a case-variant of an existing name is accepted where a conflict is required.
  • An XML Accept on a JSON-only operation is answered, not refused. The released stored-query listing declares Accept: application/json and nothing else, and the overview requires 406 Not Acceptable when an XML request cannot be fulfilled. Both the listing and its version-get sibling answer success instead.
  • A query scoped to one EHR is executed over all of them. A client restricts an ad-hoc query to a single record with the ehr_id query parameter or the openehr-ehr-id request header. The service model defines that input as the specific set of EHRs on which to execute the query, and the released REST query chapter says the same thing from the other side: a population query is one that does not use the parameter to constrain the scope. EHRbase discards both carriers. In the record, a bare projection over EHRs scoped to one freshly created EHR comes back at the request’s own row limit instead of the single scoped row, and a scope naming an EHR that does not exist answers an ordinary result set rather than reporting the unknown EHR. Reproduced live against a composed EHRbase, its own response metadata discloses the query it actually executed, with a row limit appended and no EHR predicate added; the header behaves exactly like the parameter. Naming the EHR inside the AQL works, so what is dropped is specifically the request-level scope. Stated at its true strength: the released REST text puts support for these parameters at SHOULD and the semantics are the service model’s, so this is a divergence from service-model semantics on a released carrier, not the breach of a REST-level MUST.
  • A 405 carries no Allow header, which RFC 9110 makes mandatory on that status; and the versioned-EHR_STATUS container names its owner with a lower-case ehr type where the Reference Model’s object reference spells it EHR.
  • (1.1.0-grounded) The template upload refuses a JSON Accept. Asked for application/json it answers 406 (the refusal body the record captured reads "No acceptable representation") and serves XML only, while the released parameter enumeration for that header lists JSON first. This single refusal is what makes most content-chapter rows inconclusive: the runner’s provisioning uploads ask for JSON, EHRbase refuses, and the case’s ground never exists.

Two things the record deliberately does not support, and therefore are not claimed here: it captures no response bodies beyond the few a case records, so no error text is quoted above that the record does not contain; and it says nothing about EHRbase’s canonical-XML root namespace: the case that would have established it never got past provisioning, and the negotiated-namespace siblings are out of scope for EHRbase’s declared options.

Where the difference is a reading of a silence

Some red rows are not EHRbase’s fault, and are called out rather than counted.

  • Which version a version-less stored-query store lands on. A store with no version in the URL has to land at some version, and no released sentence says which. Our suite pins the lowest slot because a suite must pin something; EHRbase continues the existing series instead, so a store beside an already-stored higher version takes the next one up. Both are defensible readings, so those two rows record a difference of house convention. The open question is with openEHR, not with either implementation.

One half of the EHR-scope row above is a silence too, and a much narrower one than this page previously claimed: the service model declares an error for a query scoped to an EHR that does not exist, and the released REST text binds that error to no status, so which refusal a conformant server owes is genuinely open, and our suite resolves it to not-found. What the scope does is not open, which is why that row is published as a divergence rather than as a reading.

Every silence is recorded in the runner’s typed ambiguity register and reported upstream: a specification silence is never resolved privately, and never resolved by looking at what a server happens to do.

Performance

Both systems run the same committed step-load stress instrument (veredictum stress) on their own freshly seeded cnf.scale.10k corpus: the geometric ladder climbs until the system leaves the envelope (p99 over the budget or errors past tolerance), then bisects to the maximum sustainable throughput. Every number derives from the two committed stress.json reports; each load step embeds re-checkable histograms and, where sampled, per-container resource telemetry.

max sustainable throughputworst p99 at the kneeDB peak CPU at the kneeSUT peak RSS at the knee
ferroehr512 req/s134 ms101 %247 MB
EHRbase0 req/s— ms— %— MB

A stress report is exploration evidence: it earns no conformance class (classes are earned exclusively by the hour-long measured class runs) and carries no class vocabulary — the chart shows where each system breaks.

Reading the stress table honestly

The step-load ladder declares a rate sustained only if the whole envelope holds at that rate: the latency budget and the error budget. EHRbase’s ladder found no sustained rung, and its own report records two simultaneous breaches on every rung it tried, down to the lowest one:

  • The error tolerance. Composition commits and updates, contribution commits, EHR_STATUS updates, directory creates and the item-tag operations error deterministically; these are the same wire refusals the conformance section above lists, met again under load.
  • The latency budget, on the ward-round query, by more than an order of magnitude.

Because both breaches are already present at the ladder’s lowest rung, neither is a throughput ceiling: EHRbase’s entry in the generated table says the envelope never held, not how fast the server can go, and it must not be read as a speed comparison. The per-operation latencies its report carries are honest observations from those same rungs.

Method, in one paragraph

The conformance instrument derives every expected outcome from the openEHR specifications (never from either server’s observed behaviour) and runs against real composed deployments of both systems (scripts/conformance.sh, CONF_SUT=ehrbase for the EHRbase side). The stress instrument drives the same hospital-simulation workload (admissions, observations, medication rounds, lab contributions, chart reviews, corrections, discharges) built from official CKM templates with seeded determinism, so both servers receive byte-identical requests from the same corpus; latencies are coordinated-omission-corrected from each request’s planned arrival instant, and the ladder bisects to the last rate held inside the envelope. Each run records its own environment, and the two runs’ environments differ. The full method chapters: Conformance · Benchmarks.

Rust crates

FerroEHR’s openEHR specification layer is published on crates.io as eight standalone Rust crates, usable without the CDR. They are the same crates the server itself is built on: the spec types are generated deterministically from the official openEHR machine-readable artifacts (BMM/XSD/OpenAPI), and the engines (ADL, AQL, serialization runtimes) are hand-written against the vendored specification text.

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, and canonical printer
openehr-itsITS-JSON, ITS-XML, ITS-REST 1.1.0Canonical JSON + XML codecs, the generated ITS-REST contract, OPT 1.4, Simplified Formats (FLAT/STRUCTURED/Web Template)
[dependencies]
openehr-rm = "0.0.64"
openehr-its = "0.0.64"

All eight are edition 2024 with an MSRV of Rust 1.97, and all eight inherit the workspace lint table, including unsafe_code = "forbid", which no attribute anywhere in the crate can relax. There is no unsafe block in the published specification layer.

Generations: reaching more than one specification version

A crate generated from more than one openEHR generation exposes each as a version-named module: openehr_rm::v1_1 and openehr_rm::v1_2, openehr_base::v1_2 / v1_3, openehr_am::v1_4 / v2_4, openehr_lang::v1_0 / v1_1, openehr_term::v3_1. The crate’s prelude re-exports the current generation, so ordinary code needs no module path; an older generation is reached by naming its module in full:

#![allow(unused)]
fn main() {
// The current generation (RM 1.2.0), via the crate prelude:
use openehr_rm::prelude::Composition;

// The released generation (RM 1.1.0), via its own module path. Import
// renaming is not used anywhere in this project; where both generations
// appear in one file, give one of them a type alias:
type Rm110Composition = openehr_rm::v1_1::composition::composition::Composition;
}

Each generation mirrors its specification’s own package structure, so the path after the generation module reads the same in both. Every generation module also carries its own prelude; no name from one generation is ever mixed into another.

openehr-lang is the one crate whose generations also differ in what they contain, because upstream publishes two BMM meta-models side by side. Its v1_1 generation carries them as sibling specification units: the stable, tool-implemented BMM v2.x model (bmm, its persistence form bmm_persistence, and the beom expression model) is on the generation’s prelude, while the paused BMM3 model (bmm3) is reachable only by full module path. They cannot be merged (a set of class names, BmmClass, BmmModel and BmmPackage among them, occurs in both units with materially different shapes) so the prelude carries the stable units and the choice stays explicit at the use site. The older v1_0 generation is what its release actually defines: the BMM model plus an ODIN reader, with no BEL or Expression-Language notation.

Versioning

The package version is the crates’ own independent SemVer line: it tracks this implementation’s code and moves freely with fixes and improvements, never with the vendored openEHR specification versions. While the line is 0.0.x, expect breaking changes between releases, which always ship in lockstep across all eight crates.

The implemented specification version is therefore a separate datum, per generation. Each generated crate emits a Generation enum that is the only authority for it: one variant per generation module, Default marking the current one, each variant carrying its specification version as a const fn, and Display/FromStr round-tripping the module token:

#![allow(unused)]
fn main() {
assert_eq!(openehr_rm::Generation::default().spec_version(), "1.2.0");
assert_eq!(openehr_rm::Generation::V1_1.spec_version(), "1.1.0");
assert_eq!(openehr_rm::Generation::default().as_str(), "v1_2");
}

There is deliberately no crate-level SPEC_VERSION constant in the generated crates: a single constant would contradict a caller using a non-current generation. Exactly three crates implement one specification each and do expose one: openehr_its::SPEC_VERSION, openehr_query::SPEC_VERSION, openehr_adl::SPEC_VERSION.

Taking only the part of openehr-its you need

openehr-its layers its features so a browser or embedded consumer does not have to compile an HTTP server to read a template. The parsing spine is jsonxmlopt14flat, each pulling in the one below it. Three layers sit beside the spine and are declined independently: cache (the async WebTemplate cache, the crate’s only moka user), schema-validation (validation against the compiled-in ITS-JSON RM schema, its only jsonschema user) and rest-server (the generated ITS-REST contract and its response runtime, which is what brings in axum). The default feature full is all seven, so an existing dependency line keeps the crate it had.

flat and opt14 build for wasm32-unknown-unknown, so a browser consumer can call flat::build_web_template or read an OPT 1.4 template:

[dependencies]
openehr-its = { version = "0.0.64", default-features = false, features = ["flat"] }

With default-features = false and no feature at all, the crate compiles to the SMART App Launch scope grammar alone: std-only, with no dependency of any kind, so a REST client can parse scope strings with the grammar the CDR enforces instead of carrying a second one.

Releases

The eight crates publish as the last leg of the release pipeline, gated on a human: the leg runs in a protected environment with a required reviewer, so the run pauses there and nothing reaches crates.io without an explicit approval. A separate dispatch lane covers a publish between releases and the recovery path when the release leg fails.

Both lanes authenticate with crates.io Trusted Publishing (OIDC, no long-lived token anywhere) and publish the crates one at a time in dependency order, treating “already exists on the index” as done, so a run interrupted halfway can be re-run to finish the set. The lane then reads the registry back and refuses to report success unless all eight resolve at the same version, because while the line is 0.0.x a straggler makes its siblings’ internal requirements unresolvable for every consumer.

Licensing

The eight crates fall into two groups.

The five generated model crates (openehr-base, openehr-rm, openehr-am, openehr-lang, openehr-term) are Apache-2.0, the licence of the openEHR machine-readable artifacts they are generated from, so any Rust project can use them, in proprietary and hosted products included. They embed material derived from those artifacts (generated types carrying specification documentation text), which is Apache-2.0 as well, and their generated files name the openEHR Foundation as a second copyright holder.

The three hand-written engines are under the Business Source License 1.1, the licence of the FerroEHR application (each ships its LICENSE with the parameters): openehr-query and openehr-adl declare BUSL-1.1 and ship only their own Rust sources, the README and that text; openehr-its declares BUSL-1.1 AND Apache-2.0, because its generated codecs and REST contract derive from the Apache-2.0 openEHR XSD, OpenAPI and BMM artifacts and it packages the vendored ITS-JSON schema as bytes, so it ships both texts and its README carries that file’s attribution (upstream repository, the exact vendored commit, and the licence) inside the package, where it travels with any redistribution. Non-production use of these three is free, production use is free for Non-Commercial Purposes, and any other production use, hosting for third parties or distribution for a fee needs a commercial licence, exactly as for the application.

openehr-term carries a third term, because it embeds a different kind of openEHR material: the official terminology XML (the five language bundles, the external-terminology index, and the property/unit data) is CC-BY-SA 3.0, redistributed verbatim with attribution. Its manifest declares Apache-2.0 AND CC-BY-SA-3.0 and the package ships both license texts, so the declaration a consumer reads names every license the crate’s own bytes are under. If you redistribute the crate, the terminology data travels under CC-BY-SA 3.0.

Versions 0.0.56 and earlier were published under the MIT terms in force at the time (MIT AND Apache-2.0 for the crates that embed openEHR material) and keep them; 0.0.57 was never published; 0.0.58 and 0.0.59 are Apache-2.0 for all eight; from 0.0.60 the three engines are BUSL-1.1 as stated above. Published versions keep the licence they were published with. The full picture, including the vendored material that never reaches a published package, is in Licensing.

The openEHR specifications themselves are © the openEHR Foundation.

Contributing

FerroEHR is source-available (the Business Source License 1.1 for the application, Apache 2.0 for the five generated openehr-* model crates, and vendored third-party material under its upstream terms; see Licensing & legal) and welcomes contributions. This chapter is a short orientation for anyone who wants to file an issue, report a vulnerability, or open a pull request; the authoritative documents live in the repository and are linked below. You keep your copyright, and there is no separate agreement to sign; the terms a contribution lands under are set out in CONTRIBUTING.md § Licensing of contributions. Why FerroEHR exists explains what the project offers the organisations that run it and build on it, and what it asks in return.

A bug report with a reproducing request, a conformance case for behaviour nothing covers yet, a specification ambiguity you had to resolve in your own integration, a documentation correction, or a measurement from your own hardware all count.

Where to start

The three governing documents are kept in the repository root:

  • CONTRIBUTING — the practical rules for setup, the required checks, and pull requests.
  • Code of conduct — the Contributor Covenant (v2.1) the community follows.
  • Security policy — how to report a vulnerability privately.

Setting up

The Rust toolchain is pinned by the repository’s rust-toolchain.toml (stable 1.98.1), so rustup installs the right version automatically on your first build. The declared minimum supported version is lower (Rust 1.97) and CI verifies it independently with cargo hack, so do not reach for a language feature newer than that. The edition is 2024.

Two extra tools are needed for the full test suite:

  • A PostgreSQL 18 server for the database-backed tests. The shared test harness starts (or re-adopts) one reusable container if Docker is running; otherwise point it at a server you already run with FERROEHR_TEST_PG_URL (its role must be able 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

# clippy is three lanes: the viewer's `hydrate` and `ssr` features are
# mutually exclusive, so it is excluded from the workspace lane and linted
# per-feature on both of its targets.
cargo clippy --workspace --exclude ferroehr-viewer --all-targets --all-features -- -D warnings
cargo clippy -p ferroehr-viewer --all-targets --features ssr -- -D warnings
cargo clippy -p ferroehr-viewer --target wasm32-unknown-unknown --features hydrate -- -D warnings

# rustdoc lints + doctests (the rustdoc lint table is inert without a doc run)
RUSTDOCFLAGS='-D warnings' cargo doc --workspace --exclude ferroehr-viewer \
  --all-features --no-deps --document-private-items
RUSTDOCFLAGS='-D warnings' cargo doc -p ferroehr-viewer --features ssr --no-deps
cargo test --workspace --doc

cargo deny check                       # subsumes cargo-audit: same RustSec DB, plus yanked/licenses/bans/sources
cargo machete                          # unused dependencies
cargo hack check --rust-version --workspace   # the declared MSRV really builds
bash scripts/checks/codegen-drift.sh    # generated layer matches the vendored specs

Beyond those, a family of small single-purpose scripts under scripts/checks/ runs on every pull request: comment and doc-comment style, default values declared inline in their struct’s Default impl, HTTP statuses compared as types rather than as numbers, licensing declarations and SPDX headers, no Python anywhere in the tooling, and the documentation-claim gates for this site. Each is a plain bash script you can run yourself, and the failure message names what to fix.

CI adds a few gates that need more than a checkout: a container smoke test that composes the built server image against the database image, the browser end-to-end battery for the viewer (bash scripts/ui-e2e.sh), the Helm chart render and boot lanes, and the changelog, crate-version, and attribution guards. Viewer-only work has its own local battery; see the repository’s CONTRIBUTING.md.

Important

Two rules are absolute. Never hand-edit a generated file: anything under a // @generated … DO NOT 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 an entry to the changelog and updates the matching page of this documentation, both in the same pull request. CI guards enforce both.

Personal data

The server keeps clinical content, the identities it belongs to and the map between them in three separate schemas, reachable by separate database roles. A change can move that boundary without meaning to, so four rules hold everywhere in the repository.

  • Synthetic data only: tests, fixtures, seeds, examples and screenshots use invented values. A real name, national identifier, address, phone number, email or date of birth never goes into the repository, an issue, or a pull request body.
  • No identifiers in telemetry: logs, traces, metric labels and 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 telemetry.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

This page is the complete licensing picture for FerroEHR: what the project’s own code is licensed under, which third-party material ships inside the repository and the container images, and the trademark and lineage acknowledgments. It is a summary for evaluators and deployers, not legal advice.

Do you need a commercial licence?

FerroEHR is source-available under the Business Source License 1.1, which is not an OSI-approved open-source licence. LICENSE is the authority and names the Licensor, the Licensed Work, the Additional Use Grant and the Change Date; this table is the same boundary in the order people ask about it, and the sections below give the full text of each rule.

What you are 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.

Installing a commercial licence

A commercial licence is a signed token file the licensor issues to you. Place it where the server can read it and point [licence] file (or FERROEHR__LICENCE__FILE) at it; GET /ferroehr/rest/status then reports licence.use = "commercial" with your organisation as licensee. Without a token the same field reports the non-commercial grant every build embeds. The server behaves identically under either; identifiers it mints carry a few bits derived from the licence id in place of random bits, so a record states which grant it was written under, and nothing else.

Each version becomes Apache License 2.0 four years after that version is published. A commercial licence starts with a short conversation with the maintainer named in MAINTAINERS.md.

The eight openehr-* crates on crates.io are a separate question. The five generated model crates are Apache-2.0, so any Rust project can use them without a licence conversation; the three hand-written engines carry the same Business Source License as the application. Full detail is under FerroEHR’s own code below.

FerroEHR’s own code: the Business Source License 1.1

Everything written for this project (the server and application crates, the code generator and tooling, the viewer, and the three hand-written specification engines openehr-query, openehr-adl and openehr-its) is licensed under the Business Source License 1.1, SPDX identifier BUSL-1.1. The Licensor is Ruben Talstra. The copyright holder is stated as Ruben Talstra, identically in LICENSE, in REUSE.toml, and in every first-party file header; a CI gate compares the three so they cannot drift apart.

FerroEHR is source-available. The Business Source License 1.1 is not an OSI-approved open-source licence, and this project does not claim that it is.

What the licence allows without asking anyone. You can read the source, build it, modify it, and redistribute it without a fee. All non-production use is permitted: development, testing, evaluation and prototyping. Production use is permitted for Non-Commercial Purposes, which the licence defines as personal use, academic or scientific research, teaching, and use by a non-profit organisation or public body that is not in the course of a business, does not deliver a service for payment, and is not for commercial advantage.

What needs a commercial licence from the Licensor. Any other production use, including the delivery of health care or any other service for payment. A hospital, clinic or care provider running FerroEHR for its patients needs a commercial licence, and so does a vendor, integrator or any company running it in production. Two uses need one in every case, whoever you are:

  • offering FerroEHR, or a work derived from it, to third parties as a hosted, managed or embedded service, meaning a service through which anyone other than you and your affiliates stores, manages or queries health data held by it;
  • selling, sublicensing or otherwise distributing FerroEHR for a fee, on its own or as a component of another product.

Companies and care providers building on FerroEHR are wanted here, and the commercial licence is the normal path for them. It starts with a short conversation with the maintainer named in MAINTAINERS.md: Ruben Talstra, @rubentalstra on GitHub.

The Change Date. Each version becomes available under the Apache License 2.0, its Change License, four years after that version is published.

What the change does not affect. Releases v3.0.0 through v4.0.17 stay under the MIT terms they were published with, and so do the openehr-* crate versions 0.0.56 and earlier on crates.io (MIT AND Apache-2.0 for the crates that embed openEHR-derived material); openehr-query, openehr-adl and openehr-its 0.0.58 and 0.0.59 stay Apache-2.0. Relicensing changes future versions only.

The conformance instrument is no longer part of this repository. It is Veredictum, an independent project under Apache-2.0, and the conformance pipeline here consumes it at a pinned version. The vendored test corpora this repository still carries keep their upstream terms exactly as the table below states.

Five of the eight published openehr-* spec crates are the exception: they are Apache-2.0, not BUSL-1.1. openehr-base, openehr-rm, openehr-am, openehr-lang and openehr-term are the generated openEHR model, published on crates.io under the licence of the openEHR machine-readable artifacts they are generated from, so any Rust project can use them, in proprietary and hosted products included, with no commercial licence involved. They embed openEHR-derived material (specification documentation text in the generated doc comments), which is Apache-2.0 as well; their generated files name the openEHR Foundation as a second copyright holder. openehr-term also embeds the official openEHR terminology XML, which is CC-BY-SA 3.0 (see the table below) and is redistributed verbatim with attribution, so it declares Apache-2.0 AND CC-BY-SA-3.0 and ships both texts.

The three hand-written engines, openehr-query (the AQL parser), openehr-adl (the ADL engine) and openehr-its (the canonical codecs, the REST contract and the Simplified Formats), are the project’s own engineering and carry the Business Source License 1.1 like the application, each with its own LICENSE naming the crate as the Licensed Work. openehr-its declares BUSL-1.1 AND Apache-2.0: its generated codecs and contract derive from the Apache-2.0 openEHR XSD, OpenAPI and BMM artifacts and it embeds the vendored ITS-JSON schema, so it ships both texts and names the openEHR Foundation as a second holder. See Rust crates.

Vendored third-party material

The repository vendors external material verbatim: machine-readable specification artifacts the code generator consumes, the openEHR specification text used as the conformance oracle, and real-world clinical models and fixtures used as test corpora. Each family keeps its upstream license:

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, 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 pseudonymisationEuropean Data Protection BoardLicenseRef-EDPB-Reuse
Dutch acts and decrees (UAVG, Wabvpz, BW Boek 7, 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 state Apache-2.0, those of openehr-its state BUSL-1.1 AND Apache-2.0, and every other first-party Rust, shell, SQL and YAML file, the openehr-query and openehr-adl 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, and the file headers.
  • One check refuses copyleft license text inside this project’s own source, which is a conflict no reviewer reliably catches by eye.

What none of them can check is whether the prose on this page is correct. No tool can judge that, which is why any change to what the repository redistributes updates this chapter in the same pull request.

The CC-BY-SA specification text and clinical models are redistributed verbatim, with attribution: they are reference and test material, not part of the compiled server. The FerroEHR binary you deploy is built from this project’s own BUSL-1.1 code, plus the Apache-2.0 machine-readable inputs the generated crates carry, plus the CC-BY-SA 3.0 openEHR terminology bundle openehr-term compiles in (five languages, the external-terminology index, and the property/unit data). No copyleft obligation beyond those attribution-and-share-alike terms on the verbatim data attaches to anything shipped.

Rust dependencies

All third-party Rust crates are pinned in the workspace manifest and license-gated in CI with cargo deny, which checks licenses, security advisories, bans, and sources on every change. The allow-list is permissive (MIT, Apache-2.0, the BSD family, ISC, Zlib, BSL-1.0, Unicode-3.0, CC0-1.0, MIT-0, CDLA-Permissive-2.0) and deliberately admits two file-scoped weak copyleft licenses: MPL 2.0, and CDDL 1.0 as a single crate-scoped exception for the flamegraph renderer the profiling instruments use. Obligations under both attach to those crates’ own files, which are consumed unmodified. No strong copyleft (GPL, LGPL, AGPL, SSPL) is admitted, and a new dependency carrying one fails the build.

A separate FOSSA lane publishes dependency and license analysis from the committed CLI configuration for review. It is analysis-only by design and gates no merge; cargo deny is the gate.

Trademarks and lineage

  • openEHR® is the registered trademark of the openEHR Foundation. FerroEHR is an independent implementation of the openEHR specifications and is not affiliated with or endorsed by the openEHR Foundation.
  • FerroEHR began as a fork of EHRbase, developed by vitasystems GmbH and the Peter L. Reichertz Institute, and records that lineage in the labelled import commit at the root of its history. EHRbase itself remains Apache-2.0; no code from it is present in this tree, and it is consulted as prior art only. FerroEHR is not affiliated with or endorsed by the EHRbase project. The measured comparison between the two is published in both directions.

Questions

If you need a clarification for a compliance review, open a GitHub discussion or issue; provenance questions can usually be answered by pointing at the exact PROVENANCE.md and upstream pin.

Compliance overview

FerroEHR is software. It is not a controller, not a processor and not a certified organisation, so nothing on this page says that a deployment complies with anything. What this page does say is which technical controls the product ships today, which are planned and tracked in public, and which obligations stay with the organisation that runs it.

The page is written for three readers. A privacy officer wants the legal sources and the split of duties. A hospital CISO wants the controls and their status. A developer wants to know where the boundary between clinical and identifying data runs. Each of those three readings takes about ten minutes.

It is also written in two layers, because openEHR is not a national standard and FerroEHR is published for every country that runs it. The GDPR, the EDPB guidelines and the EHDS apply to every EU deployment and come first. After them come the national sections, one per jurisdiction, each on top of that same EU layer. The Netherlands is filled in first because that is where the project’s own deployments are, not because it is the default; the national law section says how to add another.

What FerroEHR claims, and what it does not

FerroEHR aims to be the first openly developed, source-available openEHR CDR with a published, tracker-backed EU compliance posture, and an EHDS conformity self-assessment is on its roadmap.

It holds no certification, no declaration of conformity and no third-party assessment. No page on this site will tell you that FerroEHR is “GDPR compliant”, “NEN 7510 certified” or “EHDS conformant”, because none of those statements would be true of a piece of software on its own.

What the project does instead is publish the record. A shipped control has a feature page in this book and an issue in the tracker that delivered it. A planned control has an open issue and appears here as planned, with its number. The control matrix is generated from the tracker, so a control cannot sit on this site as “planned” after it has shipped, or as “shipped” before it has.

Warning

The legal texts linked here change, and some of them are not yet in force. The EHDS obligations phase in over several years on the dates its own final provisions carry, and NEN republishes its standards on its own cycle (NEN 7510 was reissued in December 2024). Check each publisher directly before you rely on a statement here: EUR-Lex for EU law, wetten.overheid.nl for Dutch law, gesetze-im-internet.de and recht.bund.de for German federal law, Fedlex for Swiss federal law, the EDPB for guidelines, and NEN for the 7510 family. This page is a summary for evaluators and deployers, not legal advice.

The exact texts these pages were written against are vendored in the repository under docs/law/, each at a named consolidation with its digest and licence, and that directory’s README maps every act to the pages citing it. A statement here resolves to those bytes, and a publisher’s later amendment is a re-pin there, not a silent change of meaning.

The pseudonymisation boundary

A clinical record is identifying as soon as the record and the person can be put back together by whoever holds the database. Separating the two, and controlling who may rejoin them, is what GDPR Art. 4(5) calls pseudonymisation, and it is the control the EDPB Guidelines 01/2025 expect a supplier to describe rather than assert.

openEHR anticipated this. The Reference Model’s PARTY_SELF and Referring to the Patient from the EHR section names three schemes for pointing at the record subject, and calls the one that never sets external_ref anywhere in the EHR “the most secure approach”, because the link between record and patient is then held outside the EHR. The second scheme sets it once, in EHR_STATUS.subject, whose own class description says the association “may be done elsewhere for security reasons”. The reference itself is a PARTY_REF, described by the specification as an “identifier for parties in a demographic or identity service”, so it carries a namespace, a party type and an id and no demographic content of its own. The specification leaves the choice of scheme to the implementation. Where the schemas, the database roles and the resolve path live is FerroEHR’s own design, and no openEHR spec governs it.

What ships today

Clinical content and demographic parties live in separate PostgreSQL schemas, ehr and demographic, each with its own archival tier and its own runtime database roles. ferroehr_clinical and ferroehr_party, and a read-only twin of each, are NOINHERIT, hold explicit grants on one domain only, and carry an explicit revoke on the other and on linkage. The server refuses to start if that does not hold: a self-check enumerates every table, view, sequence and function in each domain and names the role and the object it can reach. The database refuses the mix as well, in both directions, so a code path that missed the split fails as a write error rather than leaking quietly.

The separation of schemas is unconditional. Pointing the demographic and linkage pools at their own DSNs ([storage.party] url and [storage.linkage] url) makes it a separation of credentials too, which is what stops one leaked connection string from reaching more than one domain.

The controls that apply across both domains are the same: role- and attribute-based authorization, per-EHR access settings, the domain-separated database roles, and the audit trail. Both sides carry openEHR’s own provenance, because every write commits a contribution and its audit in the same transaction, which is the versioning discipline the Change Control Package defines.

The map that rejoins the two is separated as well. linkage is a third schema under a fifth role, barred from both domains it joins and both of them from it, holding identifiers and a validity period and no attribute. The one crossing runs in the application over two pools and writes an access record. On the clinical side the subject reference is an opaque pseudonym once [privacy] subject_namespaces is declared, enforced by the write path and by a database trigger, and the identifier scanner refuses a national identifier anywhere in a clinical body.

flowchart LR
    client["API client"] --> server["FerroEHR server"]
    server -->|ferroehr_clinical| ehr[("clinical schema:<br/>versions and nodes")]
    server -->|ferroehr_party| demo[("party schema:<br/>parties and sealed identifiers")]
    server -->|ferroehr_linkage| link[("linkage schema:<br/>party to EHR resolve map")]
    server -->|audit writer| audit[("audit schema:<br/>ATNA record repository")]

What is planned

One piece of the programme (#3152 closed with v4.2.0) is still open, and it is about reading across the boundary for secondary use rather than holding the boundary.

Planned controlIssue
A secondary-use read model as a separate pseudonymisation domain, fed from the outbox, under project-level pseudonyms#3160

Cross-domain cohort queries shipped with v4.2.0 (#3159): a cohort question that spans both domains is answered through POST /query/cohort, each step on its own credential with identifiers only crossing between them, and a result set serving fewer distinct EHRs than the configured threshold is withheld and marked suppressed. Until the read model lands, secondary use runs on the primary store under those controls; size your access control, purpose limitation and risk assessment on that. The threat model states the residual risk at each boundary, and the DPIA page carries the risk register and the shipped controls by issue number.

The deployment profile

A deployment declares what it may hold with the top-level deployment_profile key (configuration). production refuses to start while a separation is missing and not accepted by name: separated credentials, separated clusters, a declared pseudonym namespace, an audit trail with a durable sink, schema preparation on its own credential. sandbox, the default, must not hold real personal data and says so on the banner, in the log and on GET /rest/status. The profile is FerroEHR’s own posture. GDPR Art. 4(5) asks that the additional information be “kept separately and … subject to technical and organisational measures”, not that it sit on a separate server, so one cluster with separated schemas and roles is a defensible reading; two clusters close the bridges no grant can, the superuser, an instance-wide point-in-time recovery, a single compromise, and the profile exists so that choice is made deliberately.

GDPR

Regulation (EU) 2016/679 places its duties on the controller and the processor. A CDR can only supply the technical measures those duties are met with. These are the articles a repository actually touches.

What the article asks 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 profileshipped, #3153, #3158, #3226Run the production profile with the separations it asserts, and hold any additional information the CDR never sees to the same standard
Art. 5(1)(f) integrity and 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 subject proxy of a subject whose only record this was goes with it, the cross-reference row naming the subject is erased in the linkage domain, the externalized multimedia blobs no surviving version references are deleted from the object store, and an erasure tombstone on the change-event stream tells each consumer to delete what it derived (Art. 19). What stays is the audit trail naming the ehr_id, because Art. 17(3)(b) withholds erasure where processing is necessary for compliance with a legal obligation and the access-logging periods are that 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 survivesshipped, #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, tenancy that fails closed, audit on by defaultshipped; the by-default setting Art. 25(2) asks for, so that a record is not accessible without the individual’s intervention, is planned, #3323Choose the restrictive settings. Two defaults favour compatibility instead: the per-EHR access default is open, and the audit fail mode is open
Art. 28(3)(e) to (h) what a processor’s contract must let it do: assist with the rights, assist with Arts. 32 to 36, delete or return the data at the end of service, and make audit information 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 {base}/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. The FerroEHR project operates nothing and is not your processor
Art. 30 records of processing activities, and 30(1)(f) the envisaged time limits for erasure of the different categories of dataThe effective configuration as a redacted JSON tree at GET {base}/admin/config, this book as a description of what the software does, and the retention register as the machine-readable answer to the time limits per category 30(1)(f) asks 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; the secondary-use read model as its own pseudonymisation domain is planned, #3160Decide whether the purpose can be met without identification and take that route where it can. Until the read model lands, secondary use runs on the primary store

EDPB Guidelines 01/2025 on pseudonymisation

The EDPB guidelines ask for something more specific than “we pseudonymise”: a named pseudonymisation domain, a stated attacker, and additional information kept where that attacker cannot reach it.

What the guidelines ask 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 agentshippedBuild the patient-facing access route; the CDR exposes the trail to an admin caller, not to the patient
Chapter III, EHR systems: a European interoperability software component and a European logging software component, with published technical documentationThe EHDS readiness page maps every Annex II requirement to a status with its evidence, the technical documentation page maps the Annex II documentation items, and the access trail records the logging component’s elements (audit)readiness shipped, #3168, #3169, #3170, #3171; the exchange format itself is an open question until the Article 36 implementing acts fix itFollow the implementing acts; the conformity assessment and the EU declaration are the manufacturer’s, and who that is for a source-available CDR is stated on the readiness page
Chapter IV, secondary useAQL over the stored record, a change-event outbox, and cohort queries across the pseudonymisation boundary with small-cell suppressionshipped, #3159; a separate pseudonymisation domain for secondary use is planned, #3160Deal with the health data access body; a CDR is not a data-holder’s permit process

National law

Everything above this line applies to every EU deployment. Everything below it is one country’s law on top of it, and a deployment reads only its own section plus the EU layer.

Three jurisdictions are filled in today: the Netherlands, Germany and Switzerland, each vendored under docs/law/ and read against the text. The product side of the split is plural too: the write-path identifier scanner ships a named rule per national identifier — Finland, the United Kingdom, the Netherlands, Norway and Sweden — each transcribing the checksum its own issuing register publishes, and a deployment selects the ones its content can carry. Denmark and Belgium are named there too, with the reason each is deliberately absent.

Adding a jurisdiction takes three things, and none of them is a change to how the scanner works: the national acts that sit on top of the GDPR, as a section in the shape of the Dutch one below (provision, what the product ships, tracker status, what the organisation must do); the national security and logging standards, in the shape of the NEN section; and, where the country issues a personal identifier with a published algorithm, a rule in app/ferroehr/src/privacy/detect.rs citing the register that defines it. Open a regulation request with the official source and the provisions that reach a repository, and the project vendors the text and records a status per provision, the way the acts below are handled. A checksum transcribed from a secondary source is refused, because a rule that guesses tells an operator their data was scanned when it was not.

The Netherlands: UAVG and Wabvpz

Two Dutch acts sit on top of the GDPR for a care provider. The UAVG is the national implementation act; the Wabvpz governs the burgerservicenummer in care and the patient’s electronic access to their record.

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; a separate secondary-use domain under project-level pseudonyms is planned, #3160Decide 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.external], which is the interface gematik’s terminology server exposesshippedPoint the terminology route at the server gematik runs and adopt the information objects the KBV publishes; the text names no serialization, and neither does this page
SGB V § 360 Abs. 16, electronic prescription transport outside the telematics infrastructure is 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; the rebuildable read model under project-level pseudonyms is planned, #3160Write 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 periodshippedForward 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 {base}/admin/config, records of processing pre-filled with what the software does, and the retention and security-measure inputs Art. 12 Abs. 2 lit. e and f ask 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; a separate secondary-use domain under project-level pseudonyms is planned, #3160Set 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; FerroEHR has no emergency-access mode and 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

Shared responsibility

Almost every obligation in EU and national health-data law rests on the controller, and where FerroEHR is operated on that controller’s behalf, on the processor. A repository supplies technical measures. It cannot hold a legal basis, sign a processing agreement, notify a supervisory authority or run a management system.

This page draws that line obligation by obligation, so you can see at a glance which part of the work the software has already done and which part is still yours. No openEHR specification governs any of it; the division below follows the legal texts each row links.

How to read the tables

Each row names one obligation and links its official source. The middle column is what FerroEHR provides, linked to the page that documents it, or to the open issue when the control is planned rather than shipped. The right column is the work that stays with the deploying organisation.

“Nothing” in the middle column is a real answer and appears wherever it is the true one.

Note

The FerroEHR project is not your processor. It publishes software; it operates nothing on your behalf and holds none of your data. Where a row says “the processor”, it means whoever runs the deployment, which may be you.

GDPR

Every row below cites Regulation (EU) 2016/679. The duties in it belong to the controller and the processor. The middle column is only the technical measure FerroEHR supplies toward one of them.

ObligationWhat FerroEHR providesWhat the deploying organisation does
Art. 5(1)(e) storage limitationAudit-trail retention as a configured period, and irreversible physical deletion of an EHR through the admin APISet the retention schedule and execute it; openEHR versions are append-only until you delete the record
Art. 5(2) accountabilityAn audit trail of every access, openEHR’s own contribution and audit chain on every write, and a published conformance recordRetain the evidence and be able to produce it on demand
Art. 6 and Art. 9(2) legal basis and the condition for health dataNothing. Software cannot hold a legal basisEstablish the basis and the Art. 9(2) condition, per purpose, before data is entered
Art. 24 and Art. 25 responsibility, and protection by design and by defaultDeny-by-default authorization, per-EHR access settings, tenancy that fails closed, audit on by defaultChoose the restrictive settings, and document why the chosen configuration is appropriate
Art. 28(3)(e) to (h) what a processor’s contract must let it doThe rights operations in the rows below for (e); the trail, the threat model and the published control documentation for (f); EHR Extract export and physical deletion at the end of service for (g); GET {base}/admin/config, the trail and verifiable release artifacts as the evidence (h) asks forConclude the processing agreement with whoever operates the deployment, and audit them
Art. 30 records of processing activitiesThe effective configuration as a redacted tree at GET {base}/admin/config, and this book as a description of what the software doesWrite and maintain the record; only you know the purposes, the recipients and the transfers
Art. 32 security of processingTLS 1.3 with optional mutual authentication, authentication and access control, per-version signing, a tamper-evident audit chain, domain-separated database rolesSupply everything below the application: the database, its backups, the network, the platform. See Cluster hardening
Art. 32(1)(d) regularly testing the measuresA storage-integrity sweep and rebuild, an audit-chain verification query, and a conformance suite that runs against your own serverSchedule the checks, alert on their output, and test your restore
Art. 33(3)(a) and 34(3)(a) breach notification, and the measures that remove the duty to tell patientsThe evidence a breach assessment needs: who read what, when, per patient and per agent, and whether the trail itself is intactDetect, assess and notify within the deadlines. 34(3)(a) lifts the duty to inform patients only where the measures were applied to the data affected, and the application seals national identifiers only, so encryption of clinical content at rest is the database’s and the disk’s. The regulation says nothing about encryption at rest; the measure is yours to choose
Art. 12(3) act on a rights request within one month, electronicallyEvery right below is served by an API call, so the answer is produced in the run that handles the requestStart the clock at receipt, verify the requester, and use the two-month extension only with the reasons the article asks for
Art. 15 and 20 access, a copy, and portabilityThe full record over the openEHR REST API in canonical JSON or XML, the simplified FLAT and STRUCTURED formats, and EHR Extract export for a whole record, which another openEHR system imports directly; the recipients of 15(1)(c) from the per-patient trail searchAuthenticate the data subject and build the patient-facing route. The purposes and the storage period of 15(1) come from your own records, and the portability right reaches consent-based or contract-based processing only (20(3) excludes a public-interest task)
Art. 16 and 17 rectification and erasureVersioned correction with the prior version retained, which is the supplementary statement Art. 16 allows, and physical, irreversible deletion of an EHR and everything it owns, over the primary and the cold archival tier alikeDecide how an erasure request interacts with the medical record-keeping duty and with the Art. 17(3) grounds, record the decision, and reach the backups and the copies outside the CDR
Art. 18 and 21 restriction and objectionA restriction register at whole-EHR or single-object grain, with the marked object refused on every read, query, export, event and write path while its stored rows stay untouched, and its lift stamped rather than erased (#3324); a research objection under Art. 21(6) that removes the record from population queries, exports and the event stream while leaving reads for care untouched (#3325)Decide when to set them, record the ground, hold the restriction in the systems around the CDR, and tell the subject before lifting it
Art. 19 telling each recipient about a rectification, erasure or restrictionAn access record for every read and export naming the agent, the patient, the action, the outcome and the time, so the recipient list is answerable from the trailSend the communications, and name the recipients to the subject on request. The trail records who received data; it notifies nobody, and the regulation sets no retention period for an access log, so how far back the list reaches is the retention you configure
Art. 35 data protection impact assessmentA DPIA page to assess against (processing description, data categories per schema, roles, retention, risk register, shipped controls by issue), records of processing pre-filled with what the software does, and a go-live checklistRun the DPIA and keep it current. It is the controller’s, and no supplier document replaces it
Art. 4(5) pseudonymisationClinical data, demographic data and the EHR id / subject cross-reference live in three separate schemas with non-overlapping NOINHERIT roles, and the server refuses to boot if a role reaches across (the boundary). The cross-reference — which party and which subject identifier name which EHR, the openEHR Service Model’s EHR Index — is resolved only through the linkage pool, every resolution, merge and split an audited linkage access record (#3158, #3345); the clinical schema keeps only the guarded EHR_STATUS subject pair the openEHR wire binds to, and deleting an EHR removes the cross-reference rows naming itGive the demographic and linkage pools their own DSNs, restrict who may resolve, and hold any additional information outside the CDR to the same standard
Art. 89(1) research safeguards, and anonymising where the purpose allows itCohort queries that answer a research question as an aggregate, withheld below the configured small-cell threshold; secondary use leaves the repository through the FerroBRIDGE product to the OMOP CDM, pseudonymised per permit there; the repository’s side of that path (the restriction, objection and retention marks every export honours, a resumable batch export, an access event per export) is planned in #3379Decide whether the purpose can be met without identification and take that route where it can. Until the export lands, secondary use runs on the primary store

EHDS

Regulation (EU) 2025/327 is in force, and its operative obligations apply from the dates its own final provisions carry. No row below claims conformity with any of them.

ObligationWhat FerroEHR providesWhat the deploying organisation does
Chapter II, primary use and the patient’s sight of who accessed their dataAn access trail of every read, write and refusal, searchable by patient and by agent, and a subject-scoped read grant sized for a patient portalBuild the patient-facing access route on that grant and authenticate the person; the product serves one subject’s log to it, never every patient’s
Chapter III, EHR systems: the European interoperability and logging software components, and published technical documentationThe EHDS readiness page with a status and evidence per Annex II requirement, the technical documentation page per Annex II item, and an access trail carrying the logging component’s elements; the exchange format is an open question until the Article 36 implementing acts fix itFollow the implementing acts and, when a deployment is placed as an EHR system, carry the manufacturer’s conformity assessment and declaration
Chapter IV, secondary useAQL over the stored record and a change-event outbox; the batch export the FerroBRIDGE OMOP load consumes, filtered by the restriction and objection marks, is planned in #3379Deal with the health data access body and carry the data holder’s duties

National law

The sections above apply to every EU deployment. This one is a single country’s law on top of them, three countries so far: the division a deployment reads is “the EU layer, plus my own jurisdiction”. The compliance overview says what adding another takes (National law).

The Netherlands

ObligationWhat FerroEHR providesWhat the deploying organisation does
UAVG Art. 30, the exception for health dataAccess control at the record and attribute level, with every use auditedEstablish that your processing falls inside the exception, per role and per purpose
UAVG Art. 46, processing a national identification numberNational identifiers sealed at rest under the instance’s identifier-protection key in the demographic domain, resolved only through the demographic role, every resolution recorded as a linkage access without the valueHold the statutory authorisation before a BSN enters the store, and restrict who may resolve
Wabvpz Art. 4 to 9, use and verification of the BSNNothing. FerroEHR performs no BSN verification and consults no indexVerify 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 REST API, and EHR Extract exportAuthenticate the patient and build the route; the CDR has no patient-facing interface
Wabvpz Art. 15e, a record of who made data available and who consulted itAn ATNA trail recording the agent, the patient, the action, the outcome and the time, retrievable per patientRender it for the patient, set retention, and review it
BW Book 7, Art. 454, the medical treatment contract’s record-keeping dutyAppend-only version history, so a correction never destroys the prior versionSet the retention schedule the article requires, and reconcile it with erasure requests

The Netherlands: NEN

The NEN 7510 family is where the split is sharpest. A management-system standard cannot be met by a product at all.

ObligationWhat FerroEHR providesWhat the deploying organisation does
NEN 7510-1, the information security management systemTechnical controls an ISMS can point at, each documented with its residual risk in the threat modelRun the ISMS: scope, risk assessment, policy, internal audit, management review
NEN 7510-2, the controlsAccess control, audit logging, cryptography in transit and for version signatures, supply-chain verificationEverything organisational: personnel, physical security, supplier management, continuity
Certification against NEN 7510Nothing. A product cannot be certified against a management-system standard, and FerroEHR makes no such claimObtain and maintain the certificate for your organisation
NEN 7512, the trust basis for data exchangeMutually authenticated TLS, OAuth2 and OIDC with an enterprise identity provider, SMART App LaunchAgree the trust basis with each counterparty, and operate the certificate estate
NEN 7513, logging actions on electronic patient recordsAn audit trail of every operation including refusals, in FHIR AuditEvent and DICOM PS3.15 form, hash-chained in the databaseMap the recorded fields onto the standard’s own list, set retention, and review the trail

Germany

ObligationWhat FerroEHR providesWhat the deploying organisation does
BDSG § 22 Abs. 2, appropriate and specific measures for health data: traceability, access restriction, pseudonymisation, encryptionVersioned writes with contribution and audit, an access trail, deny-by-default authorization, the pseudonymisation boundary, TLS and sealed identifiersChoose the measures, establish the lit. b ground, and keep the processing under persons bound by professional secrecy
BDSG § 27 Abs. 3, identifying characteristics stored separately for research, rejoined only as the purpose requiresSeparate clinical, demographic and linkage schemas, and cohort queries that cross on identifiers onlyDecide when to anonymise, and hold the balancing test
SGB V §§ 346 to 348, writing treatment data into the ePA once it is held in interoperable formTemplate-structured records, the REST API and EHR Extract exportOperate the transport into the ePA, the connector and the information objects
SGB V § 339 Abs. 3 and § 352, credential-bound access with a log of who accessed what, under a closed role matrixRole- and attribute-based authorization and an access trail naming agent, roles, patient, action and outcomeBind the identity provider to the HBA and SMC-B; the sections bind the ePA, which the CDR is not
SGB V § 309, the TI access log with attempts, three years’ retention and deletion on expiryA trail that records attempts and refusals, retention_days and the retention reaperSet the retention owed; the section binds TI application controllers, not a CDR outside the TI
GDNG § 6, own-data secondary use under pseudonymisation, a rights-and-roles concept, logging and a thirty-year limitPer-domain roles, cohort queries with small-cell suppression, an access record per query with purpose and legal_basis; the export to the FerroBRIDGE OMOP load, where pseudonymisation per permit happens, is planned in #3379Write the rights-and-roles concept, publish the purposes, run the clock, answer subjects from the trail
§ 203 StGB Abs. 3 and 4, necessity-bounded access for those who keep the systems running, and the duty to bind them to secrecySeparate operational surfaces, audited admin reads, one database role per domainBind operators and subcontractors to secrecy in writing, and route support so it needs no standing read of clinical content

Switzerland

Switzerland is not an EU member state: the GDPR rows above do not apply to a Swiss deployment, and the DSG takes their place.

ObligationWhat FerroEHR providesWhat the deploying organisation does
DSG Art. 7, data protection by design and by defaultDeny-by-default authorization, a production deployment profile that refuses missing separationsChoose the restrictive settings where the shipped default favours compatibility
DSG Art. 8 with DSV Art. 3, the minimum security measuresNeed-to-know authorization, one database role per domain, TLS 1.3, attributed versioned writes, an access trail with refusals, signed releasesBackup and restore, patching, breach detection and everything below the application
DSV Art. 4, logging including reads, kept at least a year separately from the processing systemThe trail with every operation and its actor, time and outcome, and forwarding sinks that put a copy outside the CDRForward the trail, set the retention at a year or more, restrict who reads it
DSG Art. 12, the register of processing activitiesThe effective configuration at GET {base}/admin/config and records of processing pre-filled with what the software doesWrite and maintain the register; DSV Art. 24 leaves no small-organisation exemption for a clinical repository
DSG Art. 22, the impact assessment for large-scale processing of sensitive dataThe DPIA page with the technical description, the risk register and the controls by issueRun the assessment; it is the controller’s
DSG Art. 25 and Art. 28, the right of access within 30 days and data portability in a common electronic formatThe full record over the REST API, the EHR Extract, the published openEHR formats, and a per-patient search of the trail for the recipientsIdentify the requester, render the answer understandably, route it, and meet the deadline
DSG Art. 31 Abs. 2 lit. e, research on anonymised data, with measures against identifiability meanwhileSeparate clinical, demographic and linkage schemas and cohort queries with small-cell suppression; the export to the FerroBRIDGE OMOP load, where pseudonymisation per permit happens, is planned in #3379Decide when anonymisation is possible and hold the research ground
EPDG Art. 10 and EPDV Art. 10 and 12, the certified community’s logging, storage, encryption and residency dutiesIHE ATNA events over ITI-20 with ITI-19 mutual TLS, ITI-81 retrieval, the record in published formats, a self-hosted deploymentFeed the EPD through a certified community; the duties bind the community, which the CDR is not

What this page does not do

It does not tell you whether your deployment satisfies any of these obligations. That answer depends on your legal basis, your organisation, your infrastructure and your operating practice, none of which a supplier can see.

The companion guidance is written: the DPIA page, the records of processing and the go-live checklist. Beside them, the compliance overview carries the legal sources, the control matrix carries the live status of every declared control, and the threat model carries the risk that survives each one.

Retention, restriction and objection

Three marks sit beside the clinical content, and none of them is an openEHR concept. The specifications say what a repository must keep and how versions relate to each other; they say nothing about a period after which data should go, about limiting the processing of a record that stays, or about a patient who does not want their data used for research. The law says all three, so FerroEHR carries them as its own extension and this page is where they are written down.

Nothing here deletes anything. A record in an openEHR repository is indelible by design — a deletion commits a new version whose content is removed, and the history stays — so a retention period in FerroEHR produces a list, and acting on that list is the controller’s decision, taken record by record and carried out through physical deletion.

The retention register

Two tables and a view in the clinical domain, all driven through the admin API.

retention_policy is the register: one row per content category per jurisdiction, carrying the period, what the period is measured from, and the legal citation the period rests on.

ColumnMeaning
kindThe content category: COMPOSITION, EHR_STATUS, FOLDER, or EHR for the whole record
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 category, the citation, when the period expired, and two counts: the objects that are due, and the objects exempted by a per-object hold.

Ship the register empty and declare the periods your organisation is subject to. The periods are not the software’s to choose — they follow from the law the deployment runs under, and two of the provisions in the compliance overview ask for exactly this register: GDPR Art. 30(1)(f) wants the envisaged time limits for erasure per category of data in the record of processing activities, and Swiss DSG Art. 25 Abs. 2 lit. d gives the subject a right to be told the retention period or the criteria that fix it.

Declaring a period

PUT {base}/admin/retention/policy
Content-Type: application/json

{
  "kind": "EHR",
  "jurisdiction": "CH",
  "period": "20 years",
  "anchor": "last_commit",
  "source": "EPDV Art. 10 Abs. 1 lit. d"
}

204 on success, replacing any earlier period for that pair. An unknown category, an unknown anchor rule or a non-positive period is 400. GET {base}/admin/retention/policy reads the whole register back, which is what an access answer and an audit both cite.

Anchoring an EHR and holding it

PUT {base}/admin/retention/anchor
Content-Type: application/json

{
  "ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
  "jurisdiction": "CH",
  "anchored_at": "2026-01-31T00:00:00Z"
}

Leave anchored_at out until the anchor event is known; nothing is due without it. Add hold_at and hold_ground together to suspend disposal for the whole record: a hold with no stated reason is refused with 400, because a hold nobody can account for is not a record of anything.

A hold at object grain is a separate route, and it is the shape Swiss EPDV Art. 10 Abs. 2 lit. b asks for, where the patient may ask that named data be exempted from the destruction the same article otherwise requires:

POST {base}/admin/retention/hold
Content-Type: application/json

{ "vo_id": "df58b2ee-30bd-4b2c-9b7d-3a0f8e5c6d21", "held": true }

Reading what is due

GET {base}/admin/retention/due?limit=100

Oldest first. Every row carries objects_due and objects_held, so a controller acting on the list disposes of the first and leaves the second where they are. An EHR under a whole-record hold does not appear at all.

Restriction of processing

GDPR Art. 4(3) defines restriction as marking stored data to limit its future processing, and Art. 18(2) says what is left once the mark is set: storage, and nothing else without the subject’s consent or one of the named exceptions. FerroEHR implements that literally, at two grains — a whole EHR, or one versioned object inside it.

A restricted object stays in storage untouched, and every path that would process it stops:

  • a point read, a versioned read and a revision history answer 403 with a body naming the restriction — distinct from the authorization 403, because the same caller with the same rights is refused until the restriction is lifted;
  • AQL results omit it, at every scope, so naming the ehr_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.
POST {base}/admin/restriction
Content-Type: application/json

{
  "ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
  "vo_id": "df58b2ee-30bd-4b2c-9b7d-3a0f8e5c6d21",
  "ground": "gdpr-18-1-a",
  "note": "accuracy contested on 2026-09-15"
}

Omit vo_id to restrict the whole record. ground is one of the four Art. 18(1) points — gdpr-18-1-a through gdpr-18-1-d — or national for a member-state rule that goes beyond them, which is how German BDSG § 35 Abs. 2 and Abs. 3 are carried.

POST {base}/admin/restriction/lift with the same grain lifts it. The register keeps the request and stamps it lifted rather than removing it, because Art. 18(3) makes the sequence itself the obligation: the subject is informed before the restriction is lifted, and a register that forgot the request could not show that anyone was. GET {base}/admin/restriction?ehr_id=… reads the whole sequence back. Lifting a whole-record restriction leaves an object-scoped one standing on its own.

Objection to research processing

GDPR Art. 21(6) gives the subject a right to object to processing for scientific or historical research or statistical purposes under Art. 89(1), unless the processing is necessary for a task carried out for reasons of public interest. That is narrower than Art. 18: it reaches research, not care.

While an objection stands, the EHR is absent from every full-population AQL query, from every export, and from the change-event stream — the three surfaces a secondary-use consumer reads this repository through. A query that names the ehr_id and a read of the record by the treating clinician are untouched.

POST {base}/admin/research-objection
Content-Type: application/json

{ "ehr_id": "7d44b88c-4199-4bad-97dc-d78268e01398", "objected": true }

Send "ground": "…" beside "objected": true to record the public-interest ground the article admits for an override: the EHR returns to the research population and the ground says on whose authority. Send "objected": false to withdraw the objection entirely; a withdrawal carries no ground, because there is nothing left to override.

The audit trail, and the access-log ceiling

Setting and lifting any of these marks is an access record of its own, naming the EHR, the object where the act was object-scoped, and which register moved. These are the acts a supervisory authority asks about after the fact, so they are in the trail beside the reads and writes.

The trail has a retention question of its own, and it runs the other way: national rules set a floor below which an access log may not be reaped — five years in the Netherlands, one year in Switzerland — and one sets a ceiling. SGB V § 309 Abs. 1 asks the controllers of a German telematics-infrastructure application to keep access logs for the three-year limitation period, and Abs. 3 requires deletion without delay once it has run. That provision binds the controllers § 307 names, which no software can infer, so the deployment declares it:

[audit.store]
retention_days = 1095
sgb_v_309_controller = true

With the declaration in place, the server refuses at boot any horizon above the ceiling — including retention_days = 0, keep forever — and refuses a configuration whose floors and ceilings contradict each other outright, rather than silently preferring one. Retention of the trail itself is described in Audit.

What this page does not claim

The registers record decisions and make them checkable. They do not make a deployment compliant with any of the provisions named above: the periods, the grounds, the notifications, the weighing of an objection and the decision to dispose of a record are all the controller’s, and the shared responsibility page says which side of the line each one falls on.

Control matrix

FerroEHR is software. It is not a controller, not a processor and not a certified organisation, so this page makes no compliance claim on anyone’s behalf. It lists the technical controls the product ships or plans, and the article or clause each one is designed to support. Whether a deployment satisfies a legal obligation depends on how the deploying organisation runs it.

Every row comes from the tracker. A control is declared on the issue that delivers it, as a line in the issue body:

Control: <legal source> <article or clause>

The short name resolves to an official publisher URL from a registry inside the generator, so a legal citation on this page is never free text. An issue may declare several controls, one per line. The Control column carries the issue title, unless the body also states the control on its own line, which the generator prefers so that an issue titled as the defect it fixed does not read its defect as the control:

Control-text: <the control, stated as what the product does>

How this page is built

scripts/render/control-matrix.sh queries the tracker with the GitHub CLI, joins each declared control to its legal source and to its current state, and writes this file. A CI job re-runs the generator with --check and fails the build when the committed page no longer matches the tracker, which is what keeps a shipped control from sitting here as “planned”.

  • Shipped: the issue is closed as completed. The merged pull request that closed it is linked in the last column. The one pull request that closes a control regenerates this page as it will read after the merge, so the page never lags a shipped control.
  • Planned: the issue is open. Whether work has started is the issue’s column on the public roadmap board, which this page does not copy: a status that lives in two places disagrees the day one of them moves.
  • Not planned: the issue was closed without the control being built. The row stays visible so the record does not quietly lose it.

The page carries no generation timestamp and no build commit. Both change on every run or every push while the tracker has not moved, which would make the CI staleness check fail on days when nothing was wrong. When this page was last regenerated, and from which commit, is the file’s own git history.

Controls

Legal 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. 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)feat(ext): secondary use leaves through FerroBRIDGE to OMOP in batches over AQL: the CDR keeps the restriction, objection and retention marks, serves AQL over VERSION by time_committed as the batch surface and logs each population query; no in-CDR research domain#3379Planned
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 IVfeat(ext): secondary use leaves through FerroBRIDGE to OMOP in batches over AQL: the CDR keeps the restriction, objection and retention marks, serves AQL over VERSION by time_committed as the batch surface and logs each population query; no in-CDR research domain#3379Planned
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

The short names above resolve to these publishers. The linked text is the authority; nothing on this page restates it.

EU and INT apply to every deployment. A two-letter country code is national law or a national standard, and applies to a deployment in that country: FerroEHR is an openEHR CDR, openEHR is not a Dutch standard, and a deployment elsewhere answers to its own equivalents rather than to these. Adding a jurisdiction is a registry entry plus the controls that cite it.

EHDS readiness

The European Health Data Space regulation puts obligations on EHR systems in its Chapter III: an EHR system must include two harmonised software components, meet the essential requirements of Annex II, and carry technical documentation and an EU declaration of conformity before it is placed on the market or put into service.

This page states, requirement by requirement, what FerroEHR provides today. It exists so an evaluator can see the real position rather than infer one, and so the project has a checklist rather than an intention.

Warning

No conformity assessment has been carried out. No technical documentation has been drawn up under Article 37, no EU declaration of conformity exists under Article 39, and FerroEHR is not registered under Article 49. A status of “Shipped” below means the software provides the capability — it is not a claim of conformity, and nothing on this page is one.

The regulation, and how to check this page against it

Regulation (EU) 2025/327 of the European Parliament and of the Council of 11 February 2025 on the European Health Data Space and amending Directive 2011/24/EU and Regulation (EU) 2024/2847 — OJ L series, 2025/327, 5.3.2025.

Every requirement identifier, heading and date on this page was read from that published text on 2026-09-10. The wording in the “Requirement” column is this project’s own paraphrase for navigation; the regulation is the authority and its text governs. Where the two differ, the regulation is right and this page is a defect worth reporting.

When it applies

  • 26 March 2027 — The Regulation applies from this date.
  • 26 March 2029 — Articles 25, 26, 27, 47, 48 and 49 apply to the priority categories of personal electronic health data in Article 14(1)(a), (b) and (c), and to EHR systems the manufacturer intends to process them.
  • 26 March 2031 — The same articles apply to the categories in Article 14(1)(d), (e) and (f), and Chapter III applies to EHR systems put into service in the Union as described in Article 26(2).

The last of those matters most here: a deployment that a health institution runs for itself, or that is offered as a service, is put into service under Article 26(2) rather than placed on the market.

The two harmonised software components

Article 25(1) requires an EHR system to include both.

ComponentStatusWhere it stands
European interoperability software component for EHR systemsOpen question (#3171, #3206)The requirements this component carries are Annex II 2.1 to 2.3: an interface that provides and receives personal electronic health data in the European electronic health record exchange format. FerroEHR serves the openEHR ITS-REST surface and an optional FHIR façade; neither is that format, and the format’s own content is set by implementing acts under Article 36.
European logging software component for EHR systemsPartial (#3204)Annex II 3.2 lists five things every access event must record. The access-event model records the accessing organisation and person, the subject, the object and its domain, the outcome, the purpose and the time, the origins of the served data, and the audit trail is retrievable. One of the five is still short: the category is an openEHR resource class rather than an Annex I priority category.

Annex II — the essential requirements

The identifiers are the Annex’s own. “Evidence” links what a reader can check for themselves; “Gap” says what is missing when a row is not complete.

1. General requirements

#Requirement (our paraphrase)StatusEvidenceNotes
1.1The components achieve the performance the manufacturer intended and are suitable for their intended purpose in normal use, without putting patient safety at risk.Open questionQuestion: The requirement is a claim by a manufacturer about an intended purpose. FerroEHR is source-available software rather than a placed product, so who the manufacturer is depends on who puts a deployment into service — the question #3168 has to answer before this can be a status rather than a question. Tracked in #3168
1.2The components can be supplied and installed following the manufacturer’s instructions without adversely affecting their characteristics and performance.PartialDeployment artifacts and their documented installationGap: The artifacts and their instructions exist and a deployment probe reads the running stack back. What does not exist is the manufacturer’s declared intended purpose those characteristics would be measured against.
1.3Interoperability, safety and security features uphold the rights of natural persons in line with the intended purpose, as set out in Chapter II.PartialThe rights the software can serve, and who must serve the restGap: Chapter II rights are largely a deployment’s duty rather than a product’s. The shared-responsibility page states which side each one falls on; the patient-facing access route is not built.
1.4Components intended to operate with other products, including medical devices, are designed so interoperability and compatibility are reliable and secure and data can be shared with the device.Open questionQuestion: FerroEHR claims no interoperability with a medical device. If a deployment claims it, Article 27 puts that claim’s requirements on the party making it.

2. Requirements for interoperability

#Requirement (our paraphrase)StatusEvidenceNotes
2.1A system that stores or intermediates personal electronic health data provides an interface giving access to it in the European electronic health record exchange format, through the European interoperability software component.Open questionWhich priority categories round-trip through the FHIR façadeQuestion: The exchange format waits on the Article 36 implementing acts; the FHIR façade’s round trips per priority category are recorded, and the profile mappings that would carry the format belong to FerroBRIDGE. Tracked in #3171, #3206
2.2Such a system can receive personal electronic health data in that format, through the same component.Open questionQuestion: Receiving the exchange format waits on the same Article 36 acts as providing it. Tracked in #3171
2.3A system designed to provide access to personal electronic health data can receive it in that format, through the same component.Open questionQuestion: The same open question as 2.2: the format is not yet fixed. Tracked in #3171
2.4A system that lets a user enter structured personal electronic health data allows entry with enough granularity to provide it in the exchange format.PartialTemplates and the archetype-constrained entry modelGap: openEHR templates constrain entry to the archetype’s granularity, which is finer than any exchange format is likely to require. That the granularity SUFFICES for the European format cannot be shown until the format is set by implementing act.
2.5The components include no feature that prohibits, restricts or unduly burdens authorised access, sharing or permitted use.ShippedThe query surface over the whole stored record; Authorisation refuses or permits; it adds no commercial gate
2.6The components include no feature that prohibits, restricts or unduly burdens exporting the data in order to replace the system with another product.ShippedWhole-repository dump and load; EHR-Extract export

3. Requirements for security and logging

#Requirement (our paraphrase)StatusEvidenceNotes
3.1A system used by health professionals provides reliable identification and authentication of them.ShippedBasic and OAuth2/OIDC authentication
3.2The European logging software component records, for every access event or group of events, at least the healthcare provider or other individuals who accessed the data, the specific natural person or persons who accessed it, the categories of data, the time and date, and the origin of the data.PartialThe five elements mapped onto fields, gaps includedGap: Points (a), (b), (d) and (e) are recorded: the accessing organisation beside the natural person, the time, and the origins of the served data read from the FEEDER_AUDIT provenance openEHR stamps on content, with the true distinct count beside the capped set. Point (c) is partial, because the record names an openEHR resource class and a pseudonymisation domain rather than an Annex I priority category. The DICOM rendering carries neither the organisation, the declared purpose nor the origins, because PS3.15 A.5 defines no element for them; the FHIR rendering carries all three. The remaining gap is asserted by a test that fails when it closes. Tracked in #3204, #3212
3.3The components include tools or mechanisms to review and analyse the log data, or support connecting external software that does.ShippedAudit retrieval (IHE ATNA ITI-81) and the syslog and FHIR feeds
3.4Components that store personal electronic health data support different retention periods and access rights that take the origin and category of the data into account.PartialPer-EHR access control and the audit retention settingGap: Access rights are per EHR, per role and per attribute, and audit retention is configurable. Retention that varies by the ORIGIN or CATEGORY of clinical data is not implemented; the archival tier moves records without expiring them.

Open questions

These decide whether Chapter III applies to a given deployment at all, and to whom. They are questions this project has not answered, stated as questions.

  • Who is the manufacturer of a FerroEHR deployment? FerroEHR is source-available software, not a product placed on the market. Article 26(2) treats an EHR system manufactured and used within a health institution, and one offered as a service, as put into service — which puts the manufacturer’s duties on the party doing that rather than on the project. Tracked in #3168.
  • Article 25(2) excludes general purpose software used in a healthcare environment from Chapter III. A clinical data repository is not general purpose software, so the exclusion is unlikely to apply, but the line has not been drawn by guidance yet. Tracked in #3168.
  • The European electronic health record exchange format is set by implementing acts under Article 36 that have not been adopted. Until they are, requirements 2.1 to 2.3 name a format with no content, and what the interoperability component must emit cannot be built to a specification. Tracked in #3171.

Technical documentation readiness

Article 37 of the EHDS regulation requires a manufacturer to draw up technical documentation before an EHR system is placed on the market or put into service, and to keep it up to date. Article 37(2) says it must contain at least the elements of Annex III.

This page is not that documentation. It is a map of what FerroEHR can already supply for each element and what does not exist, so the gap is visible rather than discovered when someone needs the file.

Warning

No technical documentation has been drawn up, and no EU declaration of conformity exists. A “Shipped” row below means the material an element asks for is published and can be cited — not that the element has been written.

Regulation (EU) 2025/327 of the European Parliament and of the Council of 11 February 2025 on the European Health Data Space and amending Directive 2011/24/EU and Regulation (EU) 2024/2847 — OJ L series, 2025/327, 5.3.2025. Read on 2026-09-10; the regulation governs and the summaries here are this project’s paraphrase.

Annex III, element by element

1. A detailed description of the EHR system

#ElementStateMaterialNotes
1(a)Intended purpose, date and version.MissingThe version and release date are published per release; the intended purpose is a manufacturer’s statement that does not exist.
1(b)The categories of personal electronic health data it processes.Partialwhat existsThe storage model is documented and the pseudonymisation domains are separated, but a mapping onto the Annex I priority categories is not written.
1(c)How it interacts with hardware or software that is not part of it.Availablewhat exists
1(d)Versions of relevant software or firmware, and update requirements.Availablewhat exists
1(e)Every form in which it is placed on the market or put into service.Availablewhat exists
1(f)The hardware it is intended to run on.Partialwhat existsThe measured deployment classes state an environment envelope; a minimum hardware specification as such is not published.
1(g)The system architecture, and how the components integrate.Availablewhat exists
1(h)Technical specifications, variants, configurations.Availablewhat exists
1(i)A description of every change through the lifecycle.Availablewhat exists
1(j)Instructions for use and, where applicable, for installation.Availablewhat exists

2. The system in place to evaluate the EHR system’s performance

#ElementStateMaterialNotes
2Where applicable, how performance is evaluated.Availablewhat existsPerformance is measured by an independent instrument in open-loop, coordinated-omission-free runs, and the records are committed.

3. References to the common specifications used

#ElementStateMaterialNotes
3Common specifications under Article 36 against which conformity is declared.MissingThe implementing acts that set the common specifications have not been adopted. Nothing can reference them yet.

4. Verification and validation results

#ElementStateMaterialNotes
4Results and critical analyses of the tests demonstrating conformity.Partialwhat existsThe openEHR conformance record is complete and committed. It demonstrates conformity to the openEHR specifications, which is a different claim from conformity to Annex II; the European digital testing environment of Article 40 does not exist yet.

5. A copy of the information sheet

#ElementStateMaterialNotes
5The information sheet required by Article 38.MissingNo information sheet has been drawn up.

6. A copy of the EU declaration of conformity

#ElementStateMaterialNotes
6The declaration required by Article 39.MissingNo declaration exists, and none can be made before the common specifications are adopted and the manufacturer is identified.

Test evidence

Element 4 asks for the results of the verification and validation tests. What exists is the openEHR conformance record: an independent instrument’s runs against a composed deployment, with the results, verdicts and the statement committed under docs/conformance/ and published on the conformance pages.

Read what that record does and does not say. It demonstrates conformity to the openEHR specifications. Conformity to Annex II is a different claim against a different yardstick, and the European digital testing environment of Article 40 — whose results Article 37(2) also requires a reference to — does not exist yet.

Risk analysis

Annex III does not name a risk analysis as a separate element, but element 1 asks for a description of the system architecture and element 2 for the system in place to evaluate performance. The material FerroEHR publishes for both is the architecture chapter and the security chapter, with the pseudonymisation boundary and its data flows documented as they land.

Declaration of conformity

Article 39 requires an EU declaration of conformity stating that the essential requirements of Annex II are met, and Annex IV sets out what it contains. No declaration exists, and none can be drawn up yet: the common specifications of Article 36 have not been adopted, so there is nothing to declare conformity against, and the manufacturer of a given deployment has not been identified — see the open questions on the readiness page.

When those two are settled, the declaration is drawn up by the manufacturer of the deployment, not by this project on their behalf.