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 verify, not just read. Every release runs the full openEHR conformance catalogue against the live server, in both JSON and XML, and computes the profile verdicts automatically. The current, run-derived result — every number generated from the committed artifacts, never hand-typed — is on the Conformance page.
- The latest openEHR specifications, generated directly from the official machine-readable models: the REST API Release-1.1.0, AQL 1.1, Reference Model 1.2.0, Archetype Model 1.4 and 2.4, Terminology 3.1. A specification update is a regeneration, not a rewrite.
- One static binary. No JVM and no runtime dependencies — predictable memory, fast cold starts, and a minimal, shell-less container image.
- PostgreSQL 18-native storage. Clinical documents are decomposed into an indexed node model with temporal, database-enforced versioning; 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
- Just want to try it? Getting started takes you from
docker compose upto a stored composition and an AQL result in a few minutes. - Deploying it? Installation covers Docker Compose, Kubernetes/Helm, building from source, and the full configuration reference.
- 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.
Note
FerroEHR is a successor to the Java EHRbase project (by vitasystems and the Peter L. Reichertz Institute) and keeps that lineage in its history, but it is an independent, from-scratch Rust implementation and is not affiliated with or endorsed by the upstream project. FerroEHR’s own code is MIT-licensed; vendored openEHR artifacts keep their upstream Apache-2.0 terms.
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/ferroehrand a permissive CORS policy — this is a development configuration only. See Security & multi-tenancy and the configuration reference before exposing a server.
1. Start the stack
You need Docker with the Compose plugin. From a checkout of the repository:
docker compose up --build
This builds 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. The Compose file also defines optional SeaweedFS (S3) and
Keycloak (OIDC) services that the quickstart does not depend on.
The development server configuration is mounted from docker/ferroehr.dev.toml,
which ships one Basic-auth user (ferroehr / ferroehr) and one admin user
(ferroehr-admin / ferroehr) so the API authenticates out of the box.
2. Probe the status endpoint
The status endpoint is public and confirms the server is up:
curl http://localhost:8080/ferroehr/rest/status
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, and the full endpoint reference is
published on the documentation site under /ferroehr/api/ (the API tab).
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 (and the Location header 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.
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 to browse and try every
endpoint from your browser. The UI’s spec selector has one entry,
ferroehr-rest, generated by the server itself and covering its complete
surface — every openEHR API group (EHR, COMPOSITION, CONTRIBUTION, DIRECTORY,
DEMOGRAPHIC, DEFINITION, QUERY, ADMIN) plus the server’s extensions and
operational endpoints. When authentication is enabled the “Authorize” dialog
shows the scheme the server is configured for (HTTP Bearer/JWT with OIDC,
otherwise HTTP Basic). You can also read the static API reference on the
documentation site (the API tab, under /ferroehr/api/).
Next steps
- Installation — running it for real (Compose, Kubernetes, or from source) and the configuration reference.
- Using the API — the per-resource reference with headers, status codes, and versioning.
- Concepts — the openEHR model and how FerroEHR is built, if the terms above were unfamiliar.
Installation
FerroEHR is a single static binary that connects to a PostgreSQL 18 database. There is no application server to install and no runtime to provision — you choose how to run the binary and where the database lives. This part covers the three supported paths and the full configuration surface.
- Docker Compose — the fastest way to run the server plus a preconfigured PostgreSQL 18, for development and evaluation. Includes an optional observability overlay.
- Kubernetes & Helm — the production path: a hardened, non-root, default-deny workload that connects to an externally managed PostgreSQL 18.
- From source — building the binary yourself with the pinned Rust toolchain.
- Configuration reference — every
FERROEHR_*environment variable, grouped by area, with types, defaults, and meaning.
Whichever path you take, the server is configured entirely through FERROEHR_*
environment variables (with optional mounted TOML files for values that do not
fit cleanly in env, such as a Basic-auth user store or an OIDC block). The
database schema is created and updated by the binary’s migrations, which run
automatically at boot.
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.
Docker Compose
Docker Compose is the quickest way to run FerroEHR together with a preconfigured PostgreSQL 18, for local development and evaluation. This chapter describes the two published images, the Compose services, the environment variables that tune them, and the optional observability overlay. For a step-by-step first run, see Getting started.
The two images
FerroEHR publishes two container images to GHCR:
| Image | Contents |
|---|---|
ghcr.io/rubentalstra/ferroehr | The ferroehr server binary. A distroless, non-root, shell-less multi-arch image (amd64 + arm64). Configured entirely via FERROEHR_* environment variables. |
ghcr.io/rubentalstra/ferroehr-postgres | postgres:18.4 with the application role, the layered group roles (ferroehr_migrator, ferroehr_app, ferroehr_reader), database, schemas (ehr, ext), and required extensions (uuid-ossp, pgcrypto, pg_trgm, btree_gist) pre-created, so the app role never needs superuser. |
The PostgreSQL image is init-scripts only — it creates roles, schemas, and extensions, but does not bake in migration state. The server owns the schema content and applies its migrations idempotently at every boot, so a fresh database self-provisions and a restart is a no-op.
Note
PostgreSQL init scripts run only when the data volume is empty. If you see startup notices like
skipping role creation (no CREATEROLE privilege)orroles absent, your volume predates the image’s role setup (or you are running a plainpostgresimage): either recreate the volume (docker compose down -v— destroys data) or create the three group roles once by hand as a superuser. The server runs fine either way — the grants are a defense-in-depth layer, not a functional requirement.
Bringing up the stack
docker compose up --build
This starts the core services (no profile needed):
ferroehr-postgres— the database image, with a named data volume and apg_isreadyhealthcheck.ferroehr— the server, which waits for the database to report healthy (depends_on: condition: service_healthy), then boots, migrates, and serves on port 8080. Its healthcheck is the binary’s ownhealthchecksubcommand (there is no shell in the image).ferroehr-admin-ui— the admin console, which waits for the server to be healthy and serves on port 3000.
Note
All base images are pinned by digest (
name:tag@sha256:…), not by a mutable tag, so a rebuild always resolves the exact same base. Each service also declares a memory/CPU limit (deploy.resources.limits) mirroring the Helm chart, so a local stack cannot exhaust the host.
The server’s development configuration is mounted read-only from
docker/ferroehr.dev.toml to /etc/ferroehr/ferroehr.toml, where the server
auto-discovers it. That file enables Basic auth with the throwaway users
ferroehr / ferroehr and ferroehr-admin / ferroehr, and turns on permissive
CORS — development only.
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.
Environment variables
The Compose file reads these host environment variables (with the defaults shown), so you can retune without editing it:
| Variable | Default | Effect |
|---|---|---|
FERROEHR_IMAGE | ghcr.io/rubentalstra/ferroehr:local | Server image to run (set to a published tag to skip the build). |
FERROEHR_POSTGRES_IMAGE | ghcr.io/rubentalstra/ferroehr-postgres:local | Database image to run. |
FERROEHR_PORT | 8080 | Host port mapped to the server. |
FERROEHR_DB_PORT | 5432 | Host port mapped to PostgreSQL. |
PG_INIT_USER / PG_INIT_PASSWORD / PG_INIT_DB | ferroehr | App role, password, and database created by the DB image’s init script. |
POSTGRES_PASSWORD | postgres | Bootstrap superuser password (init only). |
FERROEHR__LOG__FORMAT | pretty | Log rendering for docker compose logs. Set json for log collectors. |
The server container itself is passed FERROEHR__DB__URL (assembled from the
DB variables); its ferroehr.toml is the mounted docker/ferroehr.dev.toml,
auto-discovered at /etc/ferroehr/ferroehr.toml. Any other FERROEHR_* setting
from the configuration reference can be added under the
ferroehr service’s environment: block.
Optional services (Compose profiles)
The Compose file also defines services the quickstart does not depend on — the server defaults to Basic auth, inline multimedia and the in-process openEHR terminology, so none of them is required. Each sits behind a Compose profile and stays down until you enable it:
-
seaweedfs(--profile s3) — an S3 gateway for largeDV_MULTIMEDIAexternalization (development/test only). Start it withdocker compose --profile s3 up, then setFERROEHR__MULTIMEDIA__ENABLED=true,FERROEHR__MULTIMEDIA__ENDPOINT=http://seaweedfs:8333,FERROEHR__MULTIMEDIA__BUCKET=openehr-multimedia, and (dev only)FERROEHR__MULTIMEDIA__ALLOW_HTTP=true. In production, point the multimedia settings at a real, credentialed, HTTPS S3 endpoint instead. -
keycloak(--profile keycloak) — an OIDC provider with a preloadedferroehrrealm, on port 8081. Start it withdocker compose --profile keycloak up; to use bearer auth instead of Basic, point the auth OIDC settings athttp://localhost:8081/auth/realms/ferroehr. Its healthcheck probes the realm’s OIDC discovery document, so services can gate their startup on it being fully ready. -
terminology(--profile terminology) — a real FHIR R4 terminology server (HAPI FHIR JPA) on port 8090, plus a one-shot container that seeds it with synthetic test code systems and value sets over the server’s own FHIR API. The profile only starts the server; pointing the CDR at it is a small overlay, so the plain quickstart is unaffected:docker compose --profile terminology \ -f docker-compose.yml -f docker/sut-terminology.yml upThe overlay switches on the
[terminology.external]providers thatdocker/ferroehr.dev.tomlalready carries in the disabled state. See Terminology servers for what is seeded, how several servers are routed per terminology, and the fail-open/fail-closed choice.
Build provenance
Images built by CI (and any docker compose build you drive) 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). 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 second Compose file adds a full local telemetry stack — an OTLP collector, Prometheus, Tempo, Loki, and Grafana with a provisioned service-overview dashboard:
docker compose -f docker-compose.yml -f docker-compose.observability.yml up --build
# Grafana → http://localhost:3000
The overlay reconfigures the server for that stack: it exports traces over
OTLP/gRPC (FERROEHR__TELEMETRY__OTLP_ENDPOINT), switches stdout to JSON lines
(FERROEHR__LOG__FORMAT=json), and enables the management surface on its own
internal port 9464 (FERROEHR__MANAGEMENT__ENABLED, FERROEHR__MANAGEMENT__PORT)
with info, metrics, and prometheus set to public so the bundled
Prometheus can scrape /management/prometheus without credentials. That port is
only reachable on the Compose network — Grafana’s 3000 is the sole published
port. Every variable uses the same FERROEHR__… grammar as the rest of the
configuration reference; a single-underscore spelling is
rejected at startup, not ignored.
This is the easiest way to see the server’s metrics and traces without wiring up a collector by hand. See Operations for what the server exports and how to consume it in production.
Next
- Configuration reference — every setting you can pass.
- Kubernetes & Helm — the production deployment.
Kubernetes & Helm
The deploy/helm/ferroehr chart deploys FerroEHR as a hardened,
production-shaped Kubernetes workload: non-root, read-only root filesystem,
default-deny ingress, connecting to an external PostgreSQL 18. This chapter
covers installing the chart, the database role model it expects, the security
posture it enforces, the health probes, and the optional integrations. It
assumes a cluster at Kubernetes 1.25 or newer.
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
Create a Secret holding the app-role connection string, then install the chart pointing at it:
kubectl -n ferroehr create secret generic ferroehr-db \
--from-literal=FERROEHR__DB__URL='postgres://ferroehr_app:***@pg-host:5432/ferroehr?sslmode=verify-full'
helm install ferroehr deploy/helm/ferroehr -n ferroehr \
--set database.existingSecret=ferroehr-db \
--set image.tag=3.5.0
Always pin image.tag to an immutable version or, better, a @sha256 digest —
never latest. Every values.yaml key documents the exact FERROEHR_*
environment variable it maps to; the configuration reference
is the full list.
Database roles — who runs migrations
The chart expects a four-role PostgreSQL model, so the runtime pod is never a superuser:
| Role | Purpose |
|---|---|
| owner | owns the database (provisioning only) |
ferroehr_migrator | runs the append-only schema migrations |
ferroehr_app | day-to-day reads and writes — the running pod connects as this |
ferroehr_reader | read-only, for replicas and reporting |
The binary calls its migrations on boot, so you choose one of two flows:
- (a) Grant the runtime DSN the migrator role — simplest for single-tenant or small deployments; the pod migrates itself at startup.
- (b) Run migrations out of band with a migrator DSN (a CI step or a
one-shot
Job), then start the pods with the lower-privilegedferroehr_appDSN — recommended for least-privilege production. Gate the Deployment rollout on the migration step so two server versions never race the schema.
The chart does not ship a migration Job; migrations.runByMigratorRole is an
informational marker surfaced in the install NOTES.
Secrets and mounted config
Some settings do not fit cleanly in environment variables — the Basic-auth user
store, a full OIDC block, RBAC role-claim lists, ABAC policies, the external
terminology provider map, ATNA TLS certificates, and the PGP signing key. Supply
these as files via config.files, which the chart mounts read-only from a
Secret at /etc/ferroehr/<key>; point the matching in-TOML *_file /
*_path key (or its FERROEHR__… env override) at the mounted path. Secret-bearing scalar values
(DB DSN, HMAC secret, broker URLs, S3 keys, PGP passphrase) go into the chart’s
Secret, never the ConfigMap.
Security posture
The chart pins — and its validate.sh gate asserts on every render — the
following:
| Field | Value |
|---|---|
runAsNonRoot | true (uid/gid 65532, the distroless nonroot user) |
readOnlyRootFilesystem | true (a writable emptyDir is mounted at /tmp) |
allowPrivilegeEscalation | false |
capabilities.drop | [ALL] |
seccompProfile.type | RuntimeDefault (pod and container) |
| ServiceAccount token | not mounted (the workload never calls the K8s API) |
| NetworkPolicy | default-deny ingress; only the API (and management) port admitted |
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.
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
Probes use the always-on, unauthenticated, PHI-free health routes on the main HTTP port. They need no configuration at all — no management surface, no access level, nothing to forget:
| Probe | Route | Contract |
|---|---|---|
| liveness | /health/liveness | 200 while the process is up; touches no dependency |
| readiness | /health/readiness | 200 (UP/DEGRADED) or 503 (DOWN): checks DB ping, migrations applied, audit sender, events — each 1s-bounded |
| startup | /health/liveness | gates a slow first boot |
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). If the kubelet cannot reach the HTTP port, set probes.exec.enabled=true
to use the binary’s healthcheck subcommand instead.
The management surface is independent of the probes and stays ops-only
(/management/info, /prometheus, /metrics, /env, /loggers). Set
metrics.enabled=true to expose {management.basePath}/prometheus (access
level public) with the prometheus.io/* scrape annotations; the other
endpoints stay admin_only or off unless you opt in. Set 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.
Optional integrations
Every integration is off by default, matching the binary — enabling one is
an explicit, auditable decision. Each has a values.yaml switch and maps to a
config env prefix:
| Integration | Values key | Notes |
|---|---|---|
| ADMIN API | rest.adminEnabled | Physical, irreversible delete. Gate behind admin RBAC. |
| Terminology extension API | rest.terminologyEnabled | 404 when off. |
| Event-subscription API | rest.eventSubscriptionEnabled | Admin CRUD over event filters. |
| Multi-tenancy | tenancy.enabled | Tenant from a JWT claim; leave tenancy.header unset in prod. Pairs with PG row-level security. |
| OAuth2/OIDC auth | auth.oidc.* | Prefer JWKS/discovery over an HS256 secret. |
| ABAC | authz.abac.enabled | Cedar or a remote policy decision point; policies via a mounted TOML. |
| Eventing → AMQP | events.enabled | Envelopes are PHI-free by design. Use amqps:///tls=true. |
| FHIR inbound/façade | rest.fhirEnabled | Read façade + inbound mapping. |
| FHIR outbound → AMQP | fhirOutbound.enabled | ⚠ Carries PHI (the mapped FHIR resource). Separate exchange; TLS broker only. |
| S3 multimedia | multimedia.enabled | ⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket. |
| External terminology | externalTerminology.enabled | FHIR terminology server; provider map via a mounted TOML. |
| ATNA system log | audit.enabled | Use audit.syslog.transport: tls for PHI-adjacent audit. |
| Version signing | signing.* | On by default (digest). pgp mode fails closed at boot without a usable key. |
| OTLP telemetry | telemetry.otel.* | Unset endpoint ⇒ the OTel layer is not installed (zero overhead). |
Full detail on each is in Beyond the core, Security & multi-tenancy, and Operations.
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 >= 2 (or autoscaling) 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.
Render and validate the chart before applying with deploy/helm/validate.sh
(helm lint + template + the security-field gate + golden-render diff).
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.96.1 (edition
2024) via
rust-toolchain.toml, sorustupinstalls and selects it automatically the first time you build in the checkout — you do not choose a version by hand. - Docker — required only for the integration tests, which spin up a real PostgreSQL 18 in a container.
xmllint— required only for the canonical-XML tests.
Building
From the repository root:
cargo build --workspace
To build just the server binary in release mode (what the container image ships):
cargo build --release --locked -p ferroehr
The resulting binary is target/release/ferroehr. It is statically linked
against 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
The suite includes integration tests that start PostgreSQL 18 via
testcontainers, so Docker must be running.
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). The binary also has a healthcheck
subcommand (used by the container healthcheck and Kubernetes exec probes) that
hits the status endpoint and exits 0 or 1.
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.
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 chapter is the complete
reference: the quickstart, how configuration loads and how env names map onto
the file, a subsection per configuration area, the CLI tools that validate and
scaffold a config, the production checklist, and — for upgraders — the old→new
variable map.
- Quickstart
- How configuration loads
[server][db][log][telemetry][auth][authz][admin][tenancy][smart][management][signing][query][events][fhir][terminology][multimedia][audit][subject_proxy]- Process / CLI
- The
configsubcommands - Zero-config boot and the production checklist
- What belongs in a mounted file (vs env)
- Migrating from 3.x environment variables
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 (see the checklist below).
$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 (see Zero-config boot) — the file is optional, and every key has a default.
How configuration loads
Configuration is assembled once at boot from four layers, lowest precedence to highest:
- Built-in defaults — the values in the tables below.
- The config file —
ferroehr.toml(see file discovery). FERROEHR_*environment variables — override individual keys.--set key=valueCLI flags (repeatable) — win over everything.
Two permanent conventional aliases sit below their FERROEHR_ forms within layer 3:
DATABASE_URL → db.url and RUST_LOG → log.filter. Nothing else has a
non-FERROEHR_ name.
The environment-variable mapping
Every key has one mechanical env spelling: FERROEHR + the TOML path,
upper-cased, with a double underscore (__) between every segment — including
after the FERROEHR prefix. A single underscore only ever appears inside a
key word.
| TOML | Environment variable |
|---|---|
[db] max_connections = 20 | FERROEHR__DB__MAX_CONNECTIONS=20 |
[auth.oidc] issuer = "…" | FERROEHR__AUTH__OIDC__ISSUER=… |
[management.endpoints] env = "off" | FERROEHR__MANAGEMENT__ENDPOINTS__ENV=off |
[terminology.external.providers.default] url = "…" | FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__DEFAULT__URL=… |
Scalars are typed automatically (bool / int / float, else string).
List-typed keys take comma-separated values
(FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr,other). Arrays of tables — the
Basic-auth user store — are file-only.
Note
Enum values are lowercase /
snake_casetokens, exactly as the tables show. Secret-typed keys are redacted everywhere the config is rendered (the/management/envsnapshot,ferroehr config check, logs), and each has a*_filesibling that reads the value from a file (for Kubernetes/Docker secret mounts). Setting a secret and its*_filesibling at once is a boot error.
File discovery
The first of these that exists is loaded (later layers still override its values):
--config <path>(fatal if missing/unreadable),FERROEHR_CONFIG=<path>(fatal if missing/unreadable),./ferroehr.toml(current directory),/etc/ferroehr/ferroehr.toml.
An explicitly pointed-at file (1–2) is fatal if absent; the search-order files (3–4) are simply skipped when absent (but fatal if present and 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
file:line) and in theFERROEHR_environment namespace — with a did-you-mean suggestion. A misspelled security key can no longer be silently ignored. - Type errors are boot errors, naming the key, the expected type, and where the bad value came from.
- Semantic errors are aggregated — one pass reports every problem at once, so a broken config is fixed in a single iteration.
[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 = true
cors_permissive = false
system_id = "ferroehr.local"
| Key | Type | Default | Description |
|---|---|---|---|
bind | string | 0.0.0.0:8080 | Socket address the API listener binds. |
base_path | string | /ferroehr/rest/openehr/v1 | ITS-REST base path all API routes hang off. The status, health-adjacent and documentation routes hang off its parent (/ferroehr/rest by default), and the served OpenAPI document describes whatever paths this setting produces — never the defaults. |
max_in_flight | int | 256 | Concurrent-request admission cap (not per second). Requests beyond it are shed immediately with 503 + Retry-After — never queued — so offered load beyond capacity cannot exhaust memory. Status/health/discovery routes are never limited. 0 disables shedding. |
swagger_ui | bool | true | Serve Swagger UI + the OpenAPI JSON at the REST root. Consider false in production. |
cors_permissive | bool | false | Permissive (development) CORS. Production configures explicit origins. |
system_id | string | ferroehr.local | This deployment’s own openEHR system identifier — see below. Set a stable, deployment-unique name in production (FERROEHR__SERVER__SYSTEM_ID). |
system_id: the data-authoring identity
system_id is the identifier this CDR stamps into the data it authors. It
appears on the wire in three places:
EHR.system_id, recorded when an EHR is created. The openEHR RM (EHR Information Model, EHR Identifier Allocation) says theEHR.system_id“should be set to the value that would normally be used for locally created EHRs” — i.e. a value the deployment chooses, not a product constant.AUDIT_DETAILS.system_idon every commit for which the client did not supply one through theopenehr-audit-detailsheader. The openEHR REST API requires that “whensystem_idis not provided by the client, the server MUST set it to its own configured system identifier”.OBJECT_VERSION_ID.creating_system_id— the middle segment of every version identifier the server mints (<object_id>::<creating_system_id>::<version>).
Practical notes:
- Choose it before going live and keep it stable. The value is stored with
each EHR and each version; changing it later affects only newly authored
data — existing EHR ids, audit rows, and version identifiers are never
rewritten, and previously issued
OBJECT_VERSION_IDs stay valid. - Make it unique per system, so data exchanged between openEHR systems
keeps unambiguous provenance. A DNS-style host name (
cdr.hospital.example) is the common convention. - With multi-tenancy on (
[tenancy]), a tenant’s ownsystem_idtakes precedence over this value for requests resolved to that tenant. - Validated at boot. An empty value is refused (the RM requires a
non-empty
AUDIT_DETAILS.system_id), as is one containing::— that is theOBJECT_VERSION_IDfield separator, so it would make version identifiers unparseable. system_idis not[server.identity].system_idsays which system authored the data;[server.identity]below is the display identity of theOPTIONSSystem-Options manifest (product, version, vendor, advertised profile). Rebranding changes the manifest and nothing in stored data; changingsystem_idchanges what new data says about its origin and leaves the manifest alone. They are set independently.
[server.tls] — native TLS + mutual-TLS client authentication
Native TLS termination on the main listener (off by default — deployments
commonly terminate TLS at an ingress). Protocol floor: TLS 1.2+, per IETF
BCP 195. 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.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Terminate TLS natively on the main listener. |
cert_file | path | unset | Server certificate chain (PEM). Required when enabled. |
key_file | path | unset | Server private key (PEM). Required when enabled. |
client_auth | enum{off,optional,required} | off | Client-certificate policy: required rejects any client without a verified certificate at the handshake. |
client_ca_file | path | unset | The explicit CA bundle client certificates must chain to (never the web PKI). Required unless client_auth = "off". |
[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). Defaults
are measured, not asserted — the manifest never out-claims the last
conformance verdict. This is the deployment’s display identity only; the
identifier stamped into authored data is
server.system_id.
| Key | Type | Default | Description |
|---|---|---|---|
solution | string | FerroEHR | Product name. |
solution_version | string | build version | Product version. |
vendor | string | FerroEHR project | Providing organisation. |
restapi_specs_version | string | tested-contract identity | openEHR REST API edition the build is tested against. |
conformance_profile | string | last CNF verdict | Advertised conformance profile. |
[db]
PostgreSQL connection.
[db]
url = "postgres://ferroehr:ferroehr@localhost:5432/ferroehr"
max_connections = 20
min_connections = 2
acquire_timeout_secs = 30
| Key | Type | Default | Description |
|---|---|---|---|
url | secret URL | postgres://ferroehr:ferroehr@localhost:5432/ferroehr | Connection DSN. The default matches the compose dev stack; production MUST set it. Credentials are redacted from every rendering. DATABASE_URL is a recognized lower-priority alias. |
max_connections | int | 20 | Pool ceiling. Write-heavy deployments benefit from 50+. |
min_connections | int | 2 | Idle connections kept open (avoids cold-reopen churn). |
acquire_timeout_secs | int | 30 | Seconds to wait for a free connection before failing. |
[log]
[log]
format = "auto"
filter = "info,ferroehr=info"
| Key | Type | Default | Description |
|---|---|---|---|
format | enum{auto,json,pretty} | auto | Stdout rendering; auto picks json when stdout is not a TTY (and suppresses the boot banner). |
filter | string | info,ferroehr=info | Boot EnvFilter directives; also the /management/loggers reset target. RUST_LOG is a recognized lower-priority alias. |
[telemetry]
OpenTelemetry export. Unset otlp_endpoint ⇒ the OTel layer is not installed
(zero overhead).
| Key | Type | Default | Description |
|---|---|---|---|
otlp_endpoint | string | unset | OTLP/gRPC collector endpoint. |
service_name | string | ferroehr | service.name resource attribute. |
environment | string | dev | deployment.environment resource attribute. |
traces_sample_ratio | float | 1.0 | Head-sampling ratio (0.1 is a common prod start). |
metrics_push | bool | false | Also push metrics over OTLP alongside the Prometheus pull surface. |
[auth]
Authentication (Basic + OAuth2/OIDC bearer).
[auth]
enabled = true
verified_cache_ttl_seconds = 60
[[auth.basic.users]]
username = "clinician"
password_hash = "$argon2id$v=19$..." # Argon2 PHC hash, never plaintext
roles = ["USER"]
[auth.oidc]
issuer = "https://keycloak.example.com/realms/ferroehr"
audiences = ["ferroehr"]
algorithms = ["RS256"]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master switch. false = all requests pass unauthenticated (dev only). With true and no mechanism configured, every API request 401s (fail-closed). |
verified_cache_ttl_seconds | int | 60 | Verified Basic-credential cache TTL (0 disables); bounds Argon2 cost per busy client and revocation lag alike. |
[[auth.basic.users]] — the Basic-auth user store (array of tables,
file-only):
| Key | Type | Default | Description |
|---|---|---|---|
username | string | required | Principal name. |
password_hash | secret | required | Argon2 PHC hash ($argon2id$v=19$…), never a plaintext password. |
roles | list of string | ["USER"] | Roles granted (upper-cased on authentication). |
[auth.oidc] — bearer validation (absent table ⇒ bearer disabled):
| Key | Type | Default | Description |
|---|---|---|---|
issuer | string | required when present | Expected iss; also the OIDC discovery base. |
audiences | list of string | [] | Accepted aud (empty = not checked). |
algorithms | list of string | ["RS256"] | Accepted signature algorithms. |
hmac_secret / hmac_secret_file | secret / path | unset | Symmetric HS256 secret (dev/test). At most one of the pair. |
jwks_json / jwks_json_file | string / path | unset | Static JWKS document; preferred over discovery when present. |
[authz]
RBAC + ABAC.
[authz.rbac]:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Coarse role gate (active when auth is enabled). |
admin_role | string | ADMIN | Role required for admin-class operations. |
user_role | string | USER | Baseline clinical role. |
readonly_role | string | READONLY | Role marking a principal read-only: refused on every write operation (create/update/delete/upload), even alongside granting roles. Reads and AQL queries are still allowed. |
role_claims | list of string | ["realm_access.roles","scope"] | JWT claim paths mined for roles. |
management_access | enum{admin_only,private,public} | admin_only | Access level for the management surface. |
[authz.abac]:
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Master ABAC switch. |
engine | enum{cedar,remote} | cedar | Embedded Cedar or a remote decision point. |
organization_claim | string | organization_id | JWT claim carrying the caller’s organization. |
patient_claim | string | patient_id | JWT claim carrying the patient id. |
[authz.abac.cedar]: policy_dir (path — required when engine=cedar and ABAC
on), reload_secs (int, unset — optional hot-reload interval).
[authz.abac.remote]: server (string — required when engine=remote, must
end /), connect_timeout_ms (int, 2000), request_timeout_ms (int,
5000).
[authz.abac.policy.<kind>] (kind ∈ ehr, ehr_status, composition,
contribution, query, directory): name (string), parameters (list of
enum{organization,patient,template}).
[admin]
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Mount the ADMIN API (physical, irreversible delete). Off ⇒ every admin route answers 405 Method Not Allowed with an empty Allow header, never 403. |
[tenancy]
Multi-tenancy.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Activate tenant middleware + row-level scoping. |
claim | string | tenant | JWT-claim path carrying the tenant key. |
header | string | unset | Dev-only request-header tenant override. Leave unset in production — a client header must not select a tenant. |
[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]: enabled (bool, false), platform_base_url (string, unset ⇒ REST
root), public_base_url (string, required when enabled — the external
origin, e.g. https://cdr.example.com, from which the discovery document’s
absolute services.*.baseUrl values are built), ehr_id_claim (string,
ehrId), patient_claim (string, patient), require_smart_scopes (bool,
false — when true the SMART resource-scope gate is fail-closed across the
composition, template, and AQL families, and the openehr-permission-v1
capability is advertised; when false the gate is advisory and the capability
is not claimed), launch_base64_json (bool, false).
[smart.episode]: enabled (bool, false).
[smart.endpoints]: issuer, jwks_uri, authorization_endpoint,
token_endpoint, registration_endpoint, introspection_endpoint,
revocation_endpoint, management_endpoint (all string, unset ⇒ omitted from
the discovery document); token_endpoint_auth_methods_supported,
grant_types_supported, response_types_supported,
code_challenge_methods_supported, scopes_supported, capabilities (all
list of string, [] — capabilities appends operator-advertised HL7 base
capabilities such as launch-ehr/sso-openid-connect to the derived openEHR
set). Deprecated grant types (implicit/password) are rejected at boot;
enabled = true additionally requires public_base_url,
authorization_endpoint, and token_endpoint at boot.
[management]
The ops-introspection surface (build info, Prometheus, metric views, effective config, runtime log control). Off in the bare binary 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"
access_default = "admin_only"
[management.endpoints]
info = "off"
metrics = "off"
prometheus = "off"
env = "off"
loggers = "off"
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Mount the management router. |
base_path | string | /management | Base path for the management endpoints. |
port | int | unset ⇒ share the main listener | Serve management on its own listener/port. Must differ from the server.bind port. |
access_default | enum{off,admin_only,private,public} | admin_only | Global default access level (a per-endpoint level wins). |
[management.endpoints] — info, metrics, prometheus, env, loggers,
each enum{off,admin_only,private,public}, default off.
Warning
probes_enabledandendpoints.healthwere removed. 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.
[signing]
VERSION signing. On by default in digest mode, with read-time verification of
the server’s own signatures strict by default.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Server-side signing of committed versions. |
mode | enum{digest,pgp} | digest | SHA-256 integrity digest, or an OpenPGP (RFC 4880) detached signature. |
key_path | path | unset | Armored secret key; required for pgp. |
key_passphrase / key_passphrase_file | secret / path | unset | Key passphrase. |
verify_on_read | enum{off,warn,strict} | strict when signing is enabled | Read-time recompute-and-compare policy for the server’s own signatures. |
Note
verify_on_readdefaults tostrictwhen signing is enabled. On every read the server recomputes the signature of a version it signed and, on a mismatch, returns a500integrity fault rather than silently serving a provably corrupt record. Set it explicitly towarn(log + emitversion_signature_invalid_total, still serve) oroff(never check) to opt out. Client-supplied signatures — an author’s own signature, or one carried by an imported version — are always stored verbatim and never re-verified (the author may have signed a different agreed serialization), regardless of this setting.
Warning
pgpmode fails closed at boot if the key is missing or unusable — the server will not start. Verify the key and passphrase before switching modes.
[query]
AQL execution knobs.
| Key | Type | Default | Description |
|---|---|---|---|
plan_cache_capacity | int | 256 | Max distinct cached query plans; 0 disables the cache. Cache activity is reported by the aql_plan_cache_events_total metric. |
timeout_ms | int | 0 | Per-query DB execution budget; 0 disables (the global request timeout remains). Overrun returns 408. |
[events]
Contribution-outbox eventing → AMQP, plus its admin API. Off by default; envelopes are PHI-free by design.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Spawn the outbox publisher (with fhir.outbound.enabled, gates the per-commit outbox INSERT). |
url | secret URL | amqp://guest:guest@localhost:5672/%2f | AMQP broker URL; credentials redacted from every rendering. |
exchange | string | ferroehr.events | Topic exchange (PHI-free envelope stream). |
tls | bool | false | Upgrade amqp:// to amqps://. |
batch_size | int | 128 | Rows drained per poll. |
poll_interval_ms | int | 1000 | Idle poll interval. |
retention_days | int | 7 | Published-row retention window. |
prune_interval_secs | int | 3600 | Retention-prune cadence. |
publish_max_retries | int | 3 | Per-row publish retries before backing off. |
admin_api | bool | false | Mount the /admin/event_subscription CRUD routes. |
[fhir]
The FHIR connector — an inbound façade and an independent outbound emitter.
[fhir]: api_enabled (bool, false) — mount /fhir/r4/* +
/admin/fhir_mapping.
[fhir.outbound]: enabled (bool, false), url (secret URL, same AMQP
default), exchange (string, ferroehr.fhir — deliberately distinct from the
events exchange for PHI isolation), tls (bool, false), batch_size (int,
128), poll_interval_ms (int, 1000), publish_max_retries (int, 3).
Warning
The outbound stream carries PHI — the mapped FHIR resource. It is a deliberately separate switch and exchange from the PHI-free change-event stream so broker access control can isolate it. Enable it only against a TLS, access-controlled broker.
[terminology]
Terminology extension API and external FHIR-terminology servers.
[terminology]: api_enabled (bool, false) — mount the terminology
extension API.
[terminology.external]: enabled (bool, false), fail_on_error (bool,
false — on TS/connectivity error, reject vs accept).
[terminology.external.providers.<name>] (conventionally default): type
(enum{fhir}, fhir), url (string, required), operation
(enum{validate_code,expand}, validate_code), connect_timeout_ms (int,
2000), request_timeout_ms (int, 10000), oauth2_client (string, unset —
must name an entry under [terminology.external.oauth2_clients]),
client_cert_path / client_key_path (paths, unset — the mutual-TLS client
identity, see below), ca_bundle_path (path, unset — the trust anchors for
this server, see below),
cache_ttl_secs (int, 300 — TTL of the per-provider response cache; a
repeated validate/expand/subsumes/lookup within the window is served locally
instead of one HTTPS round trip per validated code; 0 disables),
cache_capacity (int, 10000 — maximum cached responses per provider).
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.
[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.
Keys: token_url (string, required), client_id (string, required),
client_secret (secret — or client_secret_file pointing at a file holding
it; exactly one of the two), scopes (list of strings, empty),
refresh_leeway_secs (int, 30 — how long before expiry the token is
renewed), auth_method
(enum{client_secret_basic,client_secret_post}, client_secret_basic).
[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 HAPI server holds three different certificates. Repeat the same paths in each provider table if one identity really does serve them all.
Keys on [terminology.external.providers.<name>]:
| Key | Meaning |
|---|---|
client_cert_path | PEM file with the client certificate (optionally a chain) presented to this server. |
client_key_path | PEM file with that certificate’s private key. |
ca_bundle_path | PEM bundle of the trust anchors this server’s certificate is verified against. |
[terminology.external.providers.snomed]
type = "fhir"
url = "https://snowstorm.example.org/fhir"
client_cert_path = "/run/secrets/ts-snomed-client.crt.pem"
client_key_path = "/run/secrets/ts-snomed-client.key.pem"
ca_bundle_path = "/run/secrets/ts-snomed-ca.pem"
client_cert_path and client_key_path are set together — one without the
other is a startup error, never a connection that silently presents no
certificate. Unreadable files, a certificate file with no certificate in it and
a key file with no key in it are startup errors too, so a broken identity never
waits until the first validated code to surface.
ca_bundle_path replaces the default trust anchors for that provider, so a
terminology server issued by a private PKI is pinned to that PKI instead of also
accepting the whole public web PKI. Leave it unset to use the platform’s default
trust store.
Important
There is no option to disable certificate verification. Server-certificate and hostname verification are always on for every provider;
ca_bundle_pathchanges which anchors are trusted, never whether the server is verified.
The client identity applies to the connection to the terminology server itself.
An OAuth2 token endpoint (oauth2_client) is a different host in a different
trust domain and keeps the default TLS stack.
Kubernetes deployments mount the PEM files with the chart’s config.files map
(see the Helm chart values), 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 is
sent to the server the binding’s terminology routes to.
- The code is in the value set → the commit proceeds.
- The code is not in the value set →
422naming the path, the code, and the bound query. This is a real constraint violation, sofail_on_errordoes not change it. - The value set could not be resolved (server down, error response, no
provider routes to that terminology) →
fail_on_errordecides:false(default) accepts the commit and logs a warning;truerejects it with422.
With [terminology.external] enabled = false (the default) no binding is
resolved and no request is made, so commit behaviour is exactly as before.
Note
The composition’s
terminology_idis sent verbatim as the FHIRsystemparameter. If your archetypes useSNOMED-CTwhere your terminology server expectshttp://snomed.info/sct, configure the server to accept the id your archetypes carry — the CDR does not rewrite it.
[multimedia]
DV_MULTIMEDIA externalization → S3-compatible object store. Off by default (blobs stay inline, byte-identical).
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Externalize large multimedia data. |
threshold_bytes | int | 262144 (256 KiB) | Decoded size strictly above which data is offloaded. |
endpoint | string | unset ⇒ AWS default | S3-compatible endpoint. |
bucket | string | openehr-multimedia | Target bucket. |
region | string | us-east-1 | AWS region (required even for non-AWS endpoints). |
access_key_id | string | unset | S3 access key id (unset + no secret = anonymous). |
secret_access_key / secret_access_key_file | secret / path | unset | S3 secret access key. |
allow_http | bool | false | Allow plain-HTTP endpoints — dev only; prod S3 is HTTPS. |
[audit]
The IHE ATNA audit trail (see the Audit trail chapter). On
by default with only the local store active; forwarding is opt-in per
sink. (Replaces the former [atna] section — old keys fail at boot with
did-you-mean guidance.)
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master audit switch. |
enterprise_site_id | string | unset | AuditEnterpriseSiteID. |
source_id | string | ferroehr | Audit source id. |
value_if_missing | string | UNKNOWN | Fill value for empty mandatory fields. |
suppress_login_events | bool | true | Skip successful-login records (rejections are always recorded). |
fail_mode | enum{open,closed} | open | On undeliverable audit: succeed and meter (open) or reject auditable operations with 503 (closed — includes an unhealthy local store). |
resolve_subject | bool | true | Enrich the patient participant via a background subject lookup. |
queue_capacity | int | 8192 | Bounded audit queue capacity (sized for write-path bursts; the drain persists in multi-row batches). |
server_host | string | unset | This node’s advertised address (NetworkAccessPointID). |
[audit.store] — the local Audit Record Repository
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Persist every record in the audit schema (served via the ITI-81 GET /fhir/r4/AuditEvent search). |
retention_days | int | 0 | Days to keep records; 0 = keep forever. Applied hourly. |
[audit.syslog] — the classic DICOM/syslog feed (ITI-20)
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Ship DICOM PS3.15 records to an external ARR over syslog. |
host | string | localhost | ARR host. |
port | int | 514 | ARR port (514 UDP / 6514 TLS typical). |
transport | enum{udp,tls} | udp | Syslog transport. Use tls for PHI-adjacent audit. |
tls_ca_file / tls_identity_cert_file / tls_identity_key_file | path | unset | PEM CA / client cert / client key for the TLS transport. |
[audit.fhir_feed] — the RESTful-ATNA feed (ITI-20 ATX:FHIR Feed)
| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | POST each FHIR AuditEvent to an external FHIR ARR. Outbox-driven (loss-free) when the local store is on. |
url | url | http://localhost:8080/fhir | The ARR’s FHIR base; records go to {url}/AuditEvent. URL credentials are redacted from every rendering. |
batch_size | int | 64 | Outbox rows shipped per poll. |
poll_interval_ms | int | 2000 | Outbox poll interval when idle. |
max_retries | int | 3 | Per-record POST retries before the record is left pending (store on) or dropped + metered (store off). |
[subject_proxy]
FHIR frames. Empty by default — no external FHIR system is reachable until one
is named here (fail-closed). Systems are keyed by the name subject-proxy frames
use as their system_id. See Subject Proxy.
[subject_proxy.systems.<name>]: base_url (string, required per system),
connect_timeout_ms (int, 2000), request_timeout_ms (int, 10000).
[subject_proxy.systems.pas]
base_url = "https://pas.example.com/fhir"
The env form for a named system is
FERROEHR__SUBJECT_PROXY__SYSTEMS__PAS__BASE_URL.
Process / CLI
| Variable | Type | Default | Description |
|---|---|---|---|
FERROEHR_HEALTHCHECK_URL | URL | http://127.0.0.1:8080/ferroehr/rest/status | Target URL for the binary’s healthcheck subcommand (container HEALTHCHECK and Kubernetes exec probes). Not part of ferroehr.toml. |
The config subcommands
ferroehr config default # print the annotated default ferroehr.toml
ferroehr config check [--config P] # validate (file + env + --set), print the
# effective config (secrets redacted) with
# a provenance column; exit 0 on success, 1 on error
ferroehr config check runs the exact same three validation passes as boot but
touches no database — use it in CI and before a rollout.
Zero-config boot and the production checklist
With no file and no environment, the server boots as: listener 0.0.0.0:8080
at the ITS-REST base path with Swagger UI; DB at the compose-dev DSN; auth
enabled with no mechanism ⇒ every API request 401s (fail-closed; boot logs
a prominent warning naming the two ways out — add [[auth.basic.users]] /
[auth.oidc], or set auth.enabled = false for dev); RBAC on; signing on
(digest); log auto/info; everything else off.
For production, set at least:
db.url— the real DSN, viaFERROEHR__DB__URL(from a secret) or a*_file-mounted value, never inline in a world-readable file.- an auth mechanism — a Basic user store and/or
[auth.oidc]. log.format = "json"for cluster log collectors.server.cors_permissivestaysfalse;server.swagger_uiper posture.server.system_id— this deployment’s own openEHR system identifier (defaultferroehr.local). Choose it before the first EHR is created: it is stored with every EHR, audit entry, and version identifier.management.*per posture (a dedicatedportis recommended so/managementis never reachable on the clinical listener).- TLS everywhere a transport supports it —
audit.syslog.transport = "tls",events.tls,fhir.outbound.tls, HTTPS S3. - real secrets via env or
*_file, never inline.
What belongs in a mounted file (vs env)
Env 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/ABAC policies, ATNA TLS PEMs, a JWKS
blob — is referenced by an in-TOML *_path / *_file key pointing at a
mounted path (e.g. the Helm chart’s config.files). Everything else is a
plain key you can set in the file or override with an FERROEHR_* env var.
See docker/ferroehr.dev.toml in the repository for a worked dev example
(server section, CORS, admin, and the Basic-auth users).
Migrating from 3.x environment variables
The pre-redesign layout used ~14 independent loaders, several env-name
grammars, and nine FERROEHR_*_CONFIG file pointers. Every old server variable
now fails at boot with the exact uniform replacement suggested — there is
no legacy alias layer (greenfield: nothing was deployed to migrate). The table
below maps every old spelling to its replacement.
| Old variable | New key (env form) | Fate |
|---|---|---|
FERROEHR_DB_URL | db.url (FERROEHR__DB__URL) | boot error — use the new spelling |
DATABASE_URL | db.url | kept permanently |
FERROEHR_DB_MAX_CONNECTIONS / _MIN_CONNECTIONS / _ACQUIRE_TIMEOUT_SECS | db.* (FERROEHR__DB__*) | boot error — use the new spelling |
FERROEHR_LOG_FORMAT / FERROEHR_LOG_FILTER | log.* (FERROEHR__LOG__*) | boot error — use the new spelling |
RUST_LOG | log.filter | kept permanently |
FERROEHR_OTEL_* | telemetry.* | boot error — use the new spelling |
FERROEHR_REST_CONFIG | --config / FERROEHR_CONFIG + ferroehr.toml | removed — merge the file into ferroehr.toml |
FERROEHR_REST_BIND / _BASE_PATH / _SWAGGER_UI / _CORS_PERMISSIVE | server.* (FERROEHR__SERVER__*) | boot error — use the new spelling |
FERROEHR_REST_MAX_IN_FLIGHT | server.max_in_flight (FERROEHR__SERVER__MAX_IN_FLIGHT) | boot error — use the new spelling |
FERROEHR_REST_SYSTEM__* | server.identity.* | boot error — use the new spelling |
FERROEHR_REST_AUTH__ENABLED / _VERIFIED_CACHE_TTL_SECONDS | auth.* (FERROEHR__AUTH__*) | boot error — use the new spelling |
FERROEHR_REST_AUTH__OIDC__* | auth.oidc.* (FERROEHR__AUTH__OIDC__*) | boot error — use the new spelling |
FERROEHR_REST_AUTH__BASIC__USERS | [[auth.basic.users]] (file-only) | removed — set in the file |
FERROEHR_REST_AUTH__ADMIN_SCOPE | — (subsumed by authz.rbac.admin_role) | removed |
FERROEHR_REST_ADMIN__ENABLED | admin.enabled | boot error — use the new spelling |
FERROEHR_REST_TENANCY__* | tenancy.* | boot error — use the new spelling |
FERROEHR_REST_TERMINOLOGY__ENABLED | terminology.api_enabled | boot error — use the new spelling |
FERROEHR_REST_EVENT_SUBSCRIPTION__ENABLED | events.admin_api | boot error — use the new spelling |
FERROEHR_REST_FHIR__ENABLED | fhir.api_enabled | boot error — use the new spelling |
FERROEHR_REST_SMART__* | smart.* (FERROEHR__SMART__*) | boot error — use the new spelling |
FERROEHR_MANAGEMENT_* | management.* (FERROEHR__MANAGEMENT__*) | boot error — use the new spelling |
FERROEHR_MANAGEMENT_ENDPOINTS_<EP> | management.endpoints.<ep> (FERROEHR__MANAGEMENT__ENDPOINTS__<EP>) | boot error — use the new spelling |
FERROEHR_AUTHZ_RBAC__* / FERROEHR_AUTHZ_ABAC__* | authz.rbac.* / authz.abac.* (FERROEHR__AUTHZ__…) | boot error — use the new spelling |
FERROEHR_ATNA_<KEY> | audit.<key> (FERROEHR__AUDIT__<KEY>) | boot error — use the new spelling |
FERROEHR_SIGNING_<KEY> | signing.<key> (FERROEHR__SIGNING__<KEY>) | boot error — use the new spelling |
FERROEHR_EVENTS_<KEY> | events.<key> (FERROEHR__EVENTS__<KEY>) | boot error — use the new spelling |
FERROEHR_FHIR_OUTBOUND_<KEY> | fhir.outbound.<key> (FERROEHR__FHIR__OUTBOUND__<KEY>) | boot error — use the new spelling |
FERROEHR_MULTIMEDIA_<KEY> | multimedia.<key> (FERROEHR__MULTIMEDIA__<KEY>) | boot error — use the new spelling |
FERROEHR_VALIDATION_EXTERNAL_TERMINOLOGY_* | terminology.external.* | boot error — use the new spelling |
FERROEHR__SUBJECT_PROXY__SYSTEMS__* | subject_proxy.systems.* — same spelling, now actually binds | binds for the first time |
FERROEHR__QUERY__PLAN_CACHE_CAPACITY / _TIMEOUT_MS | query.* — same spelling, now strict-parsed | behaviour change (bad values now error) |
the nine FERROEHR_*_CONFIG file pointers | — | removed — merge each file’s contents into ferroehr.toml under its [section] |
The PostgreSQL init container variables FERROEHR_DB_USER / _PASSWORD /
_NAME were renamed to PG_INIT_USER / _PASSWORD / _DB — they configure
the database container, not the server, and no longer collide with the
server’s reserved FERROEHR_ namespace.
Concepts
This part explains the ideas you need to use FerroEHR effectively. If openEHR is new to you, read the openEHR primer first — it covers the Reference Model, archetypes and templates, versioning, and AQL without assuming prior knowledge. Then System architecture shows how FerroEHR is put together and where your data actually lives, so the behaviour you see through the API makes sense.
You do not need to read these before Getting started — but a few minutes here will make every later chapter clearer.
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 implements RM 1.2.0.
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 in the Operational Template (OPT) 1.4 XML format. Once a template is uploaded, the server derives everything it needs to validate incoming data and to describe the data’s shape to client applications.
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 not replaced — it is retained, and a new version is created. 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.
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 supports reading both the latest version and all versions of an object, and querying 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.
How it fits together
flowchart TB
rm["Reference Model 1.2.0<br/>(generic building blocks)"]
arch["Archetypes<br/>(one clinical concept each)"]
tmpl["Template (OPT 1.4)<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 compliance claims are trustworthy, why versioning is exact, and why AQL is fast. Two ideas run through everything: the openEHR specification layer is generated from the official models rather than hand-written, 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 & pinned)"]
subgraph gen ["Specification layer (generated, never hand-edited)"]
types["RM 1.2.0 types · canonical JSON & XML<br/>ITS-REST contract (Release-1.1.0) · AQL 1.1 parser · SDT 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 · integrations"]
end
db[("PostgreSQL 18")]
specs -->|deterministic codegen, drift-checked in CI| gen
rest --> sm
core -->|implements| sm
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, not a rewrite.
The application layer is the server — everything the generated layer does not give you: storage, the query execution engine, validation, security, and the integration connectors. This is where design choices specific to FerroEHR live.
The native service API
Internally the server is organised around the openEHR Platform Service Model — a standard catalogue of service interfaces (EHR, Composition, Directory, Contribution, Query, Definition, Terminology, Admin, and more). Each is a Rust trait carrying the specification’s own operation names and parameters. The REST layer is a thin protocol adapter over that native API. Practically, this means the HTTP behaviour you observe maps one-to-one onto the standard’s own service definitions, and the same core can be driven by adapters other than REST.
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 node’s own content is stored as canonical openEHR JSON, verbatim. 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 uses a single temporal version table. Instead of separate
“current” and “history” tables, each version is a row with a validity period,
and PostgreSQL 18’s temporal constraints enforce that periods never overlap.
The current version is the one whose period is still open. Because history is
just rows in the same table, FerroEHR can serve both LATEST_VERSION and
ALL_VERSIONS queries — reading the record as it is now, or across its entire
history — from one place.
Time-ordered UUIDv7 keys, database-generated, keep inserts index-friendly. 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.
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 go through a helper that implements openEHR’s
magnitude semantics. The result is assembled into the standard RESULT_SET
shape. See Querying with AQL for the language itself and
its supported feature envelope.
What this means for you
- Trustworthy conformance. Because the wire contract and data types are generated from the standard and drift-checked, and because each release runs the full conformance catalogue against the live server, the compliance claims are machine-verified rather than asserted. See Conformance.
- Exact versioning. Nothing is overwritten; every version and its audit are retained and queryable.
- Operational simplicity. The server is a single static binary on PostgreSQL 18 — see Installation.
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. The complete, machine-generated endpoint reference (every path,
parameter, and schema) is published separately as the API reference on the
documentation site (under /ferroehr/api/); this book explains how to use
it.
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 (see the
configuration reference).
The public, unauthenticated status probe lives just outside the base path at
/ferroehr/rest/status, and interactive docs at /ferroehr/rest/swagger-ui when
enabled.
An OPTIONS request to the API base path (also answered at /) returns the
server’s conformance manifest: the product name and version, the openEHR
REST API edition it implements, its conformance profile, and the endpoint
groups actually mounted in this deployment — useful for capability discovery
before you call anything else. The identity fields are configurable
(FERROEHR__SERVER__IDENTITY__*, see the
configuration reference); the endpoint
list always reflects reality.
Authentication
Requests are authenticated unless auth is explicitly disabled. Two mechanisms ship in Stage 1:
- HTTP Basic — a configured user store; send
Authorization: Basic .... The examples in this book use-u user:passwordwith curl. - OAuth2 / OIDC bearer tokens — send
Authorization: Bearer <token>, validated against a configured issuer (Keycloak, Active Directory, any standards-compliant provider).
A request with no or invalid credentials gets 401 Unauthorized; an
authenticated caller lacking the required role gets 403 Forbidden.
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. The full picture — mechanisms, roles, multi-tenancy —
is in Security & multi-tenancy.
Note
The development stack ships throwaway Basic users (
ferroehr/ferroehr). Replace them 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
Preferheader,ETag/If-Matchoptimistic concurrency, and the error response shape.
For querying, see Querying with AQL; for loading templates, Templates & validation.
Resource walkthroughs
This chapter walks through the core openEHR resources — EHR, EHR_STATUS,
COMPOSITION, DIRECTORY, and CONTRIBUTION — 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), and
anything unparseable 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.
To create with a specific id, use PUT /ehr/{ehr_id} (also 201; 409 if that id
is already used).
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).
EHR_STATUS
EHR_STATUS holds the record’s metadata — the link to the subject, and the
is_queryable / is_modifiable flags. It is itself versioned.
Setting is_modifiable to false deactivates the EHR: any attempt to
create, update, or delete its content — a composition, the directory, or a
folder — is refused with 409 Conflict, through every write path
including a CONTRIBUTION commit. The EHR_STATUS itself stays writable (so you
can set the flag back to true to reactivate), and reads and queries are
unaffected.
Read the current status
curl -u ferroehr:ferroehr \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/ehr_status
GET /ehr/{ehr_id}/ehr_status returns the current EHR_STATUS, its version id
in ETag. Add ?version_at_time=<ISO 8601> to read it as of a point in time.
GET .../ehr_status/{version_uid} reads a specific version.
Update the status
Updates require the current version id in an If-Match header (optimistic
concurrency):
curl -u ferroehr:ferroehr -X PUT \
-H 'Content-Type: application/json' \
-H 'If-Match: "<current-version-uid>"' \
-H 'Prefer: return=representation' \
--data-binary @ehr-status.json \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/ehr_status
PUT /ehr/{ehr_id}/ehr_status returns 200 (with representation) or 204
(minimal), plus the new ETag. A stale or wrong If-Match returns 412
Precondition Failed with the current version id in ETag.
Status version history
The versioned_ehr_status sub-resource exposes the full version history:
GET .../versioned_ehr_status— theVERSIONED_EHR_STATUSobject,GET .../versioned_ehr_status/revision_history— the revision history,GET .../versioned_ehr_status/version(optionally?version_at_time=) and.../version/{version_uid}— a specific version.
COMPOSITION
A COMPOSITION is a committed clinical document, validated against its template.
Create a composition
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
-H 'Prefer: return=representation' \
--data-binary @composition.json \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition
POST /ehr/{ehr_id}/composition returns 201 Created with the version id in
ETag. Validation failures against the template return 422 Unprocessable
Entity (with the errors); a malformed request returns 400; 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; deleting something already
deleted returns 400, and a version id that is not the latest returns 409.
Note
Watch the id you pass.
PUTtakes the object uuid (the versioned object), whileDELETEtakes the full version id (the version you are superseding).GETaccepts either.
Note
A version’s lifecycle state (set through the
openehr-version: lifecycle_state.code_stringheader — see Content negotiation & errors; the default on a commit is532|complete|) must follow the openEHR version-lifecycle state machine. An illegal transition is rejected with 422 Unprocessable Entity naming the states. In particular, a version left in the801|abandoned|state cannot be updated straight tocomplete— you must first retrieve it back to553|incomplete|, then complete it.
Composition version history
GET .../versioned_composition/{versioned_object_uid} and its
revision_history, version, and version/{version_uid} sub-resources mirror
the EHR_STATUS history endpoints.
DIRECTORY
The DIRECTORY is an optional FOLDER tree for organising compositions
within an EHR. The /directory endpoints manage the EHR’s primary hierarchy
(the openEHR EHR.directory, which is always the first member of
EHR.folders).
An EHR can also index additional folder hierarchies beyond the directory:
commit further root FOLDERs through the CONTRIBUTION endpoint (the openEHR
REST API defines no dedicated endpoint for them). The EHR resource then lists
every live hierarchy in its folders attribute, in creation order, with
directory always equal to the first member; deleting the directory promotes
the next live hierarchy.
# Create the directory
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
--data-binary @folder.json \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/directory
# Read it (optionally at a time, or a sub-path)
curl -u ferroehr:ferroehr \
'http://localhost:8080/ferroehr/rest/openehr/v1/ehr/'$EHR_ID'/directory?path=episodes/2024'
POST /ehr/{ehr_id}/directory— create the root folder; 201.PUT /ehr/{ehr_id}/directory— update it; requiresIf-Match; 200/204.DELETE /ehr/{ehr_id}/directory— logical delete; requiresIf-Match; 204.GET /ehr/{ehr_id}/directory— the current folder tree, optionally filtered by?version_at_time=and?path=(slash-separated folder names). 204 if deleted at that time.GET /ehr/{ehr_id}/directory/{version_uid}— a specific version, optionally?path=.
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, and per-version
commit_audit) plus a shared audit. The audit objects are of type
UPDATE_AUDIT (the server fills in time_committed and system_id). It returns
201 with the contribution id in ETag, or 400/404/409 on
invalid input, unknown EHR, or a uid conflict.
GET /ehr/{ehr_id}/contribution/{contribution_uid} returns 200 with the
contribution, or 404.
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": "…" }
],
"total": 123
}
Note
The contribution envelope is always canonical JSON (or XML). The FLAT and STRUCTURED formats, when used, apply only to the inner composition
dataof each version, not to the envelope.
Status-code summary
| Code | Meaning across these resources |
|---|---|
| 200 | Retrieved, or updated with Prefer: return=representation. |
| 201 | Created (EHR, composition, directory, contribution). |
| 204 | Success with no body (return=minimal), or deleted / deleted-at-time. |
| 400 | Malformed request, missing required header/parameter, or already-deleted. |
| 404 | Unknown EHR, object, version, or no version at the requested time. |
| 409 | Conflict — duplicate subject/id, a version that is not the latest, or a content write to a deactivated (is_modifiable = false) EHR. |
| 412 | If-Match did not match the latest version (current id returned in ETag). |
| 422 | Composition is well-formed but fails template/semantic validation, or an illegal version-lifecycle transition. |
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/jsonorapplication/xml. - Response: set
Accept: application/jsonorapplication/xml.
JSON is wired end to end for every operation. XML is supported for the
spec-typed RM objects — a single composition, EHR_STATUS, EHR, FOLDER, and
the version family (versioned objects and revision history) — whose canonical
XML shape the openEHR ITS-XML schemas define. 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 that differ only by the namespace the document declares:
| Lineage | Root namespace | Status |
|---|---|---|
| v1 (default) | http://schemas.openehr.org/v1 | The stable schema release most openEHR tooling reads |
| v2 | http://schemas.openehr.org/v2 | The newer schema release, still marked trial by openEHR |
Pick one per request with a version parameter on the XML media type:
# Read a composition in the v2 namespace
curl -u ferroehr:ferroehr \
-H 'Accept: application/xml; version=2' \
"http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition/$UID"
# Commit one whose payload already uses the v2 namespace
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/xml; version=2' \
-H 'Accept: application/xml; version=2' \
--data-binary @composition-v2.xml \
"http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition"
What to expect:
- Leave the parameter off and nothing changes. No parameter (or
version=1) means v1, exactly as before, and the response header staysContent-Type: application/xml. - A v2 response says so:
Content-Type: application/xml; version=2. - On requests the parameter is a courtesy, not a requirement. The server reads both namespaces regardless of what you declare, so you only need it when you want the declaration to be accurate.
- Asking for a namespace the server does not serve (
version=3, say) is 406 Not Acceptable onAcceptand 415 Unsupported Media Type onContent-Type. - 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.
Note
The
versionparameter is a FerroEHR extension: the openEHR REST specification predates the two schema lineages and says nothing about selecting one. It never changes the media type itself — responses are alwaysapplication/xml— so a client that ignores it behaves exactly as the specification describes.
Simplified formats (FLAT and STRUCTURED)
Beyond the canonical formats, the server implements the openEHR Simplified
Formats — template-driven JSON representations that use friendly field
identifiers (vital_signs/body_temperature:0/any_event:0/temperature|magnitude)
instead of full RM paths. Select them the same way as JSON/XML, with these
media types:
| Media type | Meaning |
|---|---|
application/openehr.wt.flat+json | FLAT — one flat JSON object of path: value pairs |
application/openehr.wt.structured+json | STRUCTURED — the same data as nested JSON |
application/openehr.wt+json | A template rendered as Web Template JSON (template endpoints only) |
Where they work:
- Compositions — full round-trip: commit with
Content-Type: application/openehr.wt.flat+json(or…structured…) and read back with the matchingAccept. - Template examples —
GET …/definition/template/adl1.4/{id}/example(and the ADL2 form) return the generated example in any of the four formats, chosen viaAccept. - Template definitions —
GET …/definition/template/adl1.4/{id}withAccept: application/openehr.wt+jsonreturns the Web Template document.Accept: application/jsonreturns the same document — it is the only JSON representation of a template — underContent-Type: application/json: the response always carries the media type you asked for. - Contributions — the CONTRIBUTION envelope itself stays canonical JSON;
a simplified media type applies only to each composition payload inside
versions[].data.
Two rules to know when committing a composition in a simplified format:
- A FLAT/STRUCTURED payload cannot carry its own template id, so the
openehr-template-idrequest header is required — the commit is rejected with422without it. - There is no
?format=query parameter: format selection is done exclusively through the standardAcceptandContent-Typeheaders.
Requests naming a media type the endpoint does not support are answered with 415 Unsupported Media Type (request body) or 406 Not Acceptable (response format), with a body naming the formats that endpoint does support. EHR, EHR_STATUS, directory, and demographic resources have no simplified representation (the format is generated from an operational template, which those resources do not have) — they speak canonical JSON/XML only.
The query API is JSON only — it does not accept XML or the simplified media types.
The Prefer header
Write operations (create/update) accept a Prefer header controlling the
response body. Its default is return=minimal:
Prefer value | Effect |
|---|---|
return=minimal (default) | Empty body; the identifier is in ETag/Location. Status 204 on update, 201 on create. |
return=representation | The full created/updated resource in the body, status 200/201. |
return=identifier | Just the resource identifier object — {"uid": "…"} (templates: {"template_id": "…"}). Status 200/201, never 204. |
Use return=representation when you want the server-completed object back
(with its assigned version id and any server-set audit fields); use
return=minimal for throughput when you only need the id.
return=identifier always comes back with a body, so it never uses 204 —
an update that would answer 204 under return=minimal answers 200 with
the identifier object instead.
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
in the rare case where an identifier cannot be produced, 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 ORIGINAL_VERSION objects instead — one round trip instead
of one per version.
ETag and If-Match — optimistic concurrency
openEHR objects are versioned, and updates use HTTP preconditions to prevent lost updates:
-
Every read and successful write returns an
ETagheader carrying the object or version identifier as a weak ETag,W/"...". -
Updating or deleting a versioned object requires an
If-Matchheader set to the current version id. Both the weak form and a bare quoted value are accepted — echoing theETagyou received works either way:If-Match: W/"8849182c-82ad-4088-a07f-48ead4180515::your.system::2" If-Match: "8849182c-82ad-4088-a07f-48ead4180515::your.system::2" -
If the object has moved on since you read it, the write fails with 412 Precondition Failed and the current version id in the response
ETag. Re-read, reconcile, and retry against the new version.
A Location header is emitted only when a resource is created —
reads and deletes identify the version through ETag alone, so do not expect
Location on them; the ETag is the authoritative identifier.
Last-Modified — when the version was committed
Alongside the ETag, versioned responses carry a Last-Modified header
in the standard HTTP-date form (Wed, 22 Jul 2009 19:15:56 GMT). Its value is
the commit time of the version being served — the audit
time_committed of that VERSION — so it changes exactly when the ETag
does.
You get it on:
- every
VERSIONread (…/versioned_composition/{uid}/version[/{version_uid}],…/versioned_ehr_status/version[/{version_uid}]) and revision history read; - every COMPOSITION,
EHR_STATUS, and DIRECTORY read — including the FLAT and STRUCTURED representations, which describe the same version; - every write of those resources (create, update, and the delete
204), and the EHR create201; - every CONTRIBUTION — both the read and the commit
201, where the value is the contribution audit’s commit time. On a contribution commit you get the header under eitherPrefersetting; withreturn=minimalthere is no response body, so the header is the only place the commit time appears.
Resources that are not versioned do not carry it: GET /ehr/{ehr_id} returns
the weak ETag (built from EHR.ehr_id.value) but no Last-Modified,
because the EHR root object has no commit audit of its own.
Template responses (ADL 1.4 and ADL2) carry a weak ETag keyed on the
template identifier; for ADL2 it is the resolved ARCHETYPE_HRID, so
requesting a template by a partial id or a major-version prefix still gives
you an ETag that changes when the served artefact changes.
Note
Version ids normally end in a plain trunk number (
…::2), but openEHR version trees can branch: when a version that was created on another system is modified locally, the server forks a branch and the new version id ends in a three-part tree id (…::2.1.1). Treat the version id as an opaque token — echo it back inIf-Matchexactly as received — and it works the same for trunk and branch versions.ALL_VERSIONSqueries and version reads return branch versions alongside trunk ones; the latest version of an object is always the latest trunk version.
Tip
The round-trip is: read the resource → keep its
ETagvalue → send it back asIf-Matchon the update → get a newETagfor the version you just created. Never fabricate a version id; always echo the one the server gave you.
Commit metadata headers
When you commit through the direct resource endpoints (EHR creation,
composition, EHR_STATUS, directory), the server builds the version’s audit for
you. Two request headers let you set parts of it — openehr-version for the
version’s own attributes and openehr-audit-details for the commit
audit. The value is a comma-separated list of attribute.subkey="value"
pairs (quoted values may contain commas; the header may repeat, and repeats
are merged):
# Commit a composition as a draft (lifecycle state "incomplete", code 553)
openehr-version: lifecycle_state.code_string="553"
# Name the committer, describe the change, and stamp the source system
openehr-audit-details: committer.name="John Doe",description.value="Corrected dosage",system_id="pas.example.org"
The attributes the server merges:
| Header | Attribute | Sub-keys |
|---|---|---|
openehr-version | lifecycle_state | code_string |
openehr-audit-details | change_type | code_string |
openehr-audit-details | description | value |
openehr-audit-details | committer | name, external_ref.id, external_ref.namespace, external_ref.type |
openehr-audit-details | system_id | (bare value) |
A client-supplied system_id is merged into the commit audit — useful when a
gateway commits on behalf of a source system; when absent, the server stamps
its own system id.
Both EHR creates (POST /ehr and PUT /ehr/{ehr_id}) accept the headers too:
creating an EHR commits its EHR_STATUS and EHR_ACCESS in a contribution, so the
supplied description, committer, and system id land on that commit, and
openehr-version sets the new EHR_STATUS version’s lifecycle state. The
change_type on a create is constrained to 249|creation| — a create commits a
first version — so restating 249 is accepted while any other change type is
rejected.
Note
The older dotted spellings —
openEHR-VERSION.lifecycle_state: code_string="553",openEHR-AUDIT_DETAILS.committer: name="John Doe", and so on, with the attribute in the header name — are deprecated but still accepted. If both forms appear, the lowercase value-form header 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. 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.
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 of two JSON body shapes:
-
Validation errors (a composition that fails its template) use the openEHR error shape:
{ "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. -
All other errors use a simple shape — the status reason plus a message:
{ "error": "Not Found", "message": "No EHR with id ..." }This shape is used consistently — including for
405 Method Not Allowedand501 Not Implemented, which some servers leave bodyless, and for the two refusals that come from the transport layer rather than a handler:408 Request Timeout(the request exceeded the server’s execution limit) and413 Payload Too Large(the request body exceeds the accepted size).
Match on the HTTP status first; read the body for the human-readable detail and, for validation, the per-node list.
405 always names the allowed methods
Every 405 Method Not Allowed carries an Allow header listing the methods
the target resource currently supports, as
RFC 9110 §15.5.6
requires — so a client can discover the right method without guessing:
HTTP/1.1 405 Method Not Allowed
Allow: GET,HEAD,PUT
Content-Type: application/json
{ "error": "Method Not Allowed", "message": "the request method is not allowed on this resource" }
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
the supported feature envelope.
- The shape of a query
- Running a query over HTTP
- The result set
- Parameters
- Stored queries
- Version scope: LATEST_VERSION and ALL_VERSIONS
- Terminology in queries
- Pagination and limits
- What is supported
The shape of a query
An AQL statement has the familiar SELECT … FROM … WHERE … ORDER BY skeleton,
but the “tables” are RM types and the “columns” are archetype paths:
SELECT
c/name/value AS composition_name,
o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic
FROM EHR e
CONTAINS COMPOSITION c
CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140
ORDER BY systolic DESC
FROMbinds variables to RM types (EHR e,COMPOSITION c,OBSERVATION o). A type can be constrained by archetype id in square brackets (OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]). Naming a parent archetype also returns data recorded under its specialisations, as openEHR requires: for ADL 1.4 identifiers the specialisation is the hyphen-extended concept (…blood_pressurematches…blood_pressure-cuff), and for ADL 2 identifiers — where the hyphen carries no such meaning — the lineage is read from the ADL 2 archetypes and templates you have uploaded, so the parent matches every stored specialisation of it whatever its concept is named. In both cases the major version is a hard boundary:.v1never matches.v2data.CONTAINSexpresses structural containment — “an EHR that contains a composition that contains a blood-pressure observation”. Chains can nest several deep, and combine withAND,OR, andNOT.SELECTprojects values by path. Paths use archetype node ids (at0004) and RM attribute names (value/magnitude);ASnames a column.WHEREfilters on typed leaf values, with comparisons,EXISTS,LIKE,MATCHES, and boolean combinators. Comparisons over multi-valued paths use any-match semantics: when a path matches several nodes, 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).ORDER BY,LIMIT, andOFFSETbehave as you expect; quantities order by their openEHR magnitude semantics.
Running a query over HTTP
The query API lives under the base path at /query/aql. The simplest form is a
POST with a JSON body:
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/json' \
-d '{"q":"SELECT e/ehr_id/value FROM EHR e"}' \
http://localhost:8080/ferroehr/rest/openehr/v1/query/aql
The body fields are:
| Field | Meaning |
|---|---|
q | The AQL text (required). |
offset | Rows to skip (default 0). |
fetch | Maximum rows to return. |
query_parameters | An object of named parameter values (see below). |
There is also a GET /query/aql form taking q, offset, fetch, an optional
ehr_id, and query_parameters as query-string parameters — convenient for
simple, cacheable reads.
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.
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.
{
"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.
The response also carries a meta block. Its _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.
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.
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 — 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
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 \
-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
PUT /definition/query/{name}[/{version}]— store (version is a SemVer; storing an existing version returns 409). An optionalquery_typeparameter names the formalism, defaultAQL(case-insensitive); anything other than AQL is rejected with 400 — the server never silently stores a query it cannot execute.- The name is
[{namespace}::]{query-name}— the namespace is optional, and the query-name may use letters, digits,_,., and-, so plain names likemy_compositionsand dotted names likecnf.ward_dashboardare valid as-is. The query-nameaqlis reserved (case-insensitive, it would collide with the ad-hoc/query/aqlroute) and is rejected with 400. GET /definition/query/{name}[/{version}]— list or fetch.GET|POST /query/{name}[/{version}]— execute, taking the sameoffset,fetch, andquery_parametersas ad-hoc queries. A version can be given exactly (1.0.0) or as a prefix (1).
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 versions; ALL_VERSIONS reads
across history, so you can see how a record changed over time. The VERSION
variable also exposes commit metadata — the audit, the committed time, and the
version uid.
Terminology in queries
Value filters can be backed by terminology in three ways:
TERMINOLOGY('expand', …)as (or inside) amatchesoperand expands a value set so a coded field matches any code in it, rather than listing codes by hand;TERMINOLOGY('validate'|'subsumes', …) = trueas a boolean condition evaluates a code-membership or subsumption test once per query;- a terminology URI operand —
matches { terminology://… }— expands the set the URI identifies.
These require a terminology source; if external terminology is not configured, the in-process openEHR bundle is used. See Terminology servers for wiring an external FHIR terminology server.
Pagination and limits
Combine LIMIT/OFFSET in the AQL with the fetch/offset request
parameters to page through large result sets. When you ask for more than the
server will return in one response, page with offset.
Operators can also cap how long any single query may run: set
FERROEHR__QUERY__TIMEOUT_MS to a per-query execution budget in milliseconds
(unset or 0 = no per-query cap). A query that exceeds the budget returns
408 Request Timeout — narrow the query (add archetype constraints, an
ehr_id scope, or a WHERE filter) rather than retrying unchanged.
Tip
The more specific your
FROM/CONTAINS(name the archetype, scope byehr_id), the faster the query: those constraints map to indexed columns, while broad “everything that contains anything” queries do the most work.
What is supported
FerroEHR implements the core AQL 1.1 envelope and rejects out-of-envelope constructs with an explicit, typed error rather than silently returning wrong results. Supported today includes:
SELECTof paths, literals, aliases,DISTINCT, and the aggregatesCOUNT(includingCOUNT(DISTINCT)),MIN,MAX,SUM,AVG.MINandMAXorder their operand by type — a quantity by its openEHR magnitude, a date/time chronologically, text lexically — so they work over non-numeric leaves, not just numbers;FROMoverEHR,VERSION(LATEST_VERSION/ALL_VERSIONS), and RM classes with archetype and name predicates;CONTAINStrees withAND,OR, andNOT CONTAINS;WHEREcomparisons on typed leaves (with openEHR magnitude ordering for quantities),EXISTS,LIKE,MATCHESvalue lists, and range predicates;ORDER BYtyped leaves,LIMIT/OFFSET, named query parameters, and theehr_id,offset, andfetchrequest parameters;- the single-row functions:
LENGTH,SUBSTRING,POSITION, the stringCONTAINS,CONCAT/CONCAT_WS,ABS,MOD,CEIL,FLOOR,ROUND, andCURRENT_DATE/CURRENT_TIME/CURRENT_DATE_TIME/NOW/CURRENT_TIMEZONE; - terminology-backed
TERMINOLOGY()operands and terminology-URImatchesoperands (see above).
Semantic analysis is strict: duplicate FROM variable names, LIMIT 0,
negative OFFSET, wrong function arity, and SUM/AVG over non-numeric
paths are all rejected with a clear message. Variable names are
case-insensitive, as the specification requires.
Where a construct is outside the supported set, the server returns a clear error identifying it — you never get a silently incorrect answer.
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, 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 a template
FerroEHR ingests templates in the OPT 1.4 XML format. Upload one with
Content-Type: application/xml:
curl -u ferroehr:ferroehr \
-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. 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 with the id in ETag/Location.) 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.
On upload the server checks the template itself for artefact validity — that
its constraints are internally consistent per the openEHR archetype model:
reference-model conformance of every constrained type and attribute,
occurrence/cardinality consistency, terminology code definedness and
language consistency, archetype-identifier well-formedness, constraint
pattern validity (temporal and duration patterns, boolean satisfiability,
assumed values inside their own constraints), and more. An invalid template
is rejected with 400 Bad Request carrying the specific archetype-model
rule code (e.g. VCARM, VATID, Pattern_validity) and a human-readable
detail.
ADL2 artefacts (archetypes, templates, operational templates) are accepted
as text/plain source on …/definition/template/adl2 and validated by the
full ADL2 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). An invalid artefact is rejected with 422 Unprocessable
Entity whose error body lists the offending rule codes in validationErrors
(the S-codes for a source that cannot be parsed, the V-codes for a
validation-phase failure, e.g. VARD, VCORM, VACSD). A duplicate ADL2
template id returns 409 Conflict on this endpoint.
A loaded ADL2 template is retrieved in either of two representations, chosen
by Accept: the stored ADL2 source (text/plain, the default) or the
operational template as JSON (application/json). A partial
template_id resolves to the latest matching version:
# The ADL2 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
curl -u ferroehr:ferroehr -H 'Accept: application/json' \
http://localhost:8080/ferroehr/rest/openehr/v1/definition/template/adl2/openEHR-EHR-COMPOSITION.t_vitals.v1
List and retrieve loaded 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
The ADL 1.4 list accepts three filters — template_id, concept, and
version, each a glob pattern (* wildcards, e.g.
template_id=vital*) — plus offset (rows to skip, default 0) and fetch
(maximum rows; absent or 0 = all). The ADL2 list accepts the same
offset/fetch pagination.
Note
When the
versionparameter is absent, the list collapses to the latest version of each template (the highest.vNaxis of itstemplate_id). Passversion=*to list every stored version, or a partial glob (version=*.v1*) to select specific ones.
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
FerroEHR follows the widely used Better web-template semantics (format
version 2.3), so tooling built for that 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, choosing the input or
output form and the level of detail. The same example endpoint is available for
ADL2 templates at GET /definition/template/adl2/{template_id}/example: the
stored operational template is turned into a WebTemplate and walked into an
example composition, served in any of canonical JSON/XML, FLAT, or STRUCTURED
(via Accept), with the same type (input/output) and detail_level
(required/medium/complete) query parameters.
Composition formats
When committing or retrieving a composition, the canonical openEHR JSON (or XML) is always available, but two flatter formats are offered for convenience, keyed to a template:
-
FLAT (simSDT) —
application/openehr.wt.flat+json. The whole composition as a single flat map ofpath|attribute → value, which is compact and easy to produce from a form. For example:{ "vital_signs/blood_pressure/any_event:0/systolic|magnitude": 120, "vital_signs/blood_pressure/any_event:0/systolic|unit": "mm[Hg]", "vital_signs/blood_pressure/any_event:0/diastolic|magnitude": 80, "vital_signs/blood_pressure/any_event:0/diastolic|unit": "mm[Hg]", "vital_signs/language|code": "en", "vital_signs/language|terminology": "ISO_639-1" } -
STRUCTURED (structSDT) —
application/openehr.wt.structured+json. The same data as a nested JSON tree that mirrors the template structure, rather than a flat map.
Send the matching Content-Type when committing, or the matching Accept when
retrieving, and the server converts between the flat/structured form and the
canonical composition. These formats are a Better/EHRbase interoperability
convenience; the canonical JSON and XML remain the openEHR-standard wire format.
They work against both ADL 1.4 and ADL2 templates: a FLAT or STRUCTURED commit
keyed to an ADL2-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. 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"
}
The supported family: _uid, _link:n, _feeder_audit, _null_flavour,
_null_reason, _mapping:n, _normal_range, _other_reference_ranges:n,
_accuracy, _language, _encoding, _charset, _provider,
_other_participation:n, _work_flow_id, _guideline_id, _expiry_time,
_wf_definition, _instruction_details, _identifier:n, and _thumbnail.
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, 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).
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.
Next
- Using the API → Resource walkthroughs — the full composition create/update/delete lifecycle.
- Querying with AQL — reading the data back out by path.
Beyond the core
The core of FerroEHR is the openEHR platform: EHRs, compositions, contributions, templates, versioning, and AQL. Around that core the server ships a set of optional capabilities for integrating with the wider systems landscape — messaging, demographics, external terminology, change events, FHIR, and large-object storage. This chapter set describes each one from the operator’s and integrator’s point of view: what it does, how to turn it on, and how to consume it.
Important
Every capability in this section is off by default. The bare server starts with all of them disabled, and its behaviour is byte-identical to a single-tenant, integration-free openEHR CDR until you explicitly enable one. Enabling any of them is a deliberate, auditable configuration decision — and some of them carry PHI, which each chapter calls out.
The capability set
- EHR Extract & messaging — export and import whole EHRs, clone an EHR into another system while preserving its distributed version identity, and import Template Data Documents (TDDs) as compositions.
- Demographics — a versioned party store (persons, organisations, groups, agents, roles) with relationships, served over a REST surface that mirrors the EHR APIs.
- Terminology servers — the bundled openEHR terminology for local codes, plus pluggable external FHIR R4 terminology servers for validating and expanding coded values against external value sets.
- Subject Proxy — read facts about a subject (“date of birth”, “latest blood pressure”) through named variables backed by data frames — AQL against the CDR, reads from configured external FHIR servers, or manual feeds — with a per-variable sample history and currency-based freshness. A service capability (no REST endpoints yet).
- Change events (AMQP) — a transactional outbox that publishes a PHI-free, at-least-once, per-EHR-ordered event for every commit to AMQP/RabbitMQ, so downstream systems can respond to changes.
- FHIR connectors — mapping-driven inbound ingestion of FHIR R4 resources, a read façade that returns openEHR data as FHIR, and event-driven outbound emission of mapped FHIR resources.
- S3 multimedia — threshold-based, content-addressed
offload of large
DV_MULTIMEDIAblobs to any S3-compatible object store, with integrity verification and expand-on-read.
Security, multi-tenancy, and the audit trail are covered separately in Security & multi-tenancy; running the server in production — including the observability and health surfaces the 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 and import (including cross-system cloning that preserves version identity) and Template Data Document (TDD) import.
Note
These capabilities are provided through the platform’s native service API (the openEHR SM platform-service catalogue), not as HTTP endpoints. The ITS-REST 1.1.0 contract defines no extract, message, or TDD wire operations, so the server exposes none — the conformance suite records the messaging cases as skipped with a reason (native-API-only) for exactly this reason. If you need these operations over HTTP, they are an integration you build on top of the native API, not a route the server serves today.
Exporting an EHR
Export produces an openEHR EXTRACT: a self-contained package of an EHR’s versioned objects.
- Whole-EHR export takes every versioned object in an EHR at its latest version and assembles them into one extract — the simplest way to snapshot or hand off a complete record.
- Spec-driven export takes an extract specification (a manifest of which entities to include, and a version specification per entity) and produces one extract per manifest entity — for selective or policy-controlled export.
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 must keep their original identity while being recorded as having arrived from elsewhere.
- Cloning a whole EHR takes an extract and materializes it into an empty
target EHR. You can let the server allocate the EHR id or reuse the source’s
id (a true clone). 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 already exists, following the openEHR change-control copying rules.
This is the mechanism behind cross-system EHR migration: export from the source, import into the destination, and the destination’s history faithfully reflects where each version came from.
When ATNA auditing is enabled (see
Security & multi-tenancy), each 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.
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 its operational template and commits it, returning the new version’s object version id. A batch variant imports several TDDs in one call, fail-fast and all-or-nothing: if any document fails, none are committed.
TDD import commits 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.
Current limitations
Version branching is not enabled — the store is trunk-only — so importing a modified copy of a record that has diverged on two systems is out of scope for this release; straight cloning and import of un-branched history are supported. The behaviour above is verified against the platform’s native service traits and the conformance messaging cases; because there is no REST binding, there are no endpoints, headers, or status codes to document here.
Demographics
Alongside clinical records, a CDR often needs to store the people and organisations they refer to — patients, clinicians, care teams, institutions. FerroEHR provides a versioned demographic store for openEHR party types and the relationships between them, served over a REST surface that mirrors the EHR APIs.
Note
The demographic wire API — including party relationships — is defined by the openEHR ITS-REST Release-1.1.0 Demographic API (carried at DEVELOPMENT lifecycle status within the released specification). It is an Options-profile capability, not part of the Core or Standard profile.
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 fully versioned as a VERSIONED_PARTY: 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 (see
Using the API for the versioning and If-Match
conventions, which apply here too). Writes are wrapped in contributions the same
way clinical writes are.
Party endpoints
All paths are relative to the API base path (/ferroehr/rest/openehr/v1), and
{kind} is one of agent, group, organisation, person, or role.
| Method | Path | Purpose |
|---|---|---|
POST | /demographic/{kind} | create a party |
GET | /demographic/{kind}/{uid_based_id} | read a party |
PUT | /demographic/{kind}/{uid_based_id} | update a party |
DELETE | /demographic/{kind}/{uid_based_id} | delete a party |
GET | /demographic/versioned_party/{versioned_object_uid} | the versioned container |
GET | /demographic/versioned_party/{versioned_object_uid}/revision_history | revision history |
GET | /demographic/versioned_party/{versioned_object_uid}/version | version at 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 (/demographic/tags, /demographic/{kind}/{uid_based_id}/tags, and
DELETE …/tags/{key}).
Relationships
Party relationships are managed through a parallel set of routes (a FerroEHR extension), with the same versioned shape as parties:
| Method | Path | Purpose |
|---|---|---|
POST | /demographic/party_relationship | create a relationship |
GET/PUT/DELETE | /demographic/party_relationship/{uid_based_id} | read / update / delete |
GET | /demographic/versioned_party_relationship/{versioned_object_uid} | the versioned container |
GET | /demographic/versioned_party_relationship/{versioned_object_uid}/revision_history | revision history |
GET | /demographic/versioned_party_relationship/{versioned_object_uid}/version[/{version_uid}] | version at time / by id |
The demographic endpoints are always mounted (not behind a feature switch), and are subject to the same authentication and authorization as the rest of the API — see Security & multi-tenancy.
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 validated against an external code system such as SNOMED CT or LOINC. FerroEHR serves the bundled openEHR terminology in-process and can additionally validate and expand coded values against any external FHIR R4 terminology server.
The bundled openEHR terminology
The server ships the openEHR terminology bundle (Terminology 3.1.0) and uses it by default, with no external dependency. It answers the questions the platform needs during validation 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.
You can also expose these lookups over a small read-only REST surface. It is an
extension (not part of the openEHR ITS-REST contract) and is off by default;
when disabled, every route returns 404 as if unmounted. Enable it with
FERROEHR__TERMINOLOGY__API_ENABLED=true, and it serves:
| Method | Path | Purpose |
|---|---|---|
GET | /terminology | list terminologies |
GET | /terminology/{terminology_id} | describe one terminology |
GET | /terminology/{terminology_id}/term/{code} | look up a term |
GET | /terminology/{terminology_id}/subsumes?ref_code=&candidate= | subsumption test |
GET | /terminology/{terminology_id}/value_set/{value_set_id} | get a value set |
GET | /terminology/{terminology_id}/value_set/{value_set_id}/validate?candidate_code=&at_date= | validate a code |
(All paths are relative to the API base path, /ferroehr/rest/openehr/v1.)
External FHIR terminology servers
A template can bind a coded element to an external value set via a
terminology://… reference — for example a FHIR value-set expand or
validate-code operation against a named value set. When external terminology is
enabled, the composition validator routes each such coded element to the
configured FHIR R4 terminology server: it resolves the coded value’s system and
code and asks the server whether the code is a member of the value set. If it is
not, the composition is rejected along with any other validation errors.
The server prefers a direct code-validation check and falls back to expanding the value set and testing membership where a server lacks direct validation. Only the external bindings go to the FHIR server — openEHR and local terminologies are still served by the in-process bundle.
Note
The CDR is only ever a client of the terminology server. FerroEHR does not implement a terminology server; you run an off-the-shelf FHIR R4 server and point the CDR at it by URL. HAPI FHIR is a good open, single-container default for development and CI; Snowstorm is the opt-in choice for genuine SNOMED CT subsumption (heavier — it needs Elasticsearch and a SNOMED CT licence).
Enabling and configuring it
External terminology is off by default; validation then uses only the
in-process bundle. These keys live in the [terminology.external] section of
ferroehr.toml (providers are a map, so complex blocks are usually supplied in
that TOML file); each can be overridden with the shown
FERROEHR__TERMINOLOGY__EXTERNAL__* environment variable, with __ separating
nested keys:
| Key | Meaning |
|---|---|
FERROEHR__TERMINOLOGY__EXTERNAL__ENABLED | master switch (default false) |
FERROEHR__TERMINOLOGY__EXTERNAL__FAIL_ON_ERROR | on a server error: true rejects (fail-closed), false accepts (fail-open) |
FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__<NAME>__TYPE | provider type — fhir (R4) |
FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__<NAME>__URL | the FHIR base URL, e.g. http://terminology:8090/fhir |
A provider can carry per-provider OAuth2 client-credentials and mutual-TLS settings for servers that require them. A short worked example, pointing the CDR at a HAPI FHIR container over Docker Compose:
services:
ferroehr:
environment:
FERROEHR__TERMINOLOGY__EXTERNAL__ENABLED: "true"
FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__DEFAULT__TYPE: "fhir"
FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__DEFAULT__URL: "http://terminology:8090/fhir"
Tip
A FHIR terminology server starts empty. Seed the value sets your templates reference by uploading their CodeSystem and ValueSet resources over plain FHIR REST (
PUTto/fhir/CodeSystem/<id>and/fhir/ValueSet/<id>); value sets are expanded on upload, so validation answers from the pre-computed expansion.
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 a routing
map picks the one that 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 namespace -> provider name. Keys are matched whole-string and
# case-insensitively: a terminology id as an archetype binding writes it, a
# code-system URI, or a value-set URL.
[terminology.external.routes]
"SNOMED-CT" = "snomed"
"http://snomed.info/sct" = "snomed"
"http://loinc.org" = "loinc"
Selection is deliberately mechanical, so you can predict which server answers:
the caller offers candidate keys in priority order (the value set or coded
system first, then the AQL service_api flavour); the first key with a route
entry wins; otherwise the provider named default answers — or, when exactly
one provider is configured, that one. With two or more providers and no
default, an unrouted terminology has no server at all, which is a useful way
to make routing mistakes loud instead of silent. A route naming a provider
that does not exist fails at startup, never at request time.
The terminology compose profile (development and CI)
The repository’s docker-compose.yml can start a real FHIR R4 terminology
server (HAPI FHIR JPA) beside the CDR, seeded with a small set of synthetic
test code systems and value sets:
docker compose --profile terminology \
-f docker-compose.yml -f docker/sut-terminology.yml up
The profile starts the 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. The docker/sut-terminology.yml overlay is what
points the CDR at it, by switching on the [terminology.external] providers
that docker/ferroehr.dev.toml already carries in the disabled state. Without
that overlay the CDR ignores the terminology server entirely, so the plain
quickstart is unchanged.
The seeded content is synthetic and lives under the reserved example.test
domain: one hierarchical, SNOMED-CT-shaped code system and one LOINC-shaped
one, 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 stock HAPI image keeps its database in memory, so restarting the terminology container drops the seed; re-run the profile (or just the seeding container) after one.
When the terminology server cannot answer
fail_on_error decides what happens when a bound value set cannot be resolved
at all — the server is unreachable, returns an error, or does not know the
value set:
false(the default, fail-open): the composition is accepted and a warning is logged. 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 of the bound value set is a different matter: that is a real constraint violation and the composition is rejected under either setting.
Terminology in AQL
Query authors can use the AQL TERMINOLOGY() function to constrain a match to a
value set — TERMINOLOGY('expand', …) resolves a value set and merges its
codes into a matches list at query-analysis time. See
Querying with AQL for the query surface. Where an external
terminology operation is not yet supported, the engine returns a typed rejection
rather than a silent wrong answer.
Subject Proxy
The Subject Proxy Service lets applications 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 the CDR itself, a FHIR read against a remote server, or a manual feed); the service executes the frames, tracks a sample history per variable, and serves fresh values from that history without re-querying the source.
Note
In this release the Subject Proxy is a service capability, not a REST API: there are no HTTP endpoints for it. It runs inside the server — exercised by in-process integrations — and what you configure is the set of external FHIR systems its frames may reach (below). The openEHR specification defines the service model; 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). For openEHR-backed variables the subject id is resolved to an EHR — a literal EHR id first, then a subject-id lookup. - Variable — a named, typed fact about a subject: a
name(optionally qualified by anamespace, giving a canonicalnamespace::nameidentity), a type, an optionalcurrency(how fresh a value must be), and either a binding to a data frame (frame_id+frame_path) or theis_manualflag. - Data set — an application’s working set of variables for a subject,
under local aliases (your app can call the canonical
date_of_birthvariabledob). Data sets track which applications use them (using_app_ids); when the last using application deregisters, the empty data set is dropped automatically. - Binding — an environment’s catalogue of data frames. Each frame
names a retrieval method — an
API_CALL(for example a FHIR read) or aQUERY_CALL(an AQL query) against a named system — plus an optional fallback method.
Defining frames
A binding is a plain document (YAML or JSON — the two are interchangeable).
Frames reference systems by system_id, and $subject_id in 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 → fallback: the primary method runs first; if it yields data, that is the sample. If the primary is unavailable (source down, non-2xx, timeout, malformed response) and a fallback is defined, the fallback runs and its outcome — available or not — wins. Every attempt produces a sample either way, so “the source was unreachable at 14:02” is itself recorded history.
Sample history and currency
Every retrieval attempt for a variable 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), the value — or an
unavailability marker with the reason. The service keeps the most recent 100
samples per variable, newest first, so a variable read returns not just a
value but its recent history and provenance.
A variable’s currency is an ISO 8601 duration expressing how fresh a
served value must be. On a read, if the newest stored sample’s effective time
is within the currency window, it is served without re-querying the
source; otherwise the frame executes again. A variable with no currency means
“the most recent available value is valid” — any stored sample serves.
When an application registers a data set whose variables request a tighter
(shorter) 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 HL7 FHIR R4 servers.
Which servers are reachable is opt-in and fail-closed configuration: only
systems named here can ever be called, and a frame naming an unconfigured
system_id is rejected with a typed error — never an arbitrary outbound
request. By default no system is configured and every FHIR frame is
rejected.
These keys live in the [subject_proxy] section of ferroehr.toml; each can
be overridden with the shown FERROEHR__SUBJECT_PROXY__* environment variable,
nested keys separated by __. (This env form now binds — before the
configuration redesign it was documented but inert.) Systems are a map keyed
by the name frames use as system_id (shown as <NAME>):
| Key | Type | Default | Meaning |
|---|---|---|---|
FERROEHR__SUBJECT_PROXY__SYSTEMS__<NAME>__BASE_URL | URL | none (required per system) | FHIR R4 base URL; the frame’s query_text (after $subject_id substitution) is resolved relative to it. |
FERROEHR__SUBJECT_PROXY__SYSTEMS__<NAME>__CONNECT_TIMEOUT_MS | integer (ms) | 2000 | Per-system TCP connect timeout. |
FERROEHR__SUBJECT_PROXY__SYSTEMS__<NAME>__REQUEST_TIMEOUT_MS | integer (ms) | 10000 | Per-system overall request timeout. |
For example, 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
Requests are sent with Accept: application/fhir+json; a timeout, error
status, or malformed body becomes an unavailable sample, which is what
triggers the frame’s fallback.
Manual variables
A variable with is_manual: true has no frame: its values are pushed in
by a notifier — typically a worker or device observing the subject — through
the service’s sample-notification call. Reads serve the stored history;
until a first sample is pushed, reads return 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.
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 designed so you can fan them out broadly without leaking clinical data: they carry only identifiers and metadata, never the record content.
Delivery guarantees
The publisher is built on a transactional outbox, which gives it three properties that matter for integration:
- At-least-once delivery. Every commit writes its event to an outbox table in the same database transaction as the change itself — no commit without its event, no event without its commit. A background task drains the outbox to the broker and marks a row published only after the broker confirms it. A crash or retry may deliver a message more than once, so consumers deduplicate.
- Per-EHR ordering. Rows drain in global sequence order, and the drainer stops a batch on the first publish failure rather than skipping ahead — so an earlier event for an EHR is never overtaken by a later one.
- PHI-free envelopes. The message body carries only ids, version numbers, and metadata. To read the actual clinical content, a consumer calls back through the authenticated REST or native API.
flowchart LR
commit["commit<br/>(composition / status / folder)"]
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
The event envelope
Each published message is JSON (application/json). One contribution can touch
several versioned objects, and the publisher emits one message per version,
each under its own routing key. The envelope carries:
| Field | Meaning |
|---|---|
contribution_id | the contribution this change belongs to |
ehr_id | the EHR (may be null for a demographic contribution) |
committed_at | the commit instant |
versions[] | one entry per changed versioned object |
seq | the delivery sequence number (monotonic) |
version_index | which entry in versions this message is for |
Each versions[] entry has vo_id, kind (the RM type — COMPOSITION,
EHR_STATUS, FOLDER, EHR_ACCESS), sys_version, change_type (a numeric
audit change-type code — 249 creation, 251 modification, 523 deleted,
666 attestation), and template_id (or null).
Tip
Deduplicate on the pair
(contribution_id, version_index)and process inseqorder. That handles the at-least-once redelivery and preserves per-EHR ordering at the consumer.
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. When there
is no template, the last field is -; characters outside [A-Za-z0-9_-] are
collapsed to _ so the key always has exactly three fields. Bind a queue with
the usual AMQP topic wildcards to select what you care about — for example
COMPOSITION.*.* for all composition changes, *.523.* for all deletions
(change type 523), or # for everything.
The server can also manage subscriptions for you. When the event-subscription
admin API is enabled (FERROEHR__EVENTS__ADMIN_API), each enabled
subscription row causes the server to declare and bind a durable queue named
<exchange>.<name> (for the default exchange, ferroehr.events.<name>) with a
binding key built from the subscription’s kind / change_type / template_id
predicates (a wildcard for any predicate left unset).
Enabling it
Publishing is off by default. These keys live in the [events] section of
ferroehr.toml; each can be overridden with the shown FERROEHR__EVENTS__*
environment variable, with __ separating nested keys:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__EVENTS__ENABLED | false | master switch |
FERROEHR__EVENTS__URL | amqp://guest:guest@localhost:5672/%2f | broker connection URL |
FERROEHR__EVENTS__EXCHANGE | ferroehr.events | topic exchange name (also the queue-name prefix) |
FERROEHR__EVENTS__TLS | false | when true, upgrades an amqp:// URL to amqps:// |
FERROEHR__EVENTS__BATCH_SIZE | 128 | rows drained per cycle |
FERROEHR__EVENTS__POLL_INTERVAL_MS | 1000 | poll interval while the outbox is idle |
FERROEHR__EVENTS__PUBLISH_MAX_RETRIES | 3 | retries per message before the batch stops |
FERROEHR__EVENTS__RETENTION_DAYS | 7 | how long published rows are kept |
FERROEHR__EVENTS__PRUNE_INTERVAL_SECS | 3600 | how often published rows are pruned |
Warning
The broker URL carries credentials, so keep it in a secret, not a plain environment file. For anything beyond a local broker, use a TLS connection (
FERROEHR__EVENTS__TLS=trueor anamqps://URL). The commit path never blocks on the broker — if it is down, events buffer in the outbox and drain when it recovers.
Consuming events
A minimal consumer declares nothing new — it 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.*'
Each delivery is a JSON envelope as described above. Your consumer records the
(contribution_id, version_index) it has seen, and for anything it needs the
content of, it calls the CDR’s REST API (for example
GET /ehr/{ehr_id}/composition/{vo_id}) with its own credentials — the event
told 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.
The connectors come in two independent switches — an inbound/read-façade
switch and an outbound-emission switch — because they have very different
data-exposure characteristics. All FHIR routes are relative to the API base path
(/ferroehr/rest/openehr/v1), use FHIR R4, and speak application/fhir+json. A
resource type the connector does not map yet is answered with a FHIR
OperationOutcome, never a silent success.
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, if any), 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. If the mapped composition fails validation, the request is
rejected with 422 and nothing is stored; a successful ingest returns 201
with ETag and Location headers pointing at the openEHR composition. The
starter set of supported resource types is Patient, Observation,
Condition, and DocumentReference.
Read façade
GET /fhir/r4/{resource_type}?patient=<subject> returns openEHR data
reverse-mapped into a FHIR searchset Bundle. The patient parameter is
mandatory (a missing one is a 400) — this is a targeted façade, not a
general FHIR search. An optional _count caps the number of entries. Each
Bundle entry is a FHIR resource produced from a stored composition by running
the mapping in reverse.
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 FHIR
resource to a topic exchange (default ferroehr.fhir) with a routing key of
<resource_type>.<template_id>. Delivery is at-least-once.
Warning
Outbound FHIR messages carry PHI — the payload is the mapped clinical FHIR resource itself, unlike the PHI-free change-event envelopes. That is exactly why they are a separate switch on a separate exchange (
ferroehr.fhir, notferroehr.events): broker access control can then isolate the PHI-bearing stream. Enable it only against a TLS, access-controlled broker, and treat every consumer as a PHI processor.
Mappings are data you manage
There are no bundled mapping files. Each mapping is a stored definition managed through an admin API (classed under admin authorization):
| Method | Path | Purpose |
|---|---|---|
GET | /admin/fhir_mapping | list mappings |
POST | /admin/fhir_mapping | create a mapping (201) |
GET | /admin/fhir_mapping/{mapping_id} | get a mapping |
PUT | /admin/fhir_mapping/{mapping_id} | update a mapping |
DELETE | /admin/fhir_mapping/{mapping_id} | delete a mapping (204) |
A mapping definition binds one FHIR resource type (optionally scoped to a
meta.profile URL) to one openEHR template, and lists field bindings —
each mapping an openEHR FLAT path to a FHIR path (or a constant), shaped by a
transform (plain text, date, quantity with unit, or a coded value with a
code-system-to-terminology mapping). The FHIR-path support is a deliberate
subset covering field navigation and array indexing (for example
component[1].valueQuantity.value), and the mapping is symmetric: the same
definition drives inbound ingest, the read façade, and outbound emission.
Note
The template a mapping references must already be ingested (see Templates & validation) — creating a mapping against an unknown template is a
400. Mapping names are immutable once set, and a duplicate name is a409.
Enabling the connectors
Both switches are off by default. The inbound/read-façade switch lives in the
[fhir] section of ferroehr.toml and the outbound emitter in
[fhir.outbound]; each key can be overridden with the shown FERROEHR_*
environment variable:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__FHIR__API_ENABLED | false | enable inbound ingest, the read façade, and the mapping API |
FERROEHR__FHIR__OUTBOUND__ENABLED | false | enable outbound emission to AMQP |
FERROEHR__FHIR__OUTBOUND__URL | amqp://guest:guest@localhost:5672/%2f | outbound broker URL |
FERROEHR__FHIR__OUTBOUND__EXCHANGE | ferroehr.fhir | outbound topic exchange (kept distinct from the event stream) |
FERROEHR__FHIR__OUTBOUND__TLS | false | upgrade an amqp:// URL to amqps:// |
FERROEHR__FHIR__OUTBOUND__BATCH_SIZE | 128 | commits drained per cycle |
FERROEHR__FHIR__OUTBOUND__POLL_INTERVAL_MS | 1000 | poll interval while idle |
FERROEHR__FHIR__OUTBOUND__PUBLISH_MAX_RETRIES | 3 | retries per message |
When the inbound switch is off, the /fhir/r4/* and /admin/fhir_mapping
routes answer 404 without touching the backend. When the outbound switch is
off, 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 transparently offload
large multimedia blobs to any S3-compatible object store, keeping only 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
(including a node’s nested thumbnail, which is itself a multimedia value):
- A node qualifies only when it is purely inline (it has
dataand nouri) and its decoded byte length is strictly greater than the configured threshold. A value at or below the threshold stays inline; a value that already references external media (has auri) is stored verbatim, never touched. - The raw decoded bytes are written to the object store under a key that is the SHA-256 hash of those bytes (lowercase hex). Because the key is the content hash, identical blobs deduplicate automatically, and the upload is a no-op if the key already exists.
- The node is rewritten in place: its inline
datais removed and replaced with auriof the forms3://<bucket>/<hash>, plus anintegrity_check(the SHA-256 digest), anintegrity_check_algorithmcode phrase (SHA-256), and the originalsize.
Uploads happen before anything is persisted, so a failed upload aborts the commit — a record is never half-stored.
Note
What lives where after offload: the object store holds the blob bytes; the composition in PostgreSQL holds a compact, spec-legal
DV_MULTIMEDIAthat points at the blob by content hash. Everything remains canonical openEHR JSON — thes3://reference and integrity fields are standard RM attributes.
Reading blobs back
By default a read returns the stored (offloaded) form — the compact reference.
To get the inline bytes back, request expansion on the read
(?expand_multimedia=true). The server fetches each of its own externalized
blobs (only URIs of the exact form s3://<configured-bucket>/<hash> are
treated as its own; foreign https:// or other-bucket references are left
alone), verifies the SHA-256 hash 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 silently served.
Enabling it
Offload is off by default. These keys live in the [multimedia] section of
ferroehr.toml; each can be overridden with the shown FERROEHR__MULTIMEDIA__*
environment variable:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__MULTIMEDIA__ENABLED | false | master switch |
FERROEHR__MULTIMEDIA__THRESHOLD_BYTES | 262144 (256 KiB) | offload blobs larger than this; smaller stay inline |
FERROEHR__MULTIMEDIA__ENDPOINT | unset | S3 endpoint URL (unset uses AWS default resolution) |
FERROEHR__MULTIMEDIA__BUCKET | openehr-multimedia | target bucket |
FERROEHR__MULTIMEDIA__REGION | us-east-1 | S3 region |
FERROEHR__MULTIMEDIA__ACCESS_KEY_ID | unset | access key (see note on credentials) |
FERROEHR__MULTIMEDIA__SECRET_ACCESS_KEY | unset | secret key |
FERROEHR__MULTIMEDIA__ALLOW_HTTP | false | permit plain-HTTP endpoints (development only) |
If both the access key and secret are unset, the client runs unsigned (anonymous) — the mode a local development SeaweedFS accepts with no credentials. Set both to use signed requests against a real store.
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.
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. 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
With the feature enabled and the bucket reachable, large DV_MULTIMEDIA values
committed through the normal 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.
Security & multi-tenancy
A clinical data repository holds PHI, so its access controls and audit trail are part of the product, not an afterthought. This chapter covers the four security surfaces you configure when you deploy FerroEHR: authentication (who is calling), authorization (what they may do), multi-tenancy (isolating independent logical systems), 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.
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), [tenancy]
(multi-tenancy), [authz] (authorization), and [audit]
(the ATNA audit trail) — and any key can be overridden with the matching
FERROEHR_* environment variable shown below.
Authentication
Authentication is on by default (FERROEHR__AUTH__ENABLED=true). Setting
it to false lets all requests through unauthenticated — a development-only
mode.
There is no single “mode” switch. The server offers two mechanisms and enables each by the presence of its configuration block:
- HTTP Basic is active when a
basicblock with a user list is configured. Each user has a username, an Argon2 password hash (a PHC string beginning$argon2id$), and a set of roles (default["USER"]). Because it is a list of users, the Basic block is normally supplied through the TOML configuration file rather than environment variables. - OAuth2/OIDC bearer tokens are active when an
oidcblock is configured. The server validates the token’s signature, issuer, and (optionally) audience.
Successfully verified Basic credentials are cached for
FERROEHR__AUTH__VERIFIED_CACHE_TTL_SECONDS (default 60; 0 disables
the cache) so a busy client pays the deliberately-expensive Argon2
verification once per TTL instead of on every request. The cache stores only
a SHA-256 digest of the presented credential — never a plaintext password —
and an entry exists only after a successful verification; the TTL bounds how
long a revoked credential can still authenticate, exactly like a session
lifetime.
The OIDC settings:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__AUTH__OIDC__ISSUER | — (required to enable OIDC) | expected iss, and the OIDC discovery base |
FERROEHR__AUTH__OIDC__AUDIENCES | empty (not checked) | accepted aud values |
FERROEHR__AUTH__OIDC__ALGORITHMS | ["RS256"] | accepted signing algorithms |
FERROEHR__AUTH__OIDC__HMAC_SECRET | unset | an HS256 symmetric secret (development/testing) |
FERROEHR__AUTH__OIDC__JWKS_JSON | unset | a static JWKS document |
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 (preferred when present) or an HMAC_SECRET.
Tip
Keycloak example. Point the issuer at your realm and let discovery do the rest:
export FERROEHR__AUTH__OIDC__ISSUER=https://keycloak.example/realms/ferroehr export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-apiThe same pattern works for Active Directory or any standards-compliant identity provider — walkthroughs for Entra ID and AD FS (and the answer for plain-LDAP directories) are in Enterprise identity providers. Prefer JWKS/discovery over a shared HS256 secret in production. User accounts, roles, and lifecycle are administered in the IdP — the CDR has no user API.
An unauthenticated request to a protected route is refused with 401; an
authenticated request that lacks the required role is refused with 403.
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. By default it has no settings and
the EHR is open to any authenticated caller (all existing workflows keep
working). Committing settings with the ferroehr.access_control.v1 scheme
switches that EHR to explicit policy:
{
"_type": "EHR_ACCESS",
"name": { "_type": "DV_TEXT", "value": "access" },
"archetype_node_id": "openEHR-EHR-EHR_ACCESS.generic.v1",
"settings": {
"_type": "FERROEHR_ACCESS_CONTROL_V1",
"gate_keeper": "user:alice",
"default_access": "restricted",
"access_list": [
{ "principal": "user:bob", "access": "full" },
{ "principal": "role:nurse", "access": "restricted_below", "max_level": 2 }
],
"privacy": {
"default_level": 0,
"composition_overrides": [
{ "uid": "8849182c-82ad-4088-a07f-48ead4180515", "level": 3 }
]
}
}
}
- Access list — with
default_access: "restricted", only listed principals may touch the EHR:user:<login or OIDC subject>orrole:<role>(matched against the caller’s roles). Everyone else gets403. - Privacy levels — integer sensitivity levels with meanings you define
for your jurisdiction. A composition’s level is its override entry or the
default; a caller with
restricted_belowaccess may only read compositions strictly below theirmax_level, whilefullaccess has no ceiling. - Gate-keeper — once set, only that principal may commit a new
EHR_ACCESSversion (via a CONTRIBUTION; there is no dedicatedEHR_ACCESSendpoint in the openEHR REST API). Changes are versioned and audited like all record content.
The scheme is a FerroEHR extension: openEHR mandates the EHR_ACCESS
object and its change control but publishes no concrete access-control
scheme. Query (AQL) results are not filtered by privacy level in this
release; the per-EHR gate still applies to EHR-scoped query routes.
RBAC (role-based, coarse)
Every operation is classified as Public, Clinical, Management, or Admin, and a
role model gates each class. Roles are plain, case-insensitive strings; the
defaults are USER (the baseline clinical role) and ADMIN.
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__AUTHZ__RBAC__ENABLED | true | the coarse role gate (active only when auth is enabled) |
FERROEHR__AUTHZ__RBAC__ADMIN_ROLE | ADMIN | role required for admin operations |
FERROEHR__AUTHZ__RBAC__USER_ROLE | USER | the baseline clinical role |
FERROEHR__AUTHZ__RBAC__READONLY_ROLE | READONLY | role marking a principal read-only: refused on every write |
FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS | ["realm_access.roles","scope"] | JWT claim paths mined for roles |
FERROEHR__AUTHZ__RBAC__MANAGEMENT_ACCESS | admin_only | management-surface access: admin_only, private, or public |
Roles come from the JWT claims listed in ROLE_CLAIMS — by default the
Keycloak realm_access.roles array plus the space-separated scope claim —
or from a Basic user’s configured roles. A clinical operation needs at least one
role; an admin operation needs the admin role; the management surface follows
its tri-state setting. Disabling RBAC restores authentication-only behaviour.
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
dev compose stack ships one such account (ferroehr-readonly, password
ferroehr) for evaluation.
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.
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__AUTHZ__ABAC__ENABLED | false | master ABAC switch |
FERROEHR__AUTHZ__ABAC__ENGINE | cedar | cedar (embedded) or remote (external PDP) |
FERROEHR__AUTHZ__ABAC__ORGANIZATION_CLAIM | organization_id | JWT claim for the organisation attribute |
FERROEHR__AUTHZ__ABAC__PATIENT_CLAIM | patient_id | JWT claim for the patient attribute (enables the subject gate) |
FERROEHR__AUTHZ__ABAC__CEDAR__POLICY_DIR | — (required for cedar) | directory of .cedar policy files |
FERROEHR__AUTHZ__ABAC__CEDAR__RELOAD_SECS | off | optional policy hot-reload interval |
FERROEHR__AUTHZ__ABAC__REMOTE__SERVER | — (required for remote) | PDP base URL (must end with /) |
FERROEHR__AUTHZ__ABAC__REMOTE__CONNECT_TIMEOUT_MS | 2000 | PDP connect timeout |
FERROEHR__AUTHZ__ABAC__REMOTE__REQUEST_TIMEOUT_MS | 5000 | PDP request timeout |
Two engines sit behind one interface. Cedar is the embedded default:
policies live in .cedar files, are schema-validated at boot (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.
Warning
Authorization is fail-closed: if the policy engine is unreachable or a policy cannot be evaluated, the request is refused (mapped to
500), never permitted. When a patient claim is configured, a local subject gate also rejects access to another patient’s EHR before any policy call. A denied decision is a403.
Multi-tenancy
Multi-tenancy lets one deployment host several isolated logical openEHR
systems, each with its own system_id. It is off by default; when off, the
server behaves byte-for-byte as a single-tenant system.
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__TENANCY__ENABLED | false | enable multi-tenancy |
FERROEHR__TENANCY__CLAIM | tenant | the JWT claim (a dotted path) carrying the tenant key |
FERROEHR__TENANCY__HEADER | unset | a development header override for the tenant |
A request’s tenant is resolved from the configured JWT claim (a dotted path
such as realm_access.tenant is walked through nested objects). Isolation is
enforced in the database with PostgreSQL row-level security: the resolved
tenant scopes the connection so a query can only ever see its own tenant’s
rows.
Warning
Leave
FERROEHR__TENANCY__HEADERunset in production — a client-supplied header must never be able to select a tenant; the tenant must come from the authenticated token. Isolation is also fail-safe by design: an absent or unresolvable tenant runs unscoped against a reserved default rather than guessing, and a cross-tenant access surfaces as an empty result set, never a403that would leak the existence of another tenant’s data.
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.
The full chapter — record content, sinks, the ITI-81 search, fail-mode
semantics, and mTLS — is Audit trail (IHE ATNA); every
[audit] key is in the
configuration reference.
Note
The ATNA trail is orthogonal to openEHR’s own
CONTRIBUTIONandAUDIT_DETAILS, which the server always writes in the same transaction as every change. openEHR audit records what a version says about its own authorship; ATNA records security surveillance of API access. Both coexist. Identified data never enters telemetry (metrics, traces, logs) — see Operations — so the audit trail is the single place where access to identified data is recorded.
Enterprise identity providers
FerroEHR does not manage users. There is no user table, no user API, and no plan to add one: identity administration is delegated to your identity provider (IdP), and the CDR consumes standard OIDC bearer tokens. This page records that posture and walks through connecting the two enterprise IdPs we are asked about most — Microsoft Entra ID (Azure AD) and AD FS — plus the answer for plain-LDAP directories.
- The posture: users live in the IdP
- How the CDR consumes an IdP
- Microsoft Entra ID (Azure AD)
- AD FS (on-premises Active Directory)
- “We only have LDAP”
- Multi-tenant deployments
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 admin console follows the same rule: it signs in against the same IdP and has no user-management screens. To create, disable, or re-role a user, use your IdP’s own administration surface.
How the CDR consumes an IdP
Two configuration groups do all the work:
- Token validation (
[auth.oidc]): the server discovers the JWKS from the issuer’s.well-known/openid-configurationand validates each bearer token’s signature,iss, and (when configured)aud— see the OIDC settings table. - Role mining (
[authz.rbac]):FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS(default["realm_access.roles","scope"]— the Keycloak shape) names the JWT claim paths whose values become the caller’s roles for the role layer.
Everything below is just those two groups pointed at a different issuer.
Microsoft Entra ID (Azure AD)
Entra ID exposes a standards-compliant OIDC issuer per tenant.
-
Register an application (Entra admin center → App registrations). Note the Directory (tenant) ID and the Application (client) ID.
-
Define app roles (App registration → App roles): create roles named after the CDR roles you use (for example
USER,CLINICAL,ADMIN) and assign users/groups to them (Enterprise applications → your app → Users and groups). Entra puts assigned app roles in the token’srolesclaim. -
Expose an audience: either use the client ID as the audience or add an Application ID URI (for example
api://ferroehr). -
Point the CDR at the tenant issuer and mine the
rolesclaim:export FERROEHR__AUTH__OIDC__ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0 export FERROEHR__AUTH__OIDC__AUDIENCES=api://ferroehr export FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS='["roles"]' -
Verify: request a token for the app (any OAuth2 client credential or auth-code flow) and call the API. A valid token without a required role must get
403; no token,401.
Tip
Group-based deployments can emit the
groupsclaim instead and list it inROLE_CLAIMS— but group claims arrive as object IDs unless you configure group names, so app roles usually read better in policy.
AD FS (on-premises Active Directory)
AD FS 2016+ speaks OIDC. This is also the supported path for on-premises Active Directory in general: front AD with AD FS (or another OIDC-capable broker) rather than pointing anything at LDAP.
-
Create an Application Group (AD FS Management → Application Groups → Web API template, or Server application + Web API for interactive clients). The Web API’s identifier becomes the token audience.
-
Issue role claims: on the Web API’s Issuance Transform Rules, add a rule mapping AD group membership to the
roleclaim (template: Send Group Membership as a Claim), one rule per CDR role. -
Point the CDR at the AD FS issuer and mine the
roleclaim:export FERROEHR__AUTH__OIDC__ISSUER=https://adfs.example.com/adfs export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-api export FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS='["role"]'Discovery works out of the box (
https://adfs.example.com/adfs/.well-known/openid-configuration). -
Verify exactly as above:
401without a token,403with a token that lacks the required role.
Note
AD FS emits a single string for one role and an array for several; the role-mining layer accepts both shapes on any configured claim path.
“We only have LDAP”
The CDR does not speak LDAP, by design — LDAP bind would put password handling back inside the CDR. Front the directory with an OIDC-capable broker and connect that instead:
- Active Directory → AD FS (above) or Entra ID (if synced).
- Generic LDAP → Keycloak with LDAP user federation (the Keycloak example then applies verbatim), or any other OIDC provider that can federate LDAP.
The broker owns the LDAP bind; the CDR sees only signed tokens.
Multi-tenant deployments
Tenancy is also credential-derived: the tenant is read from a JWT claim per request (see multi-tenancy), so a multi-tenant IdP setup simply issues the tenant claim alongside the roles. No client — including the admin console — chooses a tenant; the credential does.
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
- The discovery document
- The scope grammar
- Launch context: binding to one EHR
- How scopes compose with RBAC and ABAC
Enabling it
Turn it on and tell the server where your authorization server lives:
export FERROEHR__SMART__ENABLED=true
export FERROEHR__SMART__ENDPOINTS__AUTHORIZATION_ENDPOINT=https://as.example/auth
export FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT=https://as.example/token
SMART scopes ride only Bearer tokens, so pair this with OIDC bearer authentication (see Security & multi-tenancy). If SMART is enabled without any bearer mechanism, the server logs a warning at boot: it can serve discovery, but the scope gate will never see a scope.
The full key set lives in the [smart] section of ferroehr.toml; each key
can be overridden with the shown FERROEHR__SMART__* environment variable (__
separates nested fields):
| Key | Default | Meaning |
|---|---|---|
FERROEHR__SMART__ENABLED | false | Master switch. Off = no discovery document (404) and an inert scope gate. |
FERROEHR__SMART__PLATFORM_BASE_URL | unset | Base the discovery document hangs off. Unset = the REST root (/ferroehr/rest). A leading path is honoured (/gateway/v1 → /gateway/v1/.well-known/smart-configuration). |
FERROEHR__SMART__EHR_ID_CLAIM | ehrId | Token claim carrying the launch context’s openEHR EHR id. |
FERROEHR__SMART__PATIENT_CLAIM | patient | Fallback launch-context claim when the EHR-id claim is absent. |
FERROEHR__SMART__REQUIRE_SMART_SCOPES | false | Fail-closed switch — see Advisory vs required below. |
FERROEHR__SMART__EPISODE__ENABLED | false | Advertise + accept episode launch context (experimental; advisory only, no episode filtering). |
FERROEHR__SMART__LAUNCH_BASE64_JSON | false | Advertise the base64-JSON launch-parameter capability (experimental; consumed by the app, not the CDR). |
FERROEHR__SMART__ENDPOINTS__ISSUER | unset | Advertised token issuer. Unset = falls back to the configured OIDC bearer issuer. |
FERROEHR__SMART__ENDPOINTS__JWKS_URI | unset | Advertised jwks_uri. |
FERROEHR__SMART__ENDPOINTS__AUTHORIZATION_ENDPOINT | unset | Advertised OAuth2 authorization endpoint. |
FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT | unset | Advertised OAuth2 token endpoint. |
FERROEHR__SMART__ENDPOINTS__REGISTRATION_ENDPOINT | unset | Advertised dynamic-client registration endpoint. |
FERROEHR__SMART__ENDPOINTS__INTROSPECTION_ENDPOINT | unset | Advertised token introspection endpoint. |
FERROEHR__SMART__ENDPOINTS__REVOCATION_ENDPOINT | unset | Advertised token revocation endpoint. |
FERROEHR__SMART__ENDPOINTS__MANAGEMENT_ENDPOINT | unset | Advertised user management endpoint. |
FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED | [] | 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 deprecated in SMART and rejected at boot. |
FERROEHR__SMART__ENDPOINTS__RESPONSE_TYPES_SUPPORTED | [] | Advertised response types (e.g. code). |
FERROEHR__SMART__ENDPOINTS__CODE_CHALLENGE_METHODS_SUPPORTED | [] | Advertised PKCE methods (e.g. S256). |
FERROEHR__SMART__ENDPOINTS__SCOPES_SUPPORTED | [] | Advertised scopes. Empty = a default list reflecting what the CDR enforces. |
Every unset optional endpoint is simply omitted from the discovery document — the server advertises your values verbatim and validates none of them beyond the deprecated-grant check.
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 — the configured base path without its
/openehr/v1 tail — 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/auth",
"token_endpoint": "https://as.example/token",
"capabilities": ["context-openehr-ehr", "openehr-permission-v1"],
"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"
],
"response_types_supported": ["code"],
"services": [
{ "type": "org.openehr.rest", "baseUrl": "/ferroehr/rest/openehr/v1" }
]
}
capabilitiesalways containscontext-openehr-ehrandopenehr-permission-v1, pluscontext-openehr-episodeandlaunch-base64-jsonwhen those switches are on.servicesnames the openEHR REST service with its base URL, plus the FHIR façade (org.fhir.rest) when the FHIR routes are enabled.scopes_supportedis the default list above unless you configure your own, which is then emitted verbatim.
With SMART disabled the path is not mounted at all (404).
The scope grammar
SMART resource scopes have the form
<compartment>/<resource>.<permissions>:
- Compartment —
patient(the launch context’s EHR only),user(what the user may see), orsystem(a backend service, no user). - Resource — one of three families:
composition-<template-id>,template-<template-id>, oraql-<stored-query-name>. - Permissions — any combination of
ccreate,rread,uupdate,ddelete,ssearch/execute (order-free, e.g..crud,.rs).
Resource ids accept wildcards: * matches within one ::-delimited namespace
segment, ** matches across namespaces, and a bare * or ** matches every
id. The permission tail is split at the last dot, so template ids and query
names keep their internal dots and versions.
| Scope | Grants |
|---|---|
patient/composition-*.crud | Create, read, update, and delete any composition — but only in the launched patient’s EHR. |
patient/aql-*.rs | Read and execute any stored query, scoped to the launched patient’s EHR. |
patient/composition-MyHospital::Template.v0.r | Read compositions of exactly that template, in the launched patient’s EHR. |
user/composition-MyHospital::*.r | Read compositions of any template in the MyHospital namespace (not sub-namespaces). |
user/template-*.cruds | Full access to template definitions. |
system/aql-org.openehr::bloodpressure.v1.rs | A backend service may read and execute that one stored query, across all EHRs. |
Scopes the server does not recognise are ignored (never granted, never
fatal); the identity scopes (openid, profile, offline_access, …) and
the launch/launch/patient context scopes pass through untouched.
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). Any operation permitted only by a patient/… scope is then
bound to that one EHR: requests against any other EHR are 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.
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 & multi-tenancy). Every active layer
must allow the request; SMART never overrides a denial from another layer. A
scope denial is a 403 Forbidden.
In this release the scope gate enforces the composition family —
composition reads and writes are checked against …/composition-….… scopes
and the patient-compartment binding. The template and aql scope forms are
parsed and advertised so authorization servers can issue them; those
families, like the EHR, EHR_STATUS, CONTRIBUTION, and DIRECTORY operations
(for which SMART defines no resource type), remain governed by the RBAC/ABAC
layers.
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 (or a Basic-auth caller, which has no scopes) is 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 Bearer token with no matching SMART resource scope for a scope-governed operation is denied, matching deployments where every app is a SMART app.
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.
The trail is orthogonal to openEHR’s own CONTRIBUTION/AUDIT_DETAILS
change-control audit (which the server always writes in the same transaction
as every version change): openEHR audit records what a version says about its
own authorship; ATNA records security surveillance of access, including
reads and rejected attempts.
The record, in both official formats
Every audited operation produces one record, rendered in the two formats the IHE standards define:
- FHIR R4
AuditEventfollowing the IHE BALP (Basic Audit Log Patterns) content profiles — the modern RESTful-ATNA form and the canonical stored form. Patient-centric operations carry the resolved EHR subject as the patient entity (PatientRead/PatientCreate/… profile claims); query executions carry the search expression; Bearer-authenticated requests record the token’sjti(and never the token itself) perOAUTHaccessTokenUse.Minimal. - DICOM Audit Message (DICOM PS3.15 §A.5 XML) — the classic ATNA form,
shipped over syslog per IHE ITI-20 when the syslog sink is enabled.
Dedicated DICOM event ids are used throughout: Patient Record (110110),
Query (110112), Export/Import (110106/110107, the EHR-Extract directions),
User Authentication (110114, with the Login
EventTypeCode110122).
Every server operation is audited (unrecognised extension operations fail
closed to a generic audited class — nothing is silently unaudited), and
authentication rejections (401/403) are always recorded, attributed to
the caller where one authenticated.
Sinks
Records fan out to independently configured sinks ([audit] in
ferroehr.toml — see the
configuration reference):
| Sink | Default | What it does |
|---|---|---|
[audit.store] | on | The local Audit Record Repository: records persist in the dedicated audit PostgreSQL schema (append-only, strictly outside the EHR content), served back via the ITI-81 search below. retention_days prunes old records hourly (0 = keep forever). |
[audit.syslog] | off | The classic ATNA feed: DICOM PS3.15 XML over syslog (RFC 5424; UDP or TLS transport) to an external ARR, per IHE ITI-20. |
[audit.fhir_feed] | off | The RESTful-ATNA feed (ITI-20 ATX: FHIR Feed): each FHIR AuditEvent is POSTed to an external FHIR ARR. With the local store on, delivery is outbox-driven: an ARR outage loses nothing, pending records ship on recovery. |
The local store is the durability anchor. Under fail_mode = "closed", a
store that stops accepting writes makes every subsequent auditable operation
answer 503 Service Unavailable until a write succeeds again — no un-audited
PHI access. (open, the default, drops-and-meters instead; every loss path
is metered — see the atna_audit_* counters in
Operations.)
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:
date (ge/le-prefixed instants, repeatable), patient, agent (the
principal), entity (the resource id), outcome (0/4/8/12),
action (C/R/U/D/E), and _count/_offset paging; other FHIR
search parameters are ignored (lenient search). The surface is admin-only
under RBAC and answers 404 when the local store is disabled.
# Who accessed patient-42's data this month?
curl -u admin:pw \
"https://cdr.example.org/ehrbase/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.
Node authentication (ITI-19, mutual TLS)
ATNA’s second half is node authentication. [server.tls] terminates TLS
natively (protocol floor: TLS 1.2+, per IETF BCP 195) and can demand a
verified client certificate:
[server.tls]
enabled = true
cert_file = "/etc/ferroehr/server.pem"
key_file = "/etc/ferroehr/server.key"
client_auth = "required" # off | optional | required
client_ca_file = "/etc/ferroehr/client-ca.pem"
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. The
separate-port management listener always stays plain HTTP (an internal
surface). 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. Deployments
upgrading from the previous [atna] section: the server refuses the old
keys at boot with did-you-mean guidance — move the settings under
[audit.syslog] (host/port/transport/tls_ca_file/
tls_identity_cert_file/tls_identity_key_file) and the shared keys to
[audit].
Admin console
ferroehr-admin-ui is a standalone web console 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 console 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 console as the ferroehr-admin-ui service
on port 3000:
docker compose up ferroehr-postgres ferroehr ferroehr-admin-ui
# → http://localhost:3000 (log in with the dev users, e.g. ferroehr/ferroehr)
Standalone, point it at any CDR:
docker run -p 3000:3000 \
-e FERROEHR_ADMIN__CDR__BASE_URL=https://cdr.example.org \
ghcr.io/rubentalstra/ferroehr-admin-ui
Signing in
The sign-in page offers exactly the methods that can actually work: the
console’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. The page
is served fully rendered and works with JavaScript disabled.
The console ships a full dark theme (the toggle persists per browser), and 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 console’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.
Configuration
One TOML file (ferroehr-admin-ui.toml, searched in the working directory
and /etc/ferroehr/admin-ui.toml, or pointed at with
FERROEHR_ADMIN_CONFIG), with FERROEHR_ADMIN__<SECTION>__<KEY> environment
overrides:
| Key | Default | Meaning |
|---|---|---|
cdr.base_url | http://localhost:8080 | The CDR origin (the ITS-REST base path is appended). |
cdr.request_timeout_secs | 30 | Per-request timeout toward the CDR. |
cdr.management_base_url | {cdr.base_url}/management | The CDR’s management surface, base path included — set it when the CDR serves management on its own internal listener (management.port) or under a renamed base path. Drives the Operations panel. |
auth.basic_enabled | true | Offer the username/password form (validated against the CDR; held server-side). |
auth.oidc.enabled | false | Offer OIDC login (authorization code + PKCE). |
auth.oidc.issuer / client_id / client_secret (_file) / public_base_url / scopes | — | The OIDC client registration; public_base_url is the console’s externally visible origin for the redirect URI. |
session.idle_minutes | 60 | Session idle expiry. |
session.cookie_secure | false | Set behind TLS. |
The console is stateless apart from its in-process session store: 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 two console replicas always agree and nothing needs backing up.
Login and sessions live in the console’s backend; CDR credentials and bearer tokens never reach the browser.
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, and version history. See Templates & EHR browsing.
- 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 — 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.
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 console 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.
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
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 FHIR 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
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 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 — the builder assembles the same query syntax tree the server validates, never text.
Choose what comes back: whole compositions, projected data points (with column aliases), or a bare match count. 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 — 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. The builder’s output shapes include EHRs (cohort): the distinct EHR ids matching the criteria tree.
Exporting results
Both results panes (the builder and the raw editor) offer Export CSV
and Export JSON — a plain form download that works even before the
page’s WebAssembly loads. The export runs the query’s own LIMIT window,
or the server’s default fetch limit when the query has none. CSV cells
hold scalar values verbatim; structured values are embedded as compact
JSON.
Stored queries & namespaces
Fresh repositories start empty, with the action that fills the screen:
List the CDR’s stored queries, inspect a query’s AQL, and jump into the editor to run it.
The table is paged by the shared footer under it — rows on screen out of how many, previous/next, and 25/50/100 rows per page, all in the URL (see Paging). The namespace panel beside it is derived from the same listing and shows every namespace, whichever page you are on.
Each stored query row offers three hand-offs:
| Action | What it opens |
|---|---|
| Run | The stored-query runner — executes that stored query on the CDR, with its parameters prompted |
| Open in editor | The raw AQL editor, seeded with that version’s query text |
| Open in builder | The query builder, with the query loaded back into its controls — when it fits (see Opening a stored query in the builder) |
Both editing hand-offs pre-fill the namespace, name, and version fields — with the version set to the next one, so saving again publishes a new version instead of colliding with the one you opened (see Versions).
Opening a stored query in the builder
The builder writes one shape of AQL, and it will only load a stored query back
into its controls when it can reproduce that query byte for byte. 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 will send:
| Resolution | Request | Which version runs |
|---|---|---|
| Latest version | POST /query/{name} | the latest version of that query |
| Version prefix | POST /query/{name}/1 or …/1.2 | the latest version matching the prefix |
| Exact version | POST /query/{name}/1.2.0 | exactly that version |
A version that does not fit the chosen form (a full 1.2.0 typed as a prefix, or
a bare 1 typed as exact) is refused with an explanation before anything is
sent.
Grouping is the namespace
A stored query is identified by a qualified name — namespace::name, the
namespace optional and, when present, a reverse domain name whose purpose in
the openEHR REST specification is exactly “separation of use of stored
queries by teams, companies, etc.”
The console 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 console’s disk, so the grouping is durable in the
CDR and reads identically for every openEHR client and every console
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 will write. Typing the whole namespace::name into the name
field works too.
Versions
A stored query is identified by its qualified name and a version, and the
version is SEMVER-style — major.minor.patch. The save surfaces expose it as
an optional Version field, and the line under the fields always states
which of the two openEHR store operations a click will perform:
| Version field | What a save does |
|---|---|
| empty | PUT /definition/query/{name} — the CDR files it at the default slot 1.0.0 and replaces whatever was stored there |
1.2.0 | PUT /definition/query/{name}/{version} — stores a new, immutable version; if that exact (name, version) pair already exists the CDR refuses it (409) and the console 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 console 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
Template Manager
Upload ADL 1.4 operational templates (the CDR’s validation diagnostics surface verbatim on rejection) and browse what is installed.
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 by id, concept, or archetype id as you type, 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 console 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].
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, and contribution lookup. 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 read from the EHR itself — 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. Below it, the tabs resolve the EHR status (queryable / modifiable) and list the EHR’s compositions with their template, time, and version count.
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 — every composition, contribution and audit record under the EHR is removed, and it cannot be undone. On success the console 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 day, from the same contribution data the list below it shows.
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 console-side library of folder shapes — the console 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), a
version_at_time time-travel lookup, a path= sub-folder query,
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 console — 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 console; 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 — the raw views remain the complete record. - Copy puts the raw document text on the clipboard.
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 — 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.
Deleting a composition
Delete composition on the viewer performs the openEHR logical delete of the composition’s latest version, with a confirmation dialog first. The CDR commits a deleted version on top of the current one: the composition stops resolving as current and leaves the EHR’s composition list, while every earlier version and the audit trail stay readable. It is a normal versioned write, so it needs no admin API — but it does need the version to still be the latest one: if it moved on since the screen loaded, the CDR refuses the delete and the message says to reload the history and retry.
Note
This is not the same operation as Delete EHR above, which is the CDR’s physical admin delete and leaves nothing readable.
The EHR detail’s contributions tab lists the EHR’s contributions — id, commit time, committer, change type — with the by-uid lookup kept underneath.
The commit form accepts canonical JSON, canonical XML, or FLAT:
EHR status
The Status tab renders the EHR’s current EHR_STATUS: the queryable and
modifiable flags as badges, the subject, the version the document is, and the
full document itself. A non-queryable EHR is called out — AQL over it returns
nothing.
Below the document, Edit status changes the two flags and other_details:
- tick or untick is_queryable to include the EHR in population queries (AQL), and is_modifiable to allow new content to be committed to it;
- other_details takes a canonical-JSON
ITEM_STRUCTURE(for example anITEM_TREE); leaving it blank removes the attribute. A value that is not a JSON object is refused before anything is sent.
Saving commits a new EHR_STATUS version on top of the one the screen loaded, and every other attribute — the subject included — is sent back exactly as the CDR served it, so nothing the form does not show can be lost.
Note
The save is conditional on the loaded version. If another client committed a new status in the meantime, the CDR refuses the write and the console 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.
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.
Operations panel
The Operations screen is the console’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 console has no privileged channel.
When it appears
The panel is probe-and-hide: on every page load the console asks the CDR for
GET /management/info, and the sidebar entry appears only if the CDR answers.
A CDR running with its management surface off (the default) simply has no
Operations entry — the console never offers a screen that cannot work.
So to get the panel, enable the surface on the CDR (Operations → The management surface):
[management]
enabled = true
[management.endpoints]
info = "private" # the availability probe — enable it alongside the rest
metrics = "private"
env = "admin_only"
loggers = "admin_only"
Each endpoint is independently opt-in, and a card whose endpoint is off says so
in place instead of failing — but info is the probe, so a deployment that
leaves 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 console at it with one setting — the full prefix, including the path:
FERROEHR_ADMIN__CDR__MANAGEMENT_BASE_URL=http://cdr.internal:9464/management
Unset, the console derives {cdr.base_url}/management.
Note
The management endpoints are access-level gated server-side (
admin_onlyby default). The console 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 the optional component flags — with the CDR’s own detail
text where it gave one.
This is deliberately a different question from the status pill in the
topbar, which polls the product status document (GET /ferroehr/rest/status):
| Reader | Question it answers |
|---|---|
| topbar pill | is the API answering at all, and at which version? |
| health card | are the CDR’s dependencies healthy enough to serve? |
The card states that split on screen, and nothing else in the console 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 — and, under their own heading, the openEHR specification versions this
deployment implements. 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 console reads it in exactly one place: the System screen. This card links there rather than rendering a second copy.
Log level
The log card shows the filter in effect right now and the boot filter a reset
restores, and lets an operator change the live filter without a restart
(POST/DELETE /management/loggers):
Type tracing-style directives (ferroehr=debug,sqlx=warn), press Apply
filter, and confirm: the dialog spells out that logging changes immediately
for every request the instance serves. Reset to boot filter puts the startup
value back. Both outcomes are reported as a toast, and the card re-reads the
CDR’s answer, so what you see is what the server confirmed — not what was asked
for.
Important
A log-filter change applies to that CDR instance only and lasts until it restarts. Behind a load balancer, each instance is set separately; for a permanent change, set
log.filterin the server configuration.
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.
- Database roles and least privilege
- Applying migrations
- TLS and database security
- Backup and point-in-time recovery
- The container image and pod hardening
- Upgrades
- Observability
- The admin API: physical deletion
- The admin API: activity report
- The admin API: archiving
- The admin API: dump and load
- The messaging API: EHR Extract and TDD import
- Health probes
- The management surface
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 uses a four-role model — never a superuser at runtime:
| Role | Purpose | Used by |
|---|---|---|
| owner | owns the database | provisioning only |
ferroehr_migrator | runs the schema migrations; owns the helper functions | the migration step |
ferroehr_app | reads and writes clinical data | the running server |
ferroehr_reader | read-only | replicas and reporting |
The migrations create these roles idempotently, apply the per-schema grants,
and revoke the ability to create objects in the public schema. The running
server connects as ferroehr_app — its DSN should authenticate as that role,
not the migrator or the owner.
Applying migrations
The binary applies its embedded migrations on boot, so you choose how migrations run:
- Grant the runtime DSN the migrator role — simplest, for single-tenant or small deployments; the server migrates itself at startup. Least isolation.
- Run migrations out of band with a migrator DSN, then start the server
with the lower-privileged
ferroehr_appDSN — recommended for least-privilege production. Run the migration as a CI/CD step or a one-shot job with the migrator credential before rolling the deployment, and gate the rollout so two versions never race the schema.
TLS and database security
These are database-side settings that belong to whoever provisions PostgreSQL; the deployment references them but cannot enforce them:
- TLS in transit. Require
hostsslon the server and put?sslmode=verify-fullin the DSN so the client verifies the server certificate. - pgaudit. Run pgaudit as the database-layer complement to the openEHR
audit and the ATNA trail — for example
pgaudit.log = 'ddl, role, connection'globally plus object-level audit on the PHI tables — and ship the audit log to an immutable store with long (roughly six-year) retention. - Encryption at rest. Encrypt at the volume or disk layer. Do not encrypt the stored clinical JSON with pgcrypto — it would break AQL’s ability to query inside the data.
Backup and point-in-time recovery
Enable WAL archiving and point-in-time recovery from day one (pgBackRest or a
managed PITR), because a CDR’s data is not reconstructible. Clinical and audit
tables are never UNLOGGED. Test your restore, not just your backup.
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. When run under Kubernetes with the provided Helm chart, the pod is hardened and the chart’s validation asserts these on every render:
| Setting | Value |
|---|---|
| run as non-root | uid/gid 65532 |
| read-only root filesystem | yes (a writable emptyDir at /tmp) |
| privilege escalation | disallowed |
| Linux capabilities | all dropped |
| seccomp | RuntimeDefault |
| service-account token | not mounted (the workload never calls the Kubernetes API) |
| NetworkPolicy | default-deny ingress; only the API/management port admitted |
Egress restriction is opt-in because egress targets (database, broker, terminology server) are deployment-specific. See Installation → Kubernetes & Helm for the chart itself.
Upgrades
- Backward-compatible migrations. Migrations are append-only and never edited once applied. A rolling upgrade must be compatible with the previous schema for the window where both versions run: apply additive changes first, and defer destructive changes to a later release once every pod is on the new version.
- Lock-safe DDL. The migration runner bounds DDL with a
lock_timeoutandstatement_timeoutso a migration cannot block live traffic indefinitely; on a busy table useCREATE INDEX CONCURRENTLYand add constraintsNOT VALIDthenVALIDATElater. - Pin the image. Deploy an immutable tag or, better, a
@sha256digest, neverlatest; roll back by re-pinning the prior digest — 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.
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
ATNA audit trail.
- Logs go to stdout — JSON when not attached to a terminal, pretty on a
TTY — each line stamped with the trace and span id. Shipping and rotation
are the platform’s job.
FERROEHR__LOG__FORMAT(auto/json/pretty) andFERROEHR__LOG__FILTER(orRUST_LOG, defaultinfo,ferroehr=info) control them, and the level can be changed at runtime through theloggersendpoint below. On boot the server prints a one-time ASCII banner (version, maintainer, project URL, and spec pins) to stdout ahead of the logs; it is suppressed underFERROEHR__LOG__FORMAT=jsonso machine log consumers see only structured lines. - Traces export to any OpenTelemetry collector (Tempo, Jaeger, and so on) over OTLP — but only when you configure an endpoint; with none set, the tracing layer is not installed at all (zero overhead). Root spans are named by route template, never by a path containing ids.
- Metrics are exposed for Prometheus to scrape at
/management/prometheus(OTLP metrics push is an option). The catalogue includes HTTP request duration and active requests, authentication failures and authorization decisions, database pool state, AQL query counts and latency, compositions committed (by openEHR audit change type), validation failures, and the audit pipeline’s health.
The telemetry environment variables:
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__TELEMETRY__OTLP_ENDPOINT | unset (layer not installed) | OTLP collector endpoint |
FERROEHR__TELEMETRY__SERVICE_NAME | ferroehr | reported service name |
FERROEHR__TELEMETRY__ENVIRONMENT | dev | reported deployment environment |
FERROEHR__TELEMETRY__TRACES_SAMPLE_RATIO | 1.0 | head sampling ratio (start at 0.1 in production) |
FERROEHR__TELEMETRY__METRICS_PUSH | false | also push metrics over OTLP |
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.
The admin API: physical deletion
Normal openEHR deletes are logical — history is retained. The admin API is
the exception: physical, irreversible removal, for legal erasure requests
and test-data cleanup. It is enabled only by
FERROEHR__ADMIN__ENABLED=true and is classed under admin authorization (the
ADMIN role). While it is disabled every admin route answers
405 Method Not Allowed with an empty Allow header — the resource exists
but currently serves no method (see
Error bodies).
DELETE {base}/admin/ehr/{ehr_id}— physically delete one EHR and everything in it. 204 on success, 404 for an unknown id.DELETE {base}/admin/ehr/all— bulk delete. With noehr_idparameter this 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). Returns 204 with no body.DELETE {base}/admin/template/{template_id}— physically delete one operational template. 204 on success, 404 for an unknown id, and 409 if any stored composition was committed against the template (delete those compositions first — a physical delete never orphans clinical data).DELETE {base}/admin/query/{qualified_query_name}/{version}— physically delete one stored-query version (a singlename/versionrow). 204 on success, 404 for an unknown name or version.GET {base}/admin/config— the effective configuration as a JSON tree (the merged result of the config file,FERROEHR_*environment, and--setoverrides). Every secret-bearing value is redacted: passwords and password hashes, HMAC and signing-key secrets, and S3 secret keys render as***, and connection URLs (database, AMQP) keep their host and path but mask the embedded credentials (postgres://***@host:5432/db). Non-secret identifiers — usernames, roles, an OIDC issuer — stay visible. 200 with the redacted tree. Redaction is a structural property of the configuration’s secret types, not a key-name filter, so no secret value can reach this response.
Note
The template and stored-query deletes and the config view are FerroEHR extensions — the openEHR admin API defines only EHR deletes. They share the same admin gate and authorization as the EHR deletes.
The admin API: activity report
Four read-only counters over the repository’s change history, behind the
same FERROEHR__ADMIN__ENABLED gate and ADMIN role as the deletes above.
Every route takes a_service — the service whose versioned content to report
on, one of Admin, Definitions, Ehr, Ehr_index, Demographic,
Message, Query, System_log (case-insensitive) — and an optional
time_interval=<lower>/<upper> of 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.
GET {base}/admin/report/contribution— the ids of the matching CONTRIBUTIONs, ordered by commit time. 200 with a JSON array.GET {base}/admin/report/contribution/count— how many there are. 200 with a bare JSON number.GET {base}/admin/report/versioned_composition/count— how many distinct COMPOSITION version containers had a version committed in the interval.GET {base}/admin/report/composition_version/count— how many individual COMPOSITION versions were committed in the interval.
An unknown a_service, or a time_interval that is not <lower>/<upper>
with valid ISO 8601 bounds, is 400. So is 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.
The admin API: archiving
Two routes that mark a selected set of records as archived, behind the same gate and role. Archiving is not a delete: it is a lifecycle marker, and the archived records stay fully readable through the normal API.
POST {base}/admin/archive/ehrswith{"ehr_ids": ["…"]}— mark every named EHR (and all its versioned content) archived. 204 on success.POST {base}/admin/archive/partieswith{"party_ids": ["…"]}— mark every named demographic party archived. 204 on success.
Both are all-or-nothing and idempotent: a malformed id is 400 and an id that names nothing is 404, in both cases before anything is marked; re-archiving an already-archived record changes nothing. An empty list succeeds and archives nothing.
The admin API: dump and load
Two routes that move the whole repository to and from an archive on the
server’s file system, behind the same FERROEHR__ADMIN__ENABLED gate and
ADMIN role. Both answer 200 with a JSON array of per-entity failure
reports — an empty array means everything succeeded.
-
POST {base}/admin/dump— write an archive of every EHR. The body is{"file_sys_loc": "…"}plus the optional export settings:Field Values Default logical_formatopenehr_canonical_jsonoropenehr_canonical_xmlcanonical JSON compression_formatzipor7z— omit for loose filesuncompressed segment_split_sizesegment size in kb (a positive integer) 1024 -
POST {base}/admin/loadwith{"file_sys_loc": "…"}— populate the repository from an archive. It takes the location and nothing else: the container (loose files, a singlearchive.zip, or a singlearchive.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.
The archive is a directory holding a manifest.json, one or more
segment-NNNN.json files, and a blobs/ subdirectory for any externalized
multimedia — or exactly those entries packed into one archive.zip or
archive.7z when compression_format is set.
logical_format chooses how the clinical content is serialized, not how
the archive is packaged:
openehr_canonical_json(the default) keeps each version’s content inline in the segment files, exactly as this server stores it.openehr_canonical_xmlwrites each version to its ownversions/<version_uid>.xmlentry instead — a completeORIGINAL_VERSIONdocument under the openEHR-published<version>root, ready to hand to any tool that reads canonical openEHR XML. The archive’s own bookkeeping (manifest.json, the segment files) stays JSON in both formats, because openEHR publishes no XML document form for it.
Both directions are lossless: a dump and a load reproduce every record byte-for-byte, whichever format and container you choose.
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:
[ { "entity_type": "EHR",
"entity_id": "7d44b88c-4199-4bad-97dc-d78268e01398",
"dump_status": false,
"error": "an EHR with this id already exists" } ]
A missing or blank file_sys_loc, a format value that is not one of the ones
listed above, a non-positive segment_split_size, or an encoding field is
400.
A location that holds no archive, and one holding an archive that is
corrupt — a mangled or truncated container, manifest, or segment — are the
same fact and answer the same way: 500, the service model’s single
file_not_writable error for these operations. Nothing is loaded either way.
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 too — the openEHR service model defines these operations, but the released REST API surfaces no endpoint for them, so their URLs are our own. They gate no openEHR conformance claim; see Conformance.
Warning
DELETE /admin/ehr/allwithout a parameter empties the repository — there is no confirmation step and no undo. Keep the admin API disabled unless a workflow needs it, and gate theADMINrole tightly.
The messaging API: EHR Extract and TDD import
A group of six routes under {base}/message that move whole records between
systems and accept documents in the template-data (TDD) form. Unlike the admin
extensions above, these are not admin-gated: they carry the same ordinary
authentication as the clinical API, because they read and write the same
clinical content.
EHR Extract
GET {base}/message/export/{ehr_id}— export one whole EHR. 200 with a JSON array holding oneEXTRACTthat carries every versioned object of the EHR, latest versions only. 404 if the EHR does not exist; 400 ifehr_idis not a well-formed identifier, which is refused before any lookup.POST {base}/message/exportwith anEXTRACT_SPECbody — export by specification. 200 with oneEXTRACTper manifest entity, in manifest order; a manifest with no entities yields[]. Each entity must name the record byehr_idorsubject_id, otherwise 400; an identifier that names nothing is 404.extract_typemust be one of the codes the openEHR Reference Model names —openehr-ehr,openehr-demographic,openehr-synchronisation,openehr-generic,generic-emr— or the catch-allother; anything else is 400.POST {base}/message/importwith anEXTRACTbody — clone a whole EHR. Add?ehr_id=<uuid>to fix the identifier the clone lands under; leave it off and the source identifier the extract carries is re-used. 201 with{"uid": "<ehr_id>"}, so a caller that supplied no id still learns what was created. The target must not already exist (409), and the extract must carry anEHR_STATUS(400).POST {base}/message/import/{ehr_id}with anEXTRACTbody — add the extract’s content to an existing EHR as new versions. 204. 404 if the EHR does not exist; 409 if the extract carries anEHR_STATUSorEHR_ACCESSother than the one the EHR already holds.
TDD import
POST {base}/message/tdd/{ehr_id}with anapplication/xmlbody — import one Template Data Document. It is converted against the operational template its root names and committed through the ordinary validated composition path, so 201 with{"uid": "<version_uid>"}. The template must already be uploaded through the definition API (404 otherwise); a body that does not conform to it is 400, and a document that is not well-formed XML is 422.POST {base}/message/tdd/{ehr_id}/batchwith a JSON array of TDD documents — import several at once. 201 with the created version ids in input order. The batch is all-or-nothing: every document is converted before any is committed, so one bad document rejects the whole batch and commits nothing. An empty array is a fulfilled no-op — 200 with[], since nothing was created — but the target EHR is still checked, 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
/messagegroup is a FerroEHR extension: the openEHR service model defines a Message component, but the released REST API publishes no message, extract, or TDD endpoints at all. These URLs are our own and gate no openEHR conformance claim; see Conformance.
Health probes
The health endpoints are always served, on the main API port, without authentication. There is nothing to enable and nothing to remember: they are mounted outside the API’s authentication and overload-shedding layers, so an orchestrator can probe a server whose management surface, admin API, and every optional integration are switched off — and a saturated server still answers its own probes.
Choosing a health endpoint
| Endpoint | Contract | Use it for |
|---|---|---|
GET /health | constant 200 OK (plain text OK), touches nothing | load balancers, docker HEALTHCHECK, anything that must never be auth-gated |
GET /health/liveness | identical to /health — the same constant answer under the orchestrator-conventional path | Kubernetes livenessProbe and startupProbe |
GET /health/readiness | 200 when the aggregate is up or degraded, 503 when a required component is down; JSON body with every indicator (database ping, migrations applied, audit sender, events), each bounded to one second | Kubernetes readinessProbe, ops dashboards |
GET /ferroehr/rest/status | product status document: server version, ITS-REST version, timestamp | version/identity checks; the URL the container’s ferroehr healthcheck subcommand probes |
GET /management/* | ops introspection — see below | operators, off by default, enable deliberately |
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.
Important
Liveness and readiness are deliberately different: liveness never touches a dependency, so a database outage takes the instance out of rotation (readiness
503) instead of getting the container killed and restarted in a loop. WirelivenessProbeandstartupProbeto/health/livenessandreadinessProbeto/health/readiness. The Helm chart does exactly this out of the box;probes.exec.enabled=trueswitches to the container’sferroehr healthchecksubcommand if the kubelet cannot reach the HTTP port.
The management surface
The management surface is ops introspection only — build info, Prometheus,
the metric views, the effective configuration, and runtime log control. It is
off by default on the bare binary, and each endpoint is independently
opt-in with an access level (admin_only, private, or public). It can be
bound to its own internal port so it never appears on the public API listener.
Keeping it off costs you nothing operationally: the health probes above do not
depend on it.
| Environment variable | Default | Meaning |
|---|---|---|
FERROEHR__MANAGEMENT__ENABLED | false | enable the management surface |
FERROEHR__MANAGEMENT__BASE_PATH | /management | base path for the surface |
FERROEHR__MANAGEMENT__PORT | unset (main listener) | serve management on its own port |
FERROEHR__MANAGEMENT__ACCESS_DEFAULT | admin_only | default access level |
The ops endpoints:
| Endpoint | Purpose | Default access |
|---|---|---|
GET {base}/info | build, version, and pinned spec versions | admin_only |
GET {base}/prometheus | Prometheus text exposition | admin_only (re-expose to the scraper via network policy) |
GET {base}/metrics | JSON registry view | admin_only |
GET {base}/env | effective configuration, with secrets redacted | admin_only |
GET/POST/DELETE {base}/loggers | read and change the log level at runtime | admin_only |
Warning
{base}/envand{base}/loggersexpose and change server internals — keep themadmin_only, and prefer binding the surface to an internal-only port.
With the surface enabled, the admin console grows an Operations screen over
it — dependency health, build provenance, the metric registry, and runtime log
control — which appears only while the CDR serves {base}/info. See
Admin console → Operations panel.
For the full list of configuration keys across every subsystem, see
Installation → Configuration reference; to
explore the API itself, open the API reference at /ferroehr/api/ (also
linked from the toolbar on every page).
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, not by prose. This chapter explains what conformance means here, how to run the suite — against this server, against another CDR, or against any deployed endpoint you point it at — and how to read the artefacts it produces: the report, the statement, the certificate, and the cross-server comparison matrix.
- What is measured
- The current result
- Any server can be assessed
- Running the suite yourself
- Reading the artefacts
- What conformance does not cover
What is measured
Conformance is checked by the CNF 2.0 reference runner — 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-ITS operation bindings
mapping every outcome to its OpenAPI-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 spec silence is never resolved
privately. Every expectation traces to specification text, never to any
server’s observed behaviour. The catalogue spans the schedule’s chapters:
| Chapter | Scope |
|---|---|
| EHR / EHR_STATUS | EHR service and status operations |
| COMPOSITION / CONTRIBUTION / DIRECTORY | Clinical content, change sets, folder trees |
| DEFINITION | ADL 1.4 + ADL 2 template and stored-query provisioning |
| QUERY | AQL query execution with committed result-set grounds |
| CONTENT | Reference-Model and archetype-constraint accept/reject tables |
| DEMOGRAPHIC / ADMIN / MESSAGING | Party, admin, and messaging services |
| SF / SEC / PERF | Simplified formats, security, performance classes |
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, catalogue, and verdict pipeline live in
tools/cnf-runner; the published JSON Schemas for every artifact family are committed undertools/cnf-runner/schemas/. The instrument is built from the currently pinned specifications (Reference Model 1.2.0, AQL 1.1.0, Terminology 3.1.0, ITS-REST 1.1.0). 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 artifacts 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 passed, FAILED,
errored and cited-N/A 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:
- 863 case-by-format executions: 826 passed, 0 failed, 0 inconclusive, 37 not applicable with a machine-readable citation (run of 2026-07-31).
- 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 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: a phase can only close on a run with zero drift against the committed baseline.
- Upstream EHRbase (Java) —
CONF_SUT=ehrbase-javacomposes the officialehrbase/ehrbaseimage (with its companion PostgreSQL) on fresh volumes and runs the same catalogue with upstream’s own committed party set. Its measured artifacts live underdocs/conformance/ehrbase-java/and 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.jsonnaming the instances and credential environment variables, and astatement.json— 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, ISO/IEC 9646-style test selection). No code or adapter is needed; a target is a configuration entry. Theixit.jsonmay also declare deployment facts no openEHR operation exposes —system_id, the identifier the server stamps into the commit audit and into the version ids it mints; andterminology, the terminology query servers the deployment is wired to, the terminology namespaces each one answers for, and what the deployment does with a bound value set it cannot resolve. Cases that check such a value read the declaration; a party that declares none has those cases recorded not-applicable rather than checked against a guess.
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
# upstream EHRbase (Java), from the official images
CONF_SUT=ehrbase-java 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_NO_COMPOSE to run against
an already-deployed stack, and cnf-runner verdicts to recompute the
documents from a previous results.json without re-running.
Configuration lanes
Some behaviour only exists in a particular server configuration, and a run against the default configuration cannot reach it. Those get their own lane: a compose overlay plus the matching party artefacts, selected by one environment variable. Each lane is a separate, deliberate run — the default lane stays the published baseline.
# openPGP version signing instead of the default digest (stacks on the
# standard posture)
CONF_SIGNING_MODE=pgp bash scripts/conformance.sh
The SMART resource-server posture is the standard conformance posture
for ferroehr — every pipeline run boots the server with smart.enabled,
smart.require_smart_scopes and an auth.oidc issuer it trusts
(docker/sut-smart.yml), and the runner’s principals present minted Bearer
tokens carrying the roles and resource scopes each case needs. The SMART
discovery document, the resource-scope grammar, and the fail-closed 403 are
executable cases in the same committed record as everything else; a SUT
whose ixit declares no smart block (upstream EHRbase) records those cases
not-applicable with the citation instead. The tokens are signed by a
committed test issuer under tools/cnf-runner/party/smart/ — public
test key material for the conformance harness, never usable for anything
else.
An external terminology server is part of the standard posture too. An
archetype can constrain a coded element to a value set that only an external
terminology query server can resolve, so the pipeline composes a real FHIR R4
server beside the CDR (--profile terminology plus
docker/sut-terminology.yml), seeded with synthetic test code systems and
value sets, and the one committed record 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,
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: the primary deployment runs fail-open (the commit is accepted) and
the second one runs fail-closed (it is refused), and both branches execute in
the same record.
Cases that need a lane are recorded not-applicable with a citation on a
party that does not declare it, never failed: the party’s ixit.json is
where a deployment declares the posture it runs.
Reading the artefacts
A run writes one machine record and three human-readable documents to
docs/conformance/<sut-name>/. Each has a distinct job.
The machine record
results.json is the party results record (its JSON Schema is published at
tools/cnf-runner/schemas/results.schema.json): 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 SUT
identity, the runner’s verification-pack status, the technology profile,
and the ixit digest. verdicts.json is the computed verdict report. Every
other artefact is generated from these two — 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 validate gate refuses an artifact tree that
leaves such a row undecided. It is emitted for every assessed system —
FerroEHR, upstream, 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
docs/conformance/COMPARISON.md is the fully generated multi-SUT record:
profile verdicts, the capability-by-capability evidence matrix, and failure
tables in both directions, derived from the two committed
results/verdicts sets (ours and upstream EHRbase’s) by
scripts/render-comparison.sh — measured numbers only, no editorial
adjustment, both directions always published. The same content 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 passed/driven case count). A badge can never show 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 are telemetry only — the benchmark harness owns that claim). A capability whose wire cannot exist on this technology profile (for example ADL 1.4 archetype provisioning, which ITS-REST 1.1.0 defines no endpoint for) is excused through the schedule’s typed ambiguity register and reported as an explicit scope exclusion on the certificate — never a silent pass and never an unavoidable failure.
Performance
FerroEHR makes the same discipline it applies to conformance apply to performance: a class is not a marketing label a vendor writes down, it 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 artifact. Nothing on this page is hand-typed; every number comes from the committed measurement records or the generated assets below.
- The volumetric class ladder
- Where the floors come from
- The hospital simulation
- How a measured run works
- Reproducing it
- Stress testing — the second instrument
- The latest measured run
The volumetric class ladder
Performance conformance is graded on a small, closed ladder of deployment classes — proof-of-concept, small, large, and regional. Each class fixes an offered-load floor (the peak API arrival rate the server must sustain), a latency budget (a p99 service-level objective), 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 artifact always says which.
Where the floors come from
The offered-load floors are not chosen for effect — they 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).
-
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.
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 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
(vocab/journey_catalogue.yaml in the runner’s artifacts):
- 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 artifact 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 ~50:1 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 is listed explicitly as a catalogue gap.
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 docs/conformance/<sut>/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 artifact. 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) — never a database backdoor — so what the run reads is exactly what the server’s own write path produced. The corpus contract is the scale-ladder recipe in the runner’s committed artifacts.
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 SUT, 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
The runner subcommand can also be driven directly against a running SUT — the
ixit topology file supplies the mandatory environment block:
# a full measured run of the proof-of-concept class
cnf-runner perf --root tools/cnf-runner/artifacts \
--ixit <ixit.json> --results <results.json> --class POC
# an officially EXTENDED sustained window (the hours ladder: one hour is
# the case's normative window and the default; two, four, six, eight, or
# twelve hours hold the same offered load for longer — a stricter
# demonstration of the same class). The seeded corpus is reusable across
# every run seeds the freshly composed server from empty.
cnf-runner perf --root tools/cnf-runner/artifacts \
--ixit <ixit.json> --results <results.json> \
--class POC --hours 8
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. The record carries the actual warmup and window it held, and the verdict machinery re-derives everything from the embedded histograms.
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 — 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).
# the step-load stress ladder over the seeded corpus (exploration only)
cnf-runner stress --root tools/cnf-runner/artifacts \
--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. 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 run where the load generator
tops 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: cnf-runner 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 (aql-probe.json, schema-published)
is exploration evidence for the optimization loop: it earns nothing and
never touches the conformance record.
# the seeded-corpus AQL probe (exploration only)
cnf-runner aql-probe --root tools/cnf-runner/artifacts \
--ixit <ixit.json> --out <aql-probe.json>
The published assets are rendered from the committed results.json by
cnf-runner perf-assets (wrapped by scripts/render-perf-assets.sh); the
docs CI job re-renders and git diffs them, so a hand-edited or stale asset
fails the build.
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 every 10 seconds across the whole window with the warmup shaded, and the database volume’s on-disk size at four anchors — empty, after the scale seed, after the ward seed, and after the measured window — down to the storage cost per committed composition. 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 — empty, after the scale seed, after the ward seed, and after the measured window — down to the storage cost per committed composition. The rendered resource time-series and disk-growth charts join this page with the next committed measured class run (they render only from a committed record — nothing here is ever mocked).
| Class | Corpus | Offered-load floor | p99 budget | Error budget | Measured sustained | Verdict |
|---|---|---|---|---|---|---|
| POC | cnf.scale.10k | 2/s | ≤ 1000 ms | 0 | 2.0/s | EARNED |
| S | cnf.scale.100k | 15/s | ≤ 1000 ms | 0 | — | not measured |
| L | cnf.scale.1m | 150/s | ≤ 1000 ms | 0 | — | not measured |
| R | cnf.scale.10m | 1500/s | ≤ 1000 ms | 0 | — | not measured |
Measured run PERF-hospital_sim-class_POC — class POC, offered load 2.04/s sustained over 3600 s (after 300 s warmup), environment: consumer-laptop (8 cores, 16 GB, nvme, single-node docker compose (8-CPU/8GB Docker VM) on Apple M2, the SMART resource-server posture (docker/sut-smart.yml overlays the base stack) with the external-terminology profile composed beside it (a seeded HAPI FHIR R4 server, docker compose –profile terminology + docker/sut-terminology.yml, fail-open); alongside it a second deployment of the same image in the openPGP version-signing posture (project ferroehr-cnf-pgp, docker/sut-signing-pgp.yml + docker/sut-terminology-failclosed.yml + docker/sut-pgp-parallel.yml, host port 8081) declared as the sut_pgp instance, which carries the fail-closed terminology posture — the measured-performance stage drives the primary deployment alone).
| Operation | Requests | Errors | p50 (ms) | p90 (ms) | p99 (ms) |
|---|---|---|---|---|---|
| adhoc_query | 792 | 0 | 29 | 38 | 64 |
| admin_contribution_report | 60 | 0 | 159 | 176 | 269 |
| analytics_query | 23 | 0 | 62 | 76 | 197 |
| archetype_adl2_list | 60 | 0 | 13 | 18 | 28 |
| composition_commit | 280 | 0 | 50 | 106 | 214 |
| composition_commit_flat | 7 | 0 | 42 | 53 | 53 |
| composition_delete | 4 | 0 | 28 | 35 | 35 |
| composition_read | 1583 | 0 | 24 | 33 | 66 |
| composition_read_current | 896 | 0 | 27 | 36 | 57 |
| composition_read_flat | 7 | 0 | 17 | 25 | 25 |
| composition_revision_history | 889 | 0 | 14 | 21 | 31 |
| composition_update | 68 | 0 | 46 | 67 | 239 |
| composition_version_read | 30 | 0 | 28 | 35 | 49 |
| contribution_commit | 48 | 0 | 54 | 138 | 187 |
| contribution_read | 82 | 0 | 20 | 28 | 60 |
| directory_create | 12 | 0 | 25 | 40 | 86 |
| directory_read | 803 | 0 | 17 | 24 | 40 |
| directory_update | 12 | 0 | 28 | 34 | 42 |
| ehr_create | 12 | 0 | 28 | 32 | 33 |
| ehr_extract_export | 792 | 0 | 149 | 182 | 268 |
| ehr_read | 72 | 0 | 18 | 25 | 37 |
| ehr_status_read | 24 | 0 | 16 | 23 | 25 |
| ehr_status_update | 24 | 0 | 21 | 36 | 55 |
| party_create | 6 | 0 | 22 | 28 | 28 |
| party_read | 6 | 0 | 16 | 24 | 24 |
| party_relationship_create | 6 | 0 | 26 | 30 | 30 |
| party_relationship_read | 6 | 0 | 11 | 17 | 17 |
| party_update | 6 | 0 | 15 | 27 | 27 |
| readonly_write_denied | 7 | 0 | 30 | 46 | 46 |
| smart_configuration_read | 7 | 0 | 15 | 24 | 24 |
| stored_query_execute | 180 | 0 | 41 | 55 | 103 |
| system_options | 7 | 0 | 10 | 18 | 18 |
| tags_put | 30 | 0 | 24 | 33 | 41 |
| tags_read | 30 | 0 | 17 | 26 | 38 |
| tdd_import | 7 | 0 | 35 | 60 | 60 |
| template_adl2_list | 61 | 0 | 13 | 19 | 54 |
| template_example | 60 | 0 | 30 | 102 | 261 |
| template_get | 60 | 0 | 64 | 134 | 240 |
| template_list | 60 | 0 | 53 | 80 | 90 |
| terminology_query | 23 | 0 | 19 | 26 | 73 |
| unauthenticated_probe | 7 | 0 | 18 | 23 | 23 |
| ward_query | 180 | 0 | 28 | 38 | 71 |
Resources (measured context, never a verdict input) — sampled every 10 s; CPU/RSS derived over the measured phase:
| Container | CPU mean | CPU peak | RSS peak |
|---|---|---|---|
sut ferroehr-cnf-ferroehr-1 | 3.6% | 7.7% | 218 MB |
db ferroehr-cnf-ferroehr-postgres-1 | 5.9% | 21.0% | 1.5 GB |
Disk anchors: empty 133 MB → after scale seed 13 GB (≈ 13 KB / composition over 1,000,000 committed) → after ward seed 13 GB → after window 13 GB.
Benchmarks
FerroEHR measures performance with the same instrument family that measures conformance — the built-in CNF runner. There is no separate benchmark harness: every published number is measured by a committed, re-runnable instrument, reported in both directions, and regenerated from committed artifacts. There is no marketing chart in this project that you cannot regenerate yourself with one command.
The three instruments
- Measured class runs (
cnf-runner perf, wrapped byCONF_PERF_CLASS=… bash scripts/conformance.sh) — conformance by measurement: the open-loop hospital-simulation workload holds a population-anchored offered-load floor for the normative hour (or an extended 2–12 h hold), 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 (
cnf-runner stress) — 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. - The AQL probe (
cnf-runner aql-probe) — 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)
cnf-runner stress --root tools/cnf-runner/artifacts \
--ixit tools/cnf-runner/party/ferroehr/ixit.json \
--out docs/conformance/ferroehr/stress.json
# the AQL optimization probe
cnf-runner aql-probe --root tools/cnf-runner/artifacts \
--ixit tools/cnf-runner/party/ferroehr/ixit.json \
--out docs/conformance/ferroehr/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>/; the published charts regenerate from them
(scripts/render-perf-assets.sh, scripts/render-comparison.sh) 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 ladder, each on its own freshly composed stack with its own committed party statement; payload skeletons are byte-identical; database maintenance is settled deterministically on both sides before every measured window; configuration parity is explicit (connection pools raised in lockstep; version signing — a FerroEHR extension upstream does not perform — disabled for throughput comparisons and labeled). Both directions publish on equal footing: where upstream sustains more, its curve says so exactly like the reverse — see Comparison.
Comparison with upstream EHRbase
FerroEHR is measured against upstream EHRbase (Java) — the project it succeeds — on the same instruments it applies to itself: the CNF 2.0 conformance runner executes the same committed catalogue against both servers’ official deployments, and the benchmark harness drives both with byte-identical clinical workloads on the same host. Both directions are always published; a result that favours upstream is reported exactly like one that favours us.
Each side runs with its own committed party set — an ixit describing the reachable instances (upstream’s Basic auth has no read-only principal, so its ixit declares none) and a statement (the ICS) declaring the capabilities and ambiguity-register options that party actually claims. A capability a party does not claim reads not claimed and never gates its verdicts; a test whose ground cannot exist on a party’s topology or technology profile reads not applicable with a machine citation, never fail. That is how the comparison stays fair without ever weakening a case.
Every number and every curve on this page is generated at build time from
the committed run artifacts (docs/conformance/*/results.json +
verdicts.json + stress.json) — nothing here is hand-typed, and the CI
stale-numbers gate rejects any attempt to hand-type it. To reproduce either
side yourself, see
Conformance and
Benchmarks.
- Conformance
- Systems under test
- Methodology
- Profile verdicts
- In-scope outcomes
- Capability-by-capability
- Failures — both directions
- Performance
- Method, in one paragraph
Conformance
Systems under test
| ferroehr | upstream (Java) | |
|---|---|---|
| Product | ferroehr 3.15.0 | ehrbase-java 2.34.0 |
| Run date | 2026-07-31 | 2026-07-31 |
| Party statement | tools/cnf-runner/party/ferroehr/ | tools/cnf-runner/party/ehrbase-java/ |
| Stack | root compose, built from the current sources | docker/sut-ehrbase-java.yml (official images) |
Methodology
Both systems execute the same committed CNF 2.0 catalogue (863 case-by-format
executions) through the same reference runner (tools/cnf-runner), each on
fresh volumes with its own committed party set: the ixit names the reachable
instances (upstream 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 upstream 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 upstream never
claimed. The verdict-bearing comparison below is therefore each party’s
in-scope subset, never the raw record.
Profile verdicts
| Profile | ferroehr | upstream (Java) |
|---|---|---|
| CORE | pass | fail |
| STANDARD | pass | fail |
| OPTIONS | pass | not claimed |
| SEC-BASIC | pass | not claimed |
In-scope outcomes
Runs compared: ferroehr (run of 2026-07-31) vs upstream EHRbase 2.34.0 (run of 2026-07-31) — the SAME catalogue through the same runner, each with its own committed party statement. Per the presentation rule, the headline is each party’s VERDICT SCOPE (the cases its own declarations select), never the raw record: a raw count would book release-dated and unclaimed surfaces against a party that never claimed them.
| verdict scope (selected) | driven | in-scope passed | in-scope failed | in-scope inconclusive | |
|---|---|---|---|---|---|
| ferroehr | 863 | 826 | 826 | 0 | 0 |
| upstream (Java) | 499 | 459 | 136 | 132 | 191 |
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).
| Capability | ferroehr | upstream (Java) |
|---|---|---|
| ActivityReport | passed | not_evidenced |
| Adl14ArchetypeProvisioning | passed | not_evidenced |
| Adl14OptProvisioning | passed | failed |
| Adl2ArchetypeProvisioning | passed | not_evidenced |
| Adl2OptProvisioning | passed | not_evidenced |
| AdminApi | passed | not_evidenced |
| AnonymousEhrs | passed | not_evidenced |
| AqlAdvanced | passed | inconclusive |
| AqlBasic | passed | failed |
| AqlTerminology | passed | not_evidenced |
| ArchetypeValidation | passed | failed |
| AuditAccountability | passed | not_evidenced |
| AuthenticatedAccess | passed | passed |
| AuthorizationSeparation | passed | not_evidenced |
| BulkEhrLoad | passed | not_evidenced |
| ChangeSets | passed | failed |
| CompositionOps | passed | inconclusive |
| DefinitionApi | passed | failed |
| DemographicApi | passed | not_evidenced |
| DemographicArchetypeValidation | passed | not_evidenced |
| DemographicArchive | passed | not_evidenced |
| DirectoryOps | passed | failed |
| EhrApi | passed | failed |
| EhrArchive | passed | not_evidenced |
| EhrDemographicSeparation | passed | passed |
| EhrDumpLoad | passed | not_evidenced |
| EhrExtract | passed | not_evidenced |
| EhrOperations | passed | failed |
| EhrStatus | passed | failed |
| ItemTags | passed | not_evidenced |
| MessageApi | passed | not_evidenced |
| PartyOperations | passed | not_evidenced |
| PartyRelationshipOperations | passed | not_evidenced |
| PhysicalDeletion | passed | not_evidenced |
| QueryApi | passed | failed |
| QueryProvisioning | passed | failed |
| Signing | passed | not_evidenced |
| SimplifiedFormats | passed | not_evidenced |
| SmartAppLaunch | passed | not_evidenced |
| SystemApi | passed | not_evidenced |
| Tds | passed | not_evidenced |
| TemplateExamples | passed | not_evidenced |
| Versioning | passed | failed |
Failures — both directions
ferroehr failures (with the upstream outcome on the identical case)
| Case | Format | Failure | upstream outcome |
|---|---|---|---|
| — | — | none — zero failing cases | — |
Upstream failures by schedule chapter
| Chapter | failed cases |
|---|---|
| CONT | 67 |
| I_EHR_STATUS | 24 |
| I_EHR_DIRECTORY | 12 |
| I_DEFINITION_QUERY | 10 |
| I_DEFINITION_ADL2 | 7 |
| I_EHR_CONTRIBUTION | 7 |
| I_DEFINITION_ADL14 | 6 |
| I_EHR_SERVICE | 5 |
| I_QUERY_SERVICE | 3 |
| I_EHR_COMPOSITION | 1 |
| I_ITS_REST_REVISION_HISTORY | 1 |
| SIG | 1 |
Every upstream-failed case, with the ferroehr outcome on the identical case
| Case | Format | Upstream failure | ferroehr outcome |
|---|---|---|---|
| CONT-COMP-content_card_1plus-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_1plus-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3plus-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3plus-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3to5-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_3to5-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_any-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_any-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_mand-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_mand-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_opt-context_any | — | expected created, observed validation_failed | passed |
| CONT-COMP-content_card_opt-context_mand | — | expected created, observed validation_failed | passed |
| CONT-COMPOSITION-content_cardinality_count6 | — | expected created, observed validation_failed | passed |
| CONT-COMPOSITION-context_existence | — | expected created, observed validation_failed | passed |
| CONT-DV_CODED_TEXT-validate_open | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE_TIME-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_DATE_TIME-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_DURATION-validate_fields | — | expected created, observed validation_failed | passed |
| CONT-DV_DURATION-validate_fields_range | — | expected created, observed validation_failed | passed |
| CONT-DV_DURATION-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_IDENTIFIER-validate_all_list | — | expected created, observed validation_failed | passed |
| CONT-DV_IDENTIFIER-validate_all_pattern | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DURATION-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_DURATION-validate_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_ORDINAL-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_PROPORTION-validate_ratio_range | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_SCALE-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_range | — | expected created, observed validation_failed | passed |
| CONT-DV_MULTIMEDIA-validate_media_type | — | expected created, observed validation_failed | passed |
| CONT-DV_PARSABLE-validate_value_formalism | — | expected created, observed validation_failed | passed |
| CONT-DV_TEXT-validate_open | — | expected created, observed validation_failed | passed |
| CONT-DV_TIME-validate_constraint | — | expected created, observed validation_failed | passed |
| CONT-DV_TIME-validate_range | — | expected created, observed validation_failed | passed |
| CONT-EVENT-state_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-EVENT-state_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-EVENT-type_any | — | expected created, observed validation_failed | passed |
| CONT-EVENT-type_interval_event | — | expected created, observed validation_failed | passed |
| CONT-EVENT-type_point_event | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_1plus-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_1plus-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3plus-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3plus-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3to5-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_3to5-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_any-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_any-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_mand-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_mand-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_opt-summary_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-HIST-events_card_opt-summary_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-HISTORY-events_cardinality_count6 | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_any | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_list | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_single | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_table | — | expected created, observed validation_failed | passed |
| CONT-ITEM_STR-type_item_tree | — | expected created, observed validation_failed | passed |
| CONT-OBS-state_ex_mand-protocol_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-OBS-state_ex_mand-protocol_ex_opt | — | expected created, observed validation_failed | passed |
| CONT-OBS-state_ex_opt-protocol_ex_mand | — | expected created, observed validation_failed | passed |
| CONT-OBS-state_ex_opt-protocol_ex_opt | — | expected created, observed validation_failed | passed |
| I_DEFINITION_ADL14.upload_opt-invalid_opt | — | expected validation_failed, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-valid_opt | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-valid_opt_twice_conflict | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.upload_opt-valid_opt_twice_no_conflict | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL14.validate_opt-invalid_opt | — | expected validation_failed, observed not_acceptable | passed |
| I_DEFINITION_ADL14.validate_opt-valid_opt | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL2.get_artefact-example_unknown | — | expected not_found, observed not_acceptable | passed |
| I_DEFINITION_ADL2.get_artefact-version_prefix | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL2.upload_artefact-duplicate_conflict | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL2.upload_artefact-invalid_artefacts | — | expected validation_failed, observed not_acceptable | passed |
| I_DEFINITION_ADL2.upload_artefact-valid_opt | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_ADL2.valid_artefact-invalid | — | expected validation_failed, observed not_acceptable | passed |
| I_DEFINITION_ADL2.valid_artefact-valid | — | expected created, observed not_acceptable | passed |
| I_DEFINITION_QUERY.list_queries-prefix_all_versions | — | [0]/name: path resolves to nothing | passed |
| I_DEFINITION_QUERY.list_queries-version_get_xml_not_acceptable | — | expected not_acceptable, observed ok | passed |
| I_DEFINITION_QUERY.list_queries-xml_not_acceptable | — | expected not_acceptable, observed ok | passed |
| I_DEFINITION_QUERY.store_query-default_slot_with_higher_version | — | header Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/org | passed |
| I_DEFINITION_QUERY.store_query-dotted_name | — | expected stored, observed bad_request | passed |
| I_DEFINITION_QUERY.store_query-unqualified_name | — | expected stored, observed bad_request | passed |
| I_DEFINITION_QUERY.store_query-update_in_place | — | header Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/org | passed |
| I_DEFINITION_QUERY.store_query-version_duplicate_case_variant_name | — | expected conflict, observed stored | passed |
| I_DEFINITION_QUERY.store_query-version_prefix_rejected | — | expected bad_request, observed stored | passed |
| I_DEFINITION_QUERY.store_query-version_prerelease_rejected | — | expected bad_request, observed stored | passed |
| I_EHR_COMPOSITION.get_versioned_composition-malformed_uid | — | expected bad_request, observed not_found | passed |
| I_EHR_CONTRIBUTION.commit_contribution-delete_directory | — | expected created, observed not_found | passed |
| I_EHR_CONTRIBUTION.commit_contribution-deleted_member_with_data | — | expected validation_failed, observed created | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_incomplete_lifecycle | — | expected validation_failed, observed created | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_type | — | expected conflict, observed validation_failed | passed |
| I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_type_deleted | — | expected conflict, observed not_found | passed |
| I_EHR_CONTRIBUTION.commit_contribution-fail_modify_non_existing_directory | — | expected validation_failed, observed precondition_failed | passed |
| I_EHR_CONTRIBUTION.commit_contribution-non_exiting_opt | — | expected template_not_found, observed validation_failed | passed |
| I_EHR_DIRECTORY.create_directory-ehr_not_modifiable | — | expected updated, observed precondition_missing | passed |
| I_EHR_DIRECTORY.delete_directory-ehr_with_directory | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.delete_directory-empty_ehr | — | expected not_found, observed precondition_failed | passed |
| I_EHR_DIRECTORY.delete_directory-etag_names_new_version | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.get_directory-deleted_head | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.get_directory-directory_with_structure | — | equivalent: retrieved content differs from committed (modulo the normative ignore-set); $/ | passed |
| I_EHR_DIRECTORY.get_directory_at_time-deleted_at_time | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.get_directory_at_version-deleted_version | — | header Last-Modified: expected present, got none | passed |
| I_EHR_DIRECTORY.update_directory-empty_ehr | — | expected not_found, observed precondition_failed | passed |
| I_EHR_DIRECTORY.update_directory-invalid_folder | — | expected validation_failed, observed precondition_missing | passed |
| I_EHR_DIRECTORY.update_directory-stale_if_match | — | header ETag: expected the latest version uid, got none | passed |
| I_EHR_DIRECTORY.update_directory-xml | canonical-xml | expected updated, observed precondition_missing | — |
| I_EHR_SERVICE.create_ehr-bulk_load_population | — | expected created, observed validation_failed | passed |
| I_EHR_SERVICE.create_ehr-committal_headers | — | commit_audit/description/value: path resolves to nothing | passed |
| I_EHR_SERVICE.create_ehr-invalid_status | — | expected validation_failed, observed created | passed |
| I_EHR_SERVICE.create_ehr-wrong_method | — | header Allow: expected a value matching “.*(GET.*POST|POST.GET).”, got none | passed |
| I_EHR_SERVICE.get_ehr-malformed_ehr_id | — | expected bad_request, observed not_found | passed |
| I_EHR_STATUS.clear_ehr_modifiable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_modifiable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_modifiable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_queryable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_queryable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.clear_ehr_queryable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_ehr_status-at_time_future | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_ehr_status-at_time_omitted | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_ehr_status_at_version-addressed_version | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_versioned_ehr_status-at_time_future | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_versioned_ehr_status-at_time_omitted | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.get_versioned_ehr_status-contained_uid_form | — | header Last-Modified: expected present, got none | passed |
| I_EHR_STATUS.get_versioned_ehr_status-container_shape | — | owner_id/type: “ehr” != expected “EHR” | passed |
| I_EHR_STATUS.get_versioned_ehr_status-xml | canonical-xml | header Last-Modified: expected present, got none | passed |
| I_EHR_STATUS.set_ehr_modifiable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-missing_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_modifiable-xml_body | canonical-xml | expected updated, observed precondition_missing | — |
| I_EHR_STATUS.set_ehr_queryable-bad_ehr | — | expected not_found, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-existing_ehr | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-missing_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-stale_if_match | — | expected updated, observed precondition_missing | passed |
| I_EHR_STATUS.set_ehr_queryable-xml_body | canonical-xml | expected updated, observed precondition_missing | — |
| I_ITS_REST_REVISION_HISTORY.versioned_ehr_status_revision_history-two_versions | canonical-json | expected updated, observed precondition_missing | passed |
| I_QUERY_SERVICE.execute_ad_hoc_query-empty_db_bare_ehr | — | row count 100 != expected 1 | passed |
| I_QUERY_SERVICE.execute_ad_hoc_query-unknown_ehr_scope | — | expected not_found, observed ok | passed |
| I_QUERY_SERVICE.execute_stored_query-fetch_with_top | — | expected stored, observed bad_request | passed |
| SIG-VERSION-ehr_status_signature | — | signature: expected present, the ORIGINAL_VERSION envelope carries no signature | passed |
Both servers’ capability conformance, from each party’s committed verdicts (generated, diff-guarded — see Conformance for how to read the grid):
And the per-chapter outcomes side by side. Both charts render the same
chapter-and-band taxonomy, so they read band-for-band: a band upstream 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.
Reading the upstream results honestly: the failures concentrate where the catalogue pins strict specification behaviour — archetype-constraint validation depth (the content chapter), exact status codes and version headers, canonical-format details — that upstream implements differently or predates. An inconclusive row means upstream answered with a status the operation’s specification-cited outcome map does not contain, or refused the exchange that would have established the case’s required ground, so the runner refuses to guess a verdict. And where upstream simply does not implement a surface (the ITS-REST simplified-format media types, ADL 2 provisioning, demographics), or where the specification dates a behaviour to a REST-API release newer than the one upstream declares (upstream declares ITS-REST 1.0.3; the catalogue realizes 1.1.0), the result reads not claimed or N/A with a citation — upstream is never counted as failing a surface it never claimed, never against a release it never declared, and never on an ferroehr-only extension.
The principal upstream divergences, stated plainly
Each of these was reproduced live against the composed upstream stack during
triage of the committed record, and each is grounded at or below upstream’s
own declared ITS-REST 1.0.3 unless marked; the full wire evidence lives in
the committed docs/conformance/ehrbase-java/results.json.
- A quoted
If-Matchvalue is rejected (400 "UUID string too large") — including the server’s own echoedETag— while only the non-standard unquoted form is accepted. The quoted form dates to Release 1.0.2. - Semantic model violations answer
400instead of422(a Release 1.0.1 correction), and some model-invalid documents are accepted outright — anEHR_STATUSwithout its mandatory archetype details commits as201. - The
openehr-audit-detailscommittal header is ignored (both the current and the deprecated spelling), and the stored audit description is itself model-invalid (aDV_TEXTwith novalue). - Canonical XML is served with the root element in no namespace, against
the published XSD’s qualified target namespace; the stored-query list even
serves an XML
<List/>document that conforms to no published schema on a JSON-only operation. - Unqualified stored-query names are rejected although the specification
makes the namespace optional and lists
my_compositionsas a valid example; a staleIf-Matchon a directory delete answers404where the specification requires412; and405responses omit the requiredAllowheader. - The stored-query listing does not match by prefix. The specification’s
own worked example lists “all versions of all queries with names starting
with
org.openehr”; upstream returns the versions of an exactly-named query but answers200 []for any shorter prefix, so a client cannot discover what a namespace holds. - A malformed identifier in the path answers
404, not400. Given a path segment that is not a UUID at all, upstream detects the type violation and still reports a miss — literally"EHR not found, in fact, only UUID-type IDs are supported"— for the EHR, versioned-COMPOSITION and CONTRIBUTION reads alike. (It does answer400for a malformed version identifier, so the behaviour is not even internally consistent.) - Directory writes against an EHR that has no directory answer
412. Both the update and the delete reportPrecondition Failedwith the body “does not contain a directory” — a missing resource dressed as a failed precondition. HTTP requires the opposite order: a failure detectable before the precondition is evaluated takes precedence over evaluating it. - A contribution refusal surfaces as a raw
500. Committing a first version whose change type isdeletedreturns500 "An internal error has occurred", where the specification assigns that family a400(“the modification type does not match the operation”). The neighbouring row is worse than an error: a creation whose lifecycle state isdeletedis accepted (204) instead of refused. - (1.1.0-grounded) The template upload refuses
Accept: application/json(406), serving only XML — the released parameter enumeration lists JSON first. This single refusal is what makes most content-chapter rows inconclusive: the runner’s provisioning uploads ask for JSON, upstream refuses, and the case’s ground never exists.
Two red rows are not upstream’s fault, and are called out rather than
counted. Storing a query with no version in the URL has to land at some
version, and no released sentence says which. Our suite pins 1.0.0 because
a suite must pin something; upstream continues the existing series instead
(a stored 2.0.0 makes the next version-less store 3.0.0). Both are
defensible readings of a silence, so the two
store_query-{default_slot_with_higher_version,update_in_place} rows record
a difference of house convention, not a conformance defect — the open
question is with openEHR, not with either implementation.
Performance
Both systems run the same committed step-load stress instrument
(cnf-runner stress) on their own freshly seeded cnf.scale.10k
corpus: the geometric ladder climbs until the system leaves the envelope
(p99 over the budget or errors past tolerance), then bisects to the
maximum sustainable throughput. Every number derives from the two
committed stress.json reports; each load step embeds re-checkable
histograms and, where sampled, per-container resource telemetry.
| max sustainable throughput | worst p99 at the knee | DB peak CPU at the knee | SUT peak RSS at the knee | |
|---|---|---|---|---|
| ferroehr | 512 req/s | 134 ms | 101 % | 247 MB |
| upstream (Java) | 0 req/s | — ms | — % | — MB |
A stress report is exploration evidence: it earns no conformance class (classes are earned exclusively by the hour-long measured class runs) and carries no class vocabulary — the chart shows where each system breaks.
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-java for the upstream 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; latencies are coordinated-omission-corrected from
each request’s planned arrival instant, and the ladder bisects to the last
rate held inside the envelope. The full method chapters:
Conformance · Benchmarks.
Contributing
FerroEHR is open source (MIT; vendored openEHR artifacts remain Apache-2.0) 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.
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, so
rustup installs the right version automatically on your first build. Two
extra tools are needed for the full test suite:
- Docker, for the PostgreSQL 18 integration tests (they spin up a real database via testcontainers).
xmllint(fromlibxml2), used by the canonical-XML parity tests.
Install the shared git hooks once with bash scripts/install-hooks.sh.
The checks every pull request must pass
CI runs the same set of gates locally and on every pull request — none of them are advisory:
cargo build --workspace
cargo nextest run --workspace # unit + integration (real PostgreSQL 18)
cargo test --workspace --doc
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all --check
cargo deny check && cargo audit && cargo machete
bash scripts/check-codegen-drift.sh # generated layer matches the vendored specs
Important
Two rules are absolute. Never hand-edit a generated file — anything under a
// @generated … DO NOT EDITheader is produced by the code generator; change the generator and regenerate instead. And never weaken, skip, or delete a test to make a build pass, or edit a test to route around a bug it exposes.
A few more conventions worth knowing before you open a pull request:
- Branch from
develop, and target your pull request atdevelop. - 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 in the same pull request — a CI guard enforces this.
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.