Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

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

What openEHR gives you

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

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

What makes this implementation different

  • Compliance you can 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

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 / ferroehr and 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:

ImageContents
ghcr.io/rubentalstra/ferroehrThe ferroehr server binary. A distroless, non-root, shell-less multi-arch image (amd64 + arm64). Configured entirely via FERROEHR_* environment variables.
ghcr.io/rubentalstra/ferroehr-postgrespostgres: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) or roles absent, your volume predates the image’s role setup (or you are running a plain postgres image): either recreate the volume (docker compose down -vdestroys 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 a pg_isready healthcheck.
  • ferroehr — the server, which waits for the database to report healthy (depends_on: condition: service_healthy), then boots, migrates, and serves on port 8080. Its healthcheck is the binary’s own healthcheck subcommand (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:

VariableDefaultEffect
FERROEHR_IMAGEghcr.io/rubentalstra/ferroehr:localServer image to run (set to a published tag to skip the build).
FERROEHR_POSTGRES_IMAGEghcr.io/rubentalstra/ferroehr-postgres:localDatabase image to run.
FERROEHR_PORT8080Host port mapped to the server.
FERROEHR_DB_PORT5432Host port mapped to PostgreSQL.
PG_INIT_USER / PG_INIT_PASSWORD / PG_INIT_DBferroehrApp role, password, and database created by the DB image’s init script.
POSTGRES_PASSWORDpostgresBootstrap superuser password (init only).
FERROEHR__LOG__FORMATprettyLog rendering for docker compose logs. Set json for log collectors.

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 large DV_MULTIMEDIA externalization (development/test only). Start it with docker compose --profile s3 up, then set FERROEHR__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 preloaded ferroehr realm, on port 8081. Start it with docker compose --profile keycloak up; to use bearer auth instead of Basic, point the auth OIDC settings at http://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 up
    

    The overlay switches on the [terminology.external] providers that docker/ferroehr.dev.toml already 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

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:

RolePurpose
ownerowns the database (provisioning only)
ferroehr_migratorruns the append-only schema migrations
ferroehr_appday-to-day reads and writes — the running pod connects as this
ferroehr_readerread-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-privileged ferroehr_app DSN — 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:

FieldValue
runAsNonRoottrue (uid/gid 65532, the distroless nonroot user)
readOnlyRootFilesystemtrue (a writable emptyDir is mounted at /tmp)
allowPrivilegeEscalationfalse
capabilities.drop[ALL]
seccompProfile.typeRuntimeDefault (pod and container)
ServiceAccount tokennot mounted (the workload never calls the K8s API)
NetworkPolicydefault-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:

ProbeRouteContract
liveness/health/liveness200 while the process is up; touches no dependency
readiness/health/readiness200 (UP/DEGRADED) or 503 (DOWN): checks DB ping, migrations applied, audit sender, events — each 1s-bounded
startup/health/livenessgates 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:

IntegrationValues keyNotes
ADMIN APIrest.adminEnabledPhysical, irreversible delete. Gate behind admin RBAC.
Terminology extension APIrest.terminologyEnabled404 when off.
Event-subscription APIrest.eventSubscriptionEnabledAdmin CRUD over event filters.
Multi-tenancytenancy.enabledTenant from a JWT claim; leave tenancy.header unset in prod. Pairs with PG row-level security.
OAuth2/OIDC authauth.oidc.*Prefer JWKS/discovery over an HS256 secret.
ABACauthz.abac.enabledCedar or a remote policy decision point; policies via a mounted TOML.
Eventing → AMQPevents.enabledEnvelopes are PHI-free by design. Use amqps:///tls=true.
FHIR inbound/façaderest.fhirEnabledRead façade + inbound mapping.
FHIR outbound → AMQPfhirOutbound.enabledCarries PHI (the mapped FHIR resource). Separate exchange; TLS broker only.
S3 multimediamultimedia.enabled⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket.
External terminologyexternalTerminology.enabledFHIR terminology server; provider map via a mounted TOML.
ATNA system logaudit.enabledUse audit.syslog.transport: tls for PHI-adjacent audit.
Version signingsigning.*On by default (digest). pgp mode fails closed at boot without a usable key.
OTLP telemetrytelemetry.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, so rustup installs and selects it automatically the first time you build in the checkout — you do not choose a version by hand.
  • Docker — required only for the integration tests, which spin up a real PostgreSQL 18 in a container.
  • xmllint — required only for the canonical-XML tests.

Building

From the repository root:

cargo build --workspace

To build just the server binary in release mode (what the container image ships):

cargo build --release --locked -p ferroehr

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

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:

  1. Built-in defaults — the values in the tables below.
  2. The config fileferroehr.toml (see file discovery).
  3. FERROEHR_* environment variables — override individual keys.
  4. --set key=value CLI flags (repeatable) — win over everything.

Two permanent conventional aliases sit below their FERROEHR_ forms within layer 3: DATABASE_URLdb.url and RUST_LOGlog.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.

TOMLEnvironment variable
[db] max_connections = 20FERROEHR__DB__MAX_CONNECTIONS=20
[auth.oidc] issuer = "…"FERROEHR__AUTH__OIDC__ISSUER=…
[management.endpoints] env = "off"FERROEHR__MANAGEMENT__ENDPOINTS__ENV=off
[terminology.external.providers.default] url = "…"FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__DEFAULT__URL=…

Scalars are typed automatically (bool / int / float, else string). List-typed keys take comma-separated values (FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr,other). Arrays of tables — the Basic-auth user store — are file-only.

Note

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

File discovery

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

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

An explicitly pointed-at file (1–2) is fatal if absent; the search-order files (3–4) are simply skipped when absent (but fatal if present and 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 the FERROEHR_ 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"
KeyTypeDefaultDescription
bindstring0.0.0.0:8080Socket address the API listener binds.
base_pathstring/ferroehr/rest/openehr/v1ITS-REST base path all API routes hang off. 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_flightint256Concurrent-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_uibooltrueServe Swagger UI + the OpenAPI JSON at the REST root. Consider false in production.
cors_permissiveboolfalsePermissive (development) CORS. Production configures explicit origins.
system_idstringferroehr.localThis 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 the EHR.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_id on every commit for which the client did not supply one through the openehr-audit-details header. The openEHR REST API requires that “when system_id is not provided by the client, the server MUST set it to its own configured system identifier”.
  • OBJECT_VERSION_ID.creating_system_id — the middle segment of every version identifier the server mints (<object_id>::<creating_system_id>::<version>).

Practical notes:

  • 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 own system_id takes 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 the OBJECT_VERSION_ID field separator, so it would make version identifiers unparseable.
  • system_id is not [server.identity]. system_id says which system authored the data; [server.identity] below is the display identity of the OPTIONS System-Options manifest (product, version, vendor, advertised profile). Rebranding changes the manifest and nothing in stored data; changing system_id changes what new data says about its origin and leaves the manifest alone. 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.

KeyTypeDefaultDescription
enabledboolfalseTerminate TLS natively on the main listener.
cert_filepathunsetServer certificate chain (PEM). Required when enabled.
key_filepathunsetServer private key (PEM). Required when enabled.
client_authenum{off,optional,required}offClient-certificate policy: required rejects any client without a verified certificate at the handshake.
client_ca_filepathunsetThe explicit CA bundle client certificates must chain to (never the web PKI). Required unless client_auth = "off".

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

KeyTypeDefaultDescription
solutionstringFerroEHRProduct name.
solution_versionstringbuild versionProduct version.
vendorstringFerroEHR projectProviding organisation.
restapi_specs_versionstringtested-contract identityopenEHR REST API edition the build is tested against.
conformance_profilestringlast CNF verdictAdvertised conformance profile.

[db]

PostgreSQL connection.

[db]
url = "postgres://ferroehr:ferroehr@localhost:5432/ferroehr"
max_connections = 20
min_connections = 2
acquire_timeout_secs = 30
KeyTypeDefaultDescription
urlsecret URLpostgres://ferroehr:ferroehr@localhost:5432/ferroehrConnection 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_connectionsint20Pool ceiling. Write-heavy deployments benefit from 50+.
min_connectionsint2Idle connections kept open (avoids cold-reopen churn).
acquire_timeout_secsint30Seconds to wait for a free connection before failing.

[log]

[log]
format = "auto"
filter = "info,ferroehr=info"
KeyTypeDefaultDescription
formatenum{auto,json,pretty}autoStdout rendering; auto picks json when stdout is not a TTY (and suppresses the boot banner).
filterstringinfo,ferroehr=infoBoot 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).

KeyTypeDefaultDescription
otlp_endpointstringunsetOTLP/gRPC collector endpoint.
service_namestringferroehrservice.name resource attribute.
environmentstringdevdeployment.environment resource attribute.
traces_sample_ratiofloat1.0Head-sampling ratio (0.1 is a common prod start).
metrics_pushboolfalseAlso 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"]
KeyTypeDefaultDescription
enabledbooltrueMaster switch. false = all requests pass unauthenticated (dev only). With true and no mechanism configured, every API request 401s (fail-closed).
verified_cache_ttl_secondsint60Verified 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):

KeyTypeDefaultDescription
usernamestringrequiredPrincipal name.
password_hashsecretrequiredArgon2 PHC hash ($argon2id$v=19$…), never a plaintext password.
roleslist of string["USER"]Roles granted (upper-cased on authentication).

[auth.oidc] — bearer validation (absent table ⇒ bearer disabled):

KeyTypeDefaultDescription
issuerstringrequired when presentExpected iss; also the OIDC discovery base.
audienceslist of string[]Accepted aud (empty = not checked).
algorithmslist of string["RS256"]Accepted signature algorithms.
hmac_secret / hmac_secret_filesecret / pathunsetSymmetric HS256 secret (dev/test). At most one of the pair.
jwks_json / jwks_json_filestring / pathunsetStatic JWKS document; preferred over discovery when present.

[authz]

RBAC + ABAC.

[authz.rbac]:

KeyTypeDefaultDescription
enabledbooltrueCoarse role gate (active when auth is enabled).
admin_rolestringADMINRole required for admin-class operations.
user_rolestringUSERBaseline clinical role.
readonly_rolestringREADONLYRole marking a principal read-only: refused on every write operation (create/update/delete/upload), even alongside granting roles. Reads and AQL queries are still allowed.
role_claimslist of string["realm_access.roles","scope"]JWT claim paths mined for roles.
management_accessenum{admin_only,private,public}admin_onlyAccess level for the management surface.

[authz.abac]:

KeyTypeDefaultDescription
enabledboolfalseMaster ABAC switch.
engineenum{cedar,remote}cedarEmbedded Cedar or a remote decision point.
organization_claimstringorganization_idJWT claim carrying the caller’s organization.
patient_claimstringpatient_idJWT claim carrying the patient id.

[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]

KeyTypeDefaultDescription
enabledboolfalseMount 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.

KeyTypeDefaultDescription
enabledboolfalseActivate tenant middleware + row-level scoping.
claimstringtenantJWT-claim path carrying the tenant key.
headerstringunsetDev-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"
KeyTypeDefaultDescription
enabledboolfalseMount the management router.
base_pathstring/managementBase path for the management endpoints.
portintunset ⇒ share the main listenerServe management on its own listener/port. Must differ from the server.bind port.
access_defaultenum{off,admin_only,private,public}admin_onlyGlobal 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_enabled and endpoints.health were 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.

KeyTypeDefaultDescription
enabledbooltrueServer-side signing of committed versions.
modeenum{digest,pgp}digestSHA-256 integrity digest, or an OpenPGP (RFC 4880) detached signature.
key_pathpathunsetArmored secret key; required for pgp.
key_passphrase / key_passphrase_filesecret / pathunsetKey passphrase.
verify_on_readenum{off,warn,strict}strict when signing is enabledRead-time recompute-and-compare policy for the server’s own signatures.

Note

verify_on_read defaults to strict when signing is enabled. On every read the server recomputes the signature of a version it signed and, on a mismatch, returns a 500 integrity fault rather than silently serving a provably corrupt record. Set it explicitly to warn (log + emit version_signature_invalid_total, still serve) or off (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

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

[query]

AQL execution knobs.

KeyTypeDefaultDescription
plan_cache_capacityint256Max distinct cached query plans; 0 disables the cache. Cache activity is reported by the aql_plan_cache_events_total metric.
timeout_msint0Per-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.

KeyTypeDefaultDescription
enabledboolfalseSpawn the outbox publisher (with fhir.outbound.enabled, gates the per-commit outbox INSERT).
urlsecret URLamqp://guest:guest@localhost:5672/%2fAMQP broker URL; credentials redacted from every rendering.
exchangestringferroehr.eventsTopic exchange (PHI-free envelope stream).
tlsboolfalseUpgrade amqp:// to amqps://.
batch_sizeint128Rows drained per poll.
poll_interval_msint1000Idle poll interval.
retention_daysint7Published-row retention window.
prune_interval_secsint3600Retention-prune cadence.
publish_max_retriesint3Per-row publish retries before backing off.
admin_apiboolfalseMount the /admin/event_subscription CRUD routes.

[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>]:

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

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

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

Important

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

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

Kubernetes deployments mount the PEM files with the chart’s config.files map (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 → 422 naming the path, the code, and the bound query. This is a real constraint violation, so fail_on_error does not change it.
  • The value set could not be resolved (server down, error response, no provider routes to that terminology) → fail_on_error decides: false (default) accepts the commit and logs a warning; true rejects it with 422.

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_id is sent verbatim as the FHIR system parameter. If your archetypes use SNOMED-CT where your terminology server expects http://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).

KeyTypeDefaultDescription
enabledboolfalseExternalize large multimedia data.
threshold_bytesint262144 (256 KiB)Decoded size strictly above which data is offloaded.
endpointstringunset ⇒ AWS defaultS3-compatible endpoint.
bucketstringopenehr-multimediaTarget bucket.
regionstringus-east-1AWS region (required even for non-AWS endpoints).
access_key_idstringunsetS3 access key id (unset + no secret = anonymous).
secret_access_key / secret_access_key_filesecret / pathunsetS3 secret access key.
allow_httpboolfalseAllow 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.)

KeyTypeDefaultDescription
enabledbooltrueMaster audit switch.
enterprise_site_idstringunsetAuditEnterpriseSiteID.
source_idstringferroehrAudit source id.
value_if_missingstringUNKNOWNFill value for empty mandatory fields.
suppress_login_eventsbooltrueSkip successful-login records (rejections are always recorded).
fail_modeenum{open,closed}openOn undeliverable audit: succeed and meter (open) or reject auditable operations with 503 (closed — includes an unhealthy local store).
resolve_subjectbooltrueEnrich the patient participant via a background subject lookup.
queue_capacityint8192Bounded audit queue capacity (sized for write-path bursts; the drain persists in multi-row batches).
server_hoststringunsetThis node’s advertised address (NetworkAccessPointID).

[audit.store] — the local Audit Record Repository

KeyTypeDefaultDescription
enabledbooltruePersist every record in the audit schema (served via the ITI-81 GET /fhir/r4/AuditEvent search).
retention_daysint0Days to keep records; 0 = keep forever. Applied hourly.

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

KeyTypeDefaultDescription
enabledboolfalseShip DICOM PS3.15 records to an external ARR over syslog.
hoststringlocalhostARR host.
portint514ARR port (514 UDP / 6514 TLS typical).
transportenum{udp,tls}udpSyslog transport. Use tls for PHI-adjacent audit.
tls_ca_file / tls_identity_cert_file / tls_identity_key_filepathunsetPEM CA / client cert / client key for the TLS transport.

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

KeyTypeDefaultDescription
enabledboolfalsePOST each FHIR AuditEvent to an external FHIR ARR. Outbox-driven (loss-free) when the local store is on.
urlurlhttp://localhost:8080/fhirThe ARR’s FHIR base; records go to {url}/AuditEvent. URL credentials are redacted from every rendering.
batch_sizeint64Outbox rows shipped per poll.
poll_interval_msint2000Outbox poll interval when idle.
max_retriesint3Per-record POST retries before the record is left pending (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

VariableTypeDefaultDescription
FERROEHR_HEALTHCHECK_URLURLhttp://127.0.0.1:8080/ferroehr/rest/statusTarget 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, via FERROEHR__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_permissive stays false; server.swagger_ui per posture.
  • server.system_id — this deployment’s own openEHR system identifier (default ferroehr.local). Choose it before the first EHR is created: it is stored with every EHR, audit entry, and version identifier.
  • management.* per posture (a dedicated port is recommended so /management is never reachable on the clinical listener).
  • TLS everywhere a transport supports itaudit.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 variableNew key (env form)Fate
FERROEHR_DB_URLdb.url (FERROEHR__DB__URL)boot error — use the new spelling
DATABASE_URLdb.urlkept permanently
FERROEHR_DB_MAX_CONNECTIONS / _MIN_CONNECTIONS / _ACQUIRE_TIMEOUT_SECSdb.* (FERROEHR__DB__*)boot error — use the new spelling
FERROEHR_LOG_FORMAT / FERROEHR_LOG_FILTERlog.* (FERROEHR__LOG__*)boot error — use the new spelling
RUST_LOGlog.filterkept permanently
FERROEHR_OTEL_*telemetry.*boot error — use the new spelling
FERROEHR_REST_CONFIG--config / FERROEHR_CONFIG + ferroehr.tomlremoved — merge the file into ferroehr.toml
FERROEHR_REST_BIND / _BASE_PATH / _SWAGGER_UI / _CORS_PERMISSIVEserver.* (FERROEHR__SERVER__*)boot error — use the new spelling
FERROEHR_REST_MAX_IN_FLIGHTserver.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_SECONDSauth.* (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__ENABLEDadmin.enabledboot error — use the new spelling
FERROEHR_REST_TENANCY__*tenancy.*boot error — use the new spelling
FERROEHR_REST_TERMINOLOGY__ENABLEDterminology.api_enabledboot error — use the new spelling
FERROEHR_REST_EVENT_SUBSCRIPTION__ENABLEDevents.admin_apiboot error — use the new spelling
FERROEHR_REST_FHIR__ENABLEDfhir.api_enabledboot 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 bindsbinds for the first time
FERROEHR__QUERY__PLAN_CACHE_CAPACITY / _TIMEOUT_MSquery.* — same spelling, now strict-parsedbehaviour change (bad values now error)
the nine FERROEHR_*_CONFIG file pointersremoved — 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 &amp; pinned)"]

    subgraph gen ["Specification layer (generated, never hand-edited)"]
        types["RM 1.2.0 types · canonical JSON &amp; 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:password with 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 Prefer header, ETag/If-Match optimistic 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 — the VERSIONED_EHR_STATUS object,
  • GET .../versioned_ehr_status/revision_history — the revision history,
  • GET .../versioned_ehr_status/version (optionally ?version_at_time=) and .../version/{version_uid} — a specific version.

COMPOSITION

A COMPOSITION is a committed clinical document, validated against its template.

Create a composition

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

POST /ehr/{ehr_id}/composition returns 201 Created with the version id in ETag. 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. PUT takes the object uuid (the versioned object), while DELETE takes the full version id (the version you are superseding). GET accepts either.

Note

A version’s lifecycle state (set through the openehr-version: lifecycle_state.code_string header — see Content negotiation & errors; the default on a commit is 532|complete|) must follow the openEHR version-lifecycle state machine. An illegal transition is rejected with 422 Unprocessable Entity naming the states. In particular, a version left in the 801|abandoned| state cannot be updated straight to complete — you must first retrieve it back to 553|incomplete|, then complete it.

Composition version history

GET .../versioned_composition/{versioned_object_uid} and its revision_history, version, and version/{version_uid} sub-resources mirror the EHR_STATUS history endpoints.

DIRECTORY

The DIRECTORY is an optional FOLDER tree for organising compositions within an EHR. The /directory endpoints manage the EHR’s primary hierarchy (the openEHR EHR.directory, which is always the first member of EHR.folders).

An EHR can also index additional folder hierarchies beyond the directory: commit further root FOLDERs through the CONTRIBUTION endpoint (the openEHR REST API defines no dedicated endpoint for them). The EHR resource then lists every live hierarchy in its folders attribute, in creation order, with directory always equal to the first member; deleting the directory promotes the next live hierarchy.

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

# Read it (optionally at a time, or a sub-path)
curl -u ferroehr:ferroehr \
  'http://localhost:8080/ferroehr/rest/openehr/v1/ehr/'$EHR_ID'/directory?path=episodes/2024'
  • POST /ehr/{ehr_id}/directory — create the root folder; 201.
  • PUT /ehr/{ehr_id}/directory — update it; requires If-Match; 200/204.
  • DELETE /ehr/{ehr_id}/directory — logical delete; requires If-Match; 204.
  • GET /ehr/{ehr_id}/directory — the current folder tree, optionally filtered by ?version_at_time= and ?path= (slash-separated folder names). 204 if deleted at that time.
  • GET /ehr/{ehr_id}/directory/{version_uid} — a specific version, optionally ?path=.

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 data of each version, not to the envelope.

Status-code summary

CodeMeaning across these resources
200Retrieved, or updated with Prefer: return=representation.
201Created (EHR, composition, directory, contribution).
204Success with no body (return=minimal), or deleted / deleted-at-time.
400Malformed request, missing required header/parameter, or already-deleted.
404Unknown EHR, object, version, or no version at the requested time.
409Conflict — duplicate subject/id, a version that is not the latest, or a content write to a deactivated (is_modifiable = false) EHR.
412If-Match did not match the latest version (current id returned in ETag).
422Composition 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/json or application/xml.
  • Response: set Accept: application/json or application/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:

LineageRoot namespaceStatus
v1 (default)http://schemas.openehr.org/v1The stable schema release most openEHR tooling reads
v2http://schemas.openehr.org/v2The 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 stays Content-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 on Accept and 415 Unsupported Media Type on Content-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 version parameter is a FerroEHR extension: the openEHR REST specification predates the two schema lineages and says nothing about selecting one. It never changes the media type itself — responses are always application/xml — so a client that ignores it behaves exactly as the specification describes.

Simplified formats (FLAT and STRUCTURED)

Beyond the canonical formats, the server implements the openEHR Simplified Formats — template-driven JSON representations that use friendly field identifiers (vital_signs/body_temperature:0/any_event:0/temperature|magnitude) instead of full RM paths. Select them the same way as JSON/XML, with these media types:

Media typeMeaning
application/openehr.wt.flat+jsonFLAT — one flat JSON object of path: value pairs
application/openehr.wt.structured+jsonSTRUCTURED — the same data as nested JSON
application/openehr.wt+jsonA template rendered as Web Template JSON (template endpoints only)

Where they work:

  • Compositions — full round-trip: commit with Content-Type: application/openehr.wt.flat+json (or …structured…) and read back with the matching Accept.
  • Template examplesGET …/definition/template/adl1.4/{id}/example (and the ADL2 form) return the generated example in any of the four formats, chosen via Accept.
  • Template definitionsGET …/definition/template/adl1.4/{id} with Accept: application/openehr.wt+json returns the Web Template document. Accept: application/json returns the same document — it is the only JSON representation of a template — under Content-Type: application/json: the response always carries the media type you asked for.
  • Contributions — the CONTRIBUTION envelope itself stays canonical JSON; a simplified media type applies only to each composition payload inside versions[].data.

Two rules to know when committing a composition in a simplified format:

  • A FLAT/STRUCTURED payload cannot carry its own template id, so the openehr-template-id request header is required — the commit is rejected with 422 without it.
  • There is no ?format= query parameter: format selection is done exclusively through the standard Accept and Content-Type headers.

Requests naming a media type the endpoint does not support are answered with 415 Unsupported Media Type (request body) or 406 Not Acceptable (response format), with a body naming the formats that endpoint does support. EHR, EHR_STATUS, directory, and demographic resources have no simplified representation (the format is generated from an operational template, which those resources do not have) — they speak canonical JSON/XML only.

The query API is JSON only — it does not accept XML or the simplified media types.

The Prefer header

Write operations (create/update) accept a Prefer header controlling the response body. Its default is return=minimal:

Prefer valueEffect
return=minimal (default)Empty body; the identifier is in ETag/Location. Status 204 on update, 201 on create.
return=representationThe full created/updated resource in the body, status 200/201.
return=identifierJust the resource identifier object — {"uid": "…"} (templates: {"template_id": "…"}). Status 200/201, never 204.

Use return=representation when you want the server-completed object back (with its assigned version id and any server-set audit fields); use return=minimal for throughput when you only need the id.

return=identifier always comes back with a body, so it never uses 204 — an update that would answer 204 under return=minimal answers 200 with the identifier object instead.

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 ETag header carrying the object or version identifier as a weak ETag, W/"...".

  • Updating or deleting a versioned object requires an If-Match header set to the current version id. Both the weak form and a bare quoted value are accepted — echoing the ETag you received works either way:

    If-Match: W/"8849182c-82ad-4088-a07f-48ead4180515::your.system::2"
    If-Match: "8849182c-82ad-4088-a07f-48ead4180515::your.system::2"
    
  • If the object has moved on since you read it, the write fails with 412 Precondition Failed and the current version id in the response ETag. Re-read, reconcile, and retry against the new version.

A Location header is emitted only when a resource is created — reads and deletes identify the version through ETag alone, so do not expect Location on them; the ETag is the authoritative identifier.

Last-Modified — when the version was committed

Alongside the ETag, versioned responses carry a Last-Modified header in the standard HTTP-date form (Wed, 22 Jul 2009 19:15:56 GMT). Its value is the commit time of the version being served — the audit time_committed of that VERSION — so it changes exactly when the ETag does.

You get it on:

  • every VERSION read (…/versioned_composition/{uid}/version[/{version_uid}], …/versioned_ehr_status/version[/{version_uid}]) and revision history read;
  • every COMPOSITION, EHR_STATUS, and DIRECTORY read — including the FLAT and STRUCTURED representations, which describe the same version;
  • every write of those resources (create, update, and the delete 204), and the EHR create 201;
  • every CONTRIBUTION — both the read and the commit 201, where the value is the contribution audit’s commit time. On a contribution commit you get the header under either Prefer setting; with return=minimal there is no response body, so the header is the only place the commit time appears.

Resources that are not versioned do not carry it: GET /ehr/{ehr_id} returns the weak ETag (built from EHR.ehr_id.value) but no Last-Modified, because the EHR root object has no commit audit of its own.

Template responses (ADL 1.4 and ADL2) carry a weak ETag keyed on the template identifier; for ADL2 it is the resolved 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 in If-Match exactly as received — and it works the same for trunk and branch versions. ALL_VERSIONS queries and version reads return branch versions alongside trunk ones; the latest version of an object is always the latest trunk version.

Tip

The round-trip is: read the resource → keep its ETag value → send it back as If-Match on the update → get a new ETag for 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:

HeaderAttributeSub-keys
openehr-versionlifecycle_statecode_string
openehr-audit-detailschange_typecode_string
openehr-audit-detailsdescriptionvalue
openehr-audit-detailscommittername, external_ref.id, external_ref.namespace, external_ref.type
openehr-audit-detailssystem_id(bare value)

A client-supplied system_id is merged into the commit audit — useful when a gateway commits on behalf of a source system; when absent, the server stamps its own system id.

Both EHR creates (POST /ehr and PUT /ehr/{ehr_id}) accept the headers too: creating an EHR commits its EHR_STATUS and EHR_ACCESS in a contribution, so the supplied description, committer, and system id land on that commit, and openehr-version sets the new EHR_STATUS version’s lifecycle state. The change_type on a create is constrained to 249|creation| — a create commits a first version — so restating 249 is accepted while any other change type is rejected.

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 Allowed and 501 Not Implemented, which some servers leave bodyless, and for the two refusals that come from the transport layer rather than a handler: 408 Request Timeout (the request exceeded the server’s execution limit) and 413 Payload Too Large (the request body exceeds the accepted size).

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

405 always names the allowed methods

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

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

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

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

An AQL statement has the familiar SELECT … FROM … WHERE … ORDER BY skeleton, but the “tables” are RM types and the “columns” are archetype paths:

SELECT
    c/name/value AS composition_name,
    o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic
FROM EHR e
    CONTAINS COMPOSITION c
        CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140
ORDER BY systolic DESC
  • FROM binds variables to RM types (EHR e, COMPOSITION c, OBSERVATION o). A type can be constrained by archetype id in square brackets (OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]). Naming a parent archetype also returns data recorded under its specialisations, as openEHR requires: for ADL 1.4 identifiers the specialisation is the hyphen-extended concept (…blood_pressure matches …blood_pressure-cuff), and for ADL 2 identifiers — where the hyphen carries no such meaning — the lineage is read from the ADL 2 archetypes and templates you have uploaded, so the parent matches every stored specialisation of it whatever its concept is named. In both cases the major version is a hard boundary: .v1 never matches .v2 data.
  • CONTAINS expresses structural containment — “an EHR that contains a composition that contains a blood-pressure observation”. Chains can nest several deep, and combine with AND, OR, and NOT.
  • SELECT projects values by path. Paths use archetype node ids (at0004) and RM attribute names (value/magnitude); AS names a column.
  • WHERE filters on typed leaf values, with comparisons, EXISTS, LIKE, MATCHES, and boolean combinators. Comparisons over multi-valued paths use any-match semantics: when a path matches several nodes, 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, and OFFSET behave 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:

FieldMeaning
qThe AQL text (required).
offsetRows to skip (default 0).
fetchMaximum rows to return.
query_parametersAn object of named parameter values (see below).

There is also a GET /query/aql form taking q, offset, fetch, an optional ehr_id, and query_parameters as query-string parameters — convenient for simple, cacheable reads.

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 optional query_type parameter names the formalism, default AQL (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 like my_compositions and dotted names like cnf.ward_dashboard are valid as-is. The query-name aql is reserved (case-insensitive, it would collide with the ad-hoc /query/aql route) and is rejected with 400.
  • GET /definition/query/{name}[/{version}] — list or fetch.
  • GET|POST /query/{name}[/{version}] — execute, taking the same offset, fetch, and query_parameters as 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) a matches operand expands a value set so a coded field matches any code in it, rather than listing codes by hand;
  • TERMINOLOGY('validate'|'subsumes', …) = true as a boolean condition evaluates a code-membership or subsumption test once per query;
  • a terminology URI operand — matches { terminology://… } — expands the set the URI identifies.

These require a terminology source; if external terminology is not configured, the in-process openEHR bundle is used. See Terminology servers for wiring an external FHIR terminology server.

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 by ehr_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:

  • SELECT of paths, literals, aliases, DISTINCT, and the aggregates COUNT (including COUNT(DISTINCT)), MIN, MAX, SUM, AVG. MIN and MAX order their operand by type — a quantity by its openEHR magnitude, a date/time chronologically, text lexically — so they work over non-numeric leaves, not just numbers;
  • FROM over EHR, VERSION (LATEST_VERSION / ALL_VERSIONS), and RM classes with archetype and name predicates;
  • CONTAINS trees with AND, OR, and NOT CONTAINS;
  • WHERE comparisons on typed leaves (with openEHR magnitude ordering for quantities), EXISTS, LIKE, MATCHES value lists, and range predicates;
  • ORDER BY typed leaves, LIMIT/OFFSET, named query parameters, and the ehr_id, offset, and fetch request parameters;
  • the single-row functions: LENGTH, SUBSTRING, POSITION, the string CONTAINS, CONCAT/CONCAT_WS, ABS, MOD, CEIL, FLOOR, ROUND, and CURRENT_DATE/CURRENT_TIME/CURRENT_DATE_TIME/NOW/ CURRENT_TIMEZONE;
  • terminology-backed TERMINOLOGY() operands and terminology-URI matches operands (see above).

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 version parameter is absent, the list collapses to the latest version of each template (the highest .vN axis of its template_id). Pass version=* to list every stored version, or a partial glob (version=*.v1*) to select specific ones.

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 of path|attribute → value, which is compact and easy to produce from a form. For example:

    {
      "vital_signs/blood_pressure/any_event:0/systolic|magnitude": 120,
      "vital_signs/blood_pressure/any_event:0/systolic|unit": "mm[Hg]",
      "vital_signs/blood_pressure/any_event:0/diastolic|magnitude": 80,
      "vital_signs/blood_pressure/any_event:0/diastolic|unit": "mm[Hg]",
      "vital_signs/language|code": "en",
      "vital_signs/language|terminology": "ISO_639-1"
    }
    
  • STRUCTURED (structSDT)application/openehr.wt.structured+json. The same data as a nested JSON tree that mirrors the template structure, rather than a flat map.

Send the matching Content-Type when committing, or the matching Accept when retrieving, and the server converts between the flat/structured form and the canonical composition. 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

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_MULTIMEDIA blobs 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.

MethodPathPurpose
POST/demographic/{kind}create a party
GET/demographic/{kind}/{uid_based_id}read a party
PUT/demographic/{kind}/{uid_based_id}update a party
DELETE/demographic/{kind}/{uid_based_id}delete a party
GET/demographic/versioned_party/{versioned_object_uid}the versioned container
GET/demographic/versioned_party/{versioned_object_uid}/revision_historyrevision history
GET/demographic/versioned_party/{versioned_object_uid}/versionversion at 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:

MethodPathPurpose
POST/demographic/party_relationshipcreate a relationship
GET/PUT/DELETE/demographic/party_relationship/{uid_based_id}read / update / delete
GET/demographic/versioned_party_relationship/{versioned_object_uid}the versioned container
GET/demographic/versioned_party_relationship/{versioned_object_uid}/revision_historyrevision history
GET/demographic/versioned_party_relationship/{versioned_object_uid}/version[/{version_uid}]version at 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:

MethodPathPurpose
GET/terminologylist terminologies
GET/terminology/{terminology_id}describe one terminology
GET/terminology/{terminology_id}/term/{code}look up a term
GET/terminology/{terminology_id}/subsumes?ref_code=&candidate=subsumption test
GET/terminology/{terminology_id}/value_set/{value_set_id}get a value set
GET/terminology/{terminology_id}/value_set/{value_set_id}/validate?candidate_code=&at_date=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:

KeyMeaning
FERROEHR__TERMINOLOGY__EXTERNAL__ENABLEDmaster switch (default false)
FERROEHR__TERMINOLOGY__EXTERNAL__FAIL_ON_ERRORon a server error: true rejects (fail-closed), false accepts (fail-open)
FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__<NAME>__TYPEprovider type — fhir (R4)
FERROEHR__TERMINOLOGY__EXTERNAL__PROVIDERS__<NAME>__URLthe 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 (PUT to /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 a namespace, giving a canonical namespace::name identity), a type, an optional currency (how fresh a value must be), and either a binding to a data frame (frame_id + frame_path) or the is_manual flag.
  • Data set — an application’s working set of variables for a subject, under local aliases (your app can call the canonical date_of_birth variable dob). 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 a QUERY_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>):

KeyTypeDefaultMeaning
FERROEHR__SUBJECT_PROXY__SYSTEMS__<NAME>__BASE_URLURLnone (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_MSinteger (ms)2000Per-system TCP connect timeout.
FERROEHR__SUBJECT_PROXY__SYSTEMS__<NAME>__REQUEST_TIMEOUT_MSinteger (ms)10000Per-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:

FieldMeaning
contribution_idthe contribution this change belongs to
ehr_idthe EHR (may be null for a demographic contribution)
committed_atthe commit instant
versions[]one entry per changed versioned object
seqthe delivery sequence number (monotonic)
version_indexwhich entry in versions this message is for

Each versions[] entry 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 in seq order. 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 variableDefaultMeaning
FERROEHR__EVENTS__ENABLEDfalsemaster switch
FERROEHR__EVENTS__URLamqp://guest:guest@localhost:5672/%2fbroker connection URL
FERROEHR__EVENTS__EXCHANGEferroehr.eventstopic exchange name (also the queue-name prefix)
FERROEHR__EVENTS__TLSfalsewhen true, upgrades an amqp:// URL to amqps://
FERROEHR__EVENTS__BATCH_SIZE128rows drained per cycle
FERROEHR__EVENTS__POLL_INTERVAL_MS1000poll interval while the outbox is idle
FERROEHR__EVENTS__PUBLISH_MAX_RETRIES3retries per message before the batch stops
FERROEHR__EVENTS__RETENTION_DAYS7how long published rows are kept
FERROEHR__EVENTS__PRUNE_INTERVAL_SECS3600how 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=true or an amqps:// 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, not ferroehr.events): broker access control can then isolate the PHI-bearing stream. Enable it only against a TLS, access-controlled broker, and treat every consumer as a PHI processor.

Mappings are data you manage

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

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

A mapping definition binds one FHIR resource type (optionally scoped to a meta.profile URL) to one openEHR template, and lists field bindings — each mapping an openEHR FLAT path to a FHIR path (or 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 a 409.

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 variableDefaultMeaning
FERROEHR__FHIR__API_ENABLEDfalseenable inbound ingest, the read façade, and the mapping API
FERROEHR__FHIR__OUTBOUND__ENABLEDfalseenable outbound emission to AMQP
FERROEHR__FHIR__OUTBOUND__URLamqp://guest:guest@localhost:5672/%2foutbound broker URL
FERROEHR__FHIR__OUTBOUND__EXCHANGEferroehr.fhiroutbound topic exchange (kept distinct from the event stream)
FERROEHR__FHIR__OUTBOUND__TLSfalseupgrade an amqp:// URL to amqps://
FERROEHR__FHIR__OUTBOUND__BATCH_SIZE128commits drained per cycle
FERROEHR__FHIR__OUTBOUND__POLL_INTERVAL_MS1000poll interval while idle
FERROEHR__FHIR__OUTBOUND__PUBLISH_MAX_RETRIES3retries 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):

  1. A node qualifies only when it is purely inline (it has data and no uri) and its decoded byte length is strictly greater than the configured threshold. A value at or below the threshold stays inline; a value that already references external media (has a uri) is stored verbatim, never touched.
  2. The raw decoded bytes are written to the object store under a key that is the SHA-256 hash of those bytes (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.
  3. The node is rewritten in place: its inline data is removed and replaced with a uri of the form s3://<bucket>/<hash>, plus an integrity_check (the SHA-256 digest), an integrity_check_algorithm code phrase (SHA-256), and the original size.

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_MULTIMEDIA that points at the blob by content hash. Everything remains canonical openEHR JSON — the s3:// 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 variableDefaultMeaning
FERROEHR__MULTIMEDIA__ENABLEDfalsemaster switch
FERROEHR__MULTIMEDIA__THRESHOLD_BYTES262144 (256 KiB)offload blobs larger than this; smaller stay inline
FERROEHR__MULTIMEDIA__ENDPOINTunsetS3 endpoint URL (unset uses AWS default resolution)
FERROEHR__MULTIMEDIA__BUCKETopenehr-multimediatarget bucket
FERROEHR__MULTIMEDIA__REGIONus-east-1S3 region
FERROEHR__MULTIMEDIA__ACCESS_KEY_IDunsetaccess key (see note on credentials)
FERROEHR__MULTIMEDIA__SECRET_ACCESS_KEYunsetsecret key
FERROEHR__MULTIMEDIA__ALLOW_HTTPfalsepermit 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 basic block 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 oidc block 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 variableDefaultMeaning
FERROEHR__AUTH__OIDC__ISSUER— (required to enable OIDC)expected iss, and the OIDC discovery base
FERROEHR__AUTH__OIDC__AUDIENCESempty (not checked)accepted aud values
FERROEHR__AUTH__OIDC__ALGORITHMS["RS256"]accepted signing algorithms
FERROEHR__AUTH__OIDC__HMAC_SECRETunsetan HS256 symmetric secret (development/testing)
FERROEHR__AUTH__OIDC__JWKS_JSONunseta 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-api

The same pattern works for Active Directory or any standards-compliant identity provider — walkthroughs for Entra ID and AD FS (and the answer for plain-LDAP directories) are in Enterprise identity providers. Prefer JWKS/discovery over a shared HS256 secret in production. User accounts, roles, and lifecycle are administered in the IdP — the CDR has no user API.

An unauthenticated request to a protected route is refused with 401; an authenticated request that lacks the required role is refused with 403.

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> or role:<role> (matched against the caller’s roles). Everyone else gets 403.
  • Privacy levels — integer sensitivity levels with meanings you define for your jurisdiction. A composition’s level is its override entry or the default; a caller with restricted_below access may only read compositions strictly below their max_level, while full access has no ceiling.
  • Gate-keeper — once set, only that principal may commit a new EHR_ACCESS version (via a CONTRIBUTION; there is no dedicated EHR_ACCESS endpoint in the openEHR REST API). Changes are versioned and audited like all record content.

The scheme is a FerroEHR extension: openEHR mandates the EHR_ACCESS object and its change control but publishes no concrete access-control scheme. Query (AQL) results are not filtered by privacy level 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 variableDefaultMeaning
FERROEHR__AUTHZ__RBAC__ENABLEDtruethe coarse role gate (active only when auth is enabled)
FERROEHR__AUTHZ__RBAC__ADMIN_ROLEADMINrole required for admin operations
FERROEHR__AUTHZ__RBAC__USER_ROLEUSERthe baseline clinical role
FERROEHR__AUTHZ__RBAC__READONLY_ROLEREADONLYrole 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_ACCESSadmin_onlymanagement-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 variableDefaultMeaning
FERROEHR__AUTHZ__ABAC__ENABLEDfalsemaster ABAC switch
FERROEHR__AUTHZ__ABAC__ENGINEcedarcedar (embedded) or remote (external PDP)
FERROEHR__AUTHZ__ABAC__ORGANIZATION_CLAIMorganization_idJWT claim for the organisation attribute
FERROEHR__AUTHZ__ABAC__PATIENT_CLAIMpatient_idJWT claim for the patient attribute (enables the subject gate)
FERROEHR__AUTHZ__ABAC__CEDAR__POLICY_DIR— (required for cedar)directory of .cedar policy files
FERROEHR__AUTHZ__ABAC__CEDAR__RELOAD_SECSoffoptional policy hot-reload interval
FERROEHR__AUTHZ__ABAC__REMOTE__SERVER— (required for remote)PDP base URL (must end with /)
FERROEHR__AUTHZ__ABAC__REMOTE__CONNECT_TIMEOUT_MS2000PDP connect timeout
FERROEHR__AUTHZ__ABAC__REMOTE__REQUEST_TIMEOUT_MS5000PDP request timeout

Two engines sit behind one interface. Cedar is the embedded default: policies live in .cedar files, are schema-validated at boot (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 a 403.

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 variableDefaultMeaning
FERROEHR__TENANCY__ENABLEDfalseenable multi-tenancy
FERROEHR__TENANCY__CLAIMtenantthe JWT claim (a dotted path) carrying the tenant key
FERROEHR__TENANCY__HEADERunseta 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__HEADER unset 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 a 403 that 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 CONTRIBUTION and AUDIT_DETAILS, which the server always writes in the same transaction as every change. openEHR audit records what a version says about its own authorship; ATNA records security surveillance of API access. Both coexist. Identified data never enters telemetry (metrics, traces, logs) — 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

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:

  1. Token validation ([auth.oidc]): the server discovers the JWKS from the issuer’s .well-known/openid-configuration and validates each bearer token’s signature, iss, and (when configured) aud — see the OIDC settings table.
  2. 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.

  1. Register an application (Entra admin center → App registrations). Note the Directory (tenant) ID and the Application (client) ID.

  2. Define app roles (App registration → App roles): create roles named after the CDR roles you use (for example USER, CLINICAL, ADMIN) and assign users/groups to them (Enterprise applications → your app → Users and groups). Entra puts assigned app roles in the token’s roles claim.

  3. Expose an audience: either use the client ID as the audience or add an Application ID URI (for example api://ferroehr).

  4. Point the CDR at the tenant issuer and mine the roles claim:

    export FERROEHR__AUTH__OIDC__ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
    export FERROEHR__AUTH__OIDC__AUDIENCES=api://ferroehr
    export FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS='["roles"]'
    
  5. Verify: request a token for the app (any OAuth2 client credential or auth-code flow) and call the API. A valid token without a required role must get 403; no token, 401.

Tip

Group-based deployments can emit the groups claim instead and list it in ROLE_CLAIMS — but group claims arrive as object IDs unless you configure group names, so app roles usually read better in policy.

AD FS (on-premises Active Directory)

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

  1. Create an Application Group (AD FS Management → Application Groups → Web API template, or Server application + Web API for interactive clients). The Web API’s identifier becomes the token audience.

  2. Issue role claims: on the Web API’s Issuance Transform Rules, add a rule mapping AD group membership to the role claim (template: Send Group Membership as a Claim), one rule per CDR role.

  3. Point the CDR at the AD FS issuer and mine the role claim:

    export FERROEHR__AUTH__OIDC__ISSUER=https://adfs.example.com/adfs
    export FERROEHR__AUTH__OIDC__AUDIENCES=ferroehr-api
    export FERROEHR__AUTHZ__RBAC__ROLE_CLAIMS='["role"]'
    

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

  4. Verify exactly as above: 401 without a token, 403 with a token that lacks the required role.

Note

AD FS emits a single string for one role and an array for several; the role-mining layer accepts both shapes on any configured claim path.

“We only have LDAP”

The CDR does not speak LDAP, by design — LDAP bind would put password handling back inside the CDR. Front the directory with an OIDC-capable broker and connect that instead:

  • Active Directory → AD FS (above) or Entra ID (if synced).
  • Generic LDAP → Keycloak with LDAP user federation (the Keycloak example then applies verbatim), or any other OIDC provider that can federate LDAP.

The broker owns the LDAP bind; the CDR sees only signed tokens.

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

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

KeyDefaultMeaning
FERROEHR__SMART__ENABLEDfalseMaster switch. Off = no discovery document (404) and an inert scope gate.
FERROEHR__SMART__PLATFORM_BASE_URLunsetBase the discovery document hangs off. Unset = the REST root (/ferroehr/rest). A leading path is honoured (/gateway/v1/gateway/v1/.well-known/smart-configuration).
FERROEHR__SMART__EHR_ID_CLAIMehrIdToken claim carrying the launch context’s openEHR EHR id.
FERROEHR__SMART__PATIENT_CLAIMpatientFallback launch-context claim when the EHR-id claim is absent.
FERROEHR__SMART__REQUIRE_SMART_SCOPESfalseFail-closed switch — see Advisory vs required below.
FERROEHR__SMART__EPISODE__ENABLEDfalseAdvertise + accept episode launch context (experimental; advisory only, no episode filtering).
FERROEHR__SMART__LAUNCH_BASE64_JSONfalseAdvertise the base64-JSON launch-parameter capability (experimental; consumed by the app, not the CDR).
FERROEHR__SMART__ENDPOINTS__ISSUERunsetAdvertised token issuer. Unset = falls back to the configured OIDC bearer issuer.
FERROEHR__SMART__ENDPOINTS__JWKS_URIunsetAdvertised jwks_uri.
FERROEHR__SMART__ENDPOINTS__AUTHORIZATION_ENDPOINTunsetAdvertised OAuth2 authorization endpoint.
FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINTunsetAdvertised OAuth2 token endpoint.
FERROEHR__SMART__ENDPOINTS__REGISTRATION_ENDPOINTunsetAdvertised dynamic-client registration endpoint.
FERROEHR__SMART__ENDPOINTS__INTROSPECTION_ENDPOINTunsetAdvertised token introspection endpoint.
FERROEHR__SMART__ENDPOINTS__REVOCATION_ENDPOINTunsetAdvertised token revocation endpoint.
FERROEHR__SMART__ENDPOINTS__MANAGEMENT_ENDPOINTunsetAdvertised user management endpoint.
FERROEHR__SMART__ENDPOINTS__TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED[]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" }
  ]
}
  • capabilities always contains context-openehr-ehr and openehr-permission-v1, plus context-openehr-episode and launch-base64-json when those switches are on.
  • services names the openEHR REST service with its base URL, plus the FHIR façade (org.fhir.rest) when the FHIR routes are enabled.
  • scopes_supported is 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>:

  • Compartmentpatient (the launch context’s EHR only), user (what the user may see), or system (a backend service, no user).
  • Resource — one of three families: composition-<template-id>, template-<template-id>, or aql-<stored-query-name>.
  • Permissions — any combination of c create, r read, u update, d delete, s search/execute (order-free, e.g. .crud, .rs).

Resource ids accept wildcards: * matches within one ::-delimited namespace segment, ** matches across namespaces, and a bare * or ** matches every id. The permission tail is split at the last dot, so template ids and query names keep their internal dots and versions.

ScopeGrants
patient/composition-*.crudCreate, read, update, and delete any composition — but only in the launched patient’s EHR.
patient/aql-*.rsRead and execute any stored query, scoped to the launched patient’s EHR.
patient/composition-MyHospital::Template.v0.rRead compositions of exactly that template, in the launched patient’s EHR.
user/composition-MyHospital::*.rRead compositions of any template in the MyHospital namespace (not sub-namespaces).
user/template-*.crudsFull access to template definitions.
system/aql-org.openehr::bloodpressure.v1.rsA backend service may read and execute that one stored query, 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 AuditEvent following the IHE BALP (Basic Audit Log Patterns) content profiles — the modern RESTful-ATNA form and the canonical stored form. Patient-centric operations carry the resolved EHR subject as the patient entity (PatientRead/PatientCreate/… profile claims); query executions carry the search expression; Bearer-authenticated requests record the token’s jti (and never the token itself) per OAUTHaccessTokenUse.Minimal.
  • DICOM Audit Message (DICOM PS3.15 §A.5 XML) — the classic ATNA form, shipped over syslog per IHE ITI-20 when the syslog sink is enabled. Dedicated DICOM event ids are used throughout: Patient Record (110110), Query (110112), Export/Import (110106/110107, the EHR-Extract directions), User Authentication (110114, with the Login EventTypeCode 110122).

Every server operation is audited (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):

SinkDefaultWhat it does
[audit.store]onThe 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]offThe classic ATNA feed: DICOM PS3.15 XML over syslog (RFC 5424; UDP or TLS transport) to an external ARR, per IHE ITI-20.
[audit.fhir_feed]offThe RESTful-ATNA feed (ITI-20 ATX: FHIR Feed): each FHIR AuditEvent is POSTed to an external FHIR ARR. With the local store on, delivery is outbox-driven: an ARR outage loses nothing, pending records ship on recovery.

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:

KeyDefaultMeaning
cdr.base_urlhttp://localhost:8080The CDR origin (the ITS-REST base path is appended).
cdr.request_timeout_secs30Per-request timeout toward the CDR.
cdr.management_base_url{cdr.base_url}/managementThe CDR’s management surface, base path included — set it when the CDR serves management on its own internal listener (management.port) or under a renamed base path. Drives the Operations panel.
auth.basic_enabledtrueOffer the username/password form (validated against the CDR; held server-side).
auth.oidc.enabledfalseOffer OIDC login (authorization code + PKCE).
auth.oidc.issuer / client_id / client_secret (_file) / public_base_url / scopesThe OIDC client registration; public_base_url is the console’s externally visible origin for the redirect URI.
session.idle_minutes60Session idle expiry.
session.cookie_securefalseSet 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:

ActionWhat it opens
RunThe stored-query runner — executes that stored query on the CDR, with its parameters prompted
Open in editorThe raw AQL editor, seeded with that version’s query text
Open in builderThe query builder, with the query loaded back into its controls — when it fits (see Opening a stored query in the builder)

Both editing hand-offs pre-fill the namespace, name, and version fields — with the version set to the next one, so saving again publishes a new version instead of colliding with the one you opened (see Versions).

Opening a stored query in the builder

The builder writes one shape of AQL, and it will only load a stored query back into its controls when it can reproduce that query byte for byte. 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:

ResolutionRequestWhich version runs
Latest versionPOST /query/{name}the latest version of that query
Version prefixPOST /query/{name}/1 or …/1.2the latest version matching the prefix
Exact versionPOST /query/{name}/1.2.0exactly that version

A version that does not fit the chosen form (a full 1.2.0 typed as a prefix, or a bare 1 typed as exact) is refused with an explanation before anything is sent.

Grouping is the namespace

A stored query is identified by a qualified name — namespace::name, the namespace optional and, when present, a reverse domain name whose purpose in the openEHR REST specification is exactly “separation of use of stored queries by teams, companies, etc.”

The 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 fieldWhat a save does
emptyPUT /definition/query/{name} — the CDR files it at the default slot 1.0.0 and replaces whatever was stored there
1.2.0PUT /definition/query/{name}/{version} — stores a new, immutable version; if that exact (name, version) pair already exists the CDR refuses it (409) and the 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 an ITEM_TREE); leaving it blank removes the attribute. A value that is not a JSON object is refused before anything is sent.

Saving commits a new EHR_STATUS version on top of the one the screen loaded, and every other attribute — the subject included — is sent back exactly as the CDR served it, so nothing the form does not show can be lost.

Note

The save is conditional on the loaded version. If another client committed a new status in the meantime, the CDR refuses the write and the 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_only by 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):

ReaderQuestion it answers
topbar pillis the API answering at all, and at which version?
health cardare 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.filter in 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

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:

RolePurposeUsed by
ownerowns the databaseprovisioning only
ferroehr_migratorruns the schema migrations; owns the helper functionsthe migration step
ferroehr_appreads and writes clinical datathe running server
ferroehr_readerread-onlyreplicas 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_app DSN — 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 hostssl on the server and put ?sslmode=verify-full in the DSN so the client verifies the server certificate.
  • pgaudit. Run pgaudit as the database-layer complement to the openEHR audit and the ATNA trail — for example pgaudit.log = 'ddl, role, connection' globally plus object-level audit on the PHI tables — and ship the audit log to an immutable store with long (roughly six-year) retention.
  • Encryption at rest. Encrypt at the volume or disk layer. Do not encrypt the stored clinical JSON with pgcrypto — it would break AQL’s ability to query inside the data.

Backup and point-in-time recovery

Enable WAL archiving and point-in-time recovery from day one (pgBackRest or a managed PITR), because a CDR’s data is not reconstructible. Clinical and audit tables are never UNLOGGED. Test your restore, not just your backup.

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:

SettingValue
run as non-rootuid/gid 65532
read-only root filesystemyes (a writable emptyDir at /tmp)
privilege escalationdisallowed
Linux capabilitiesall dropped
seccompRuntimeDefault
service-account tokennot mounted (the workload never calls the Kubernetes API)
NetworkPolicydefault-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_timeout and statement_timeout so a migration cannot block live traffic indefinitely; on a busy table use CREATE INDEX CONCURRENTLY and add constraints NOT VALID then VALIDATE later.
  • Pin the image. Deploy an immutable tag or, better, a @sha256 digest, never latest; 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) and FERROEHR__LOG__FILTER (or RUST_LOG, default info,ferroehr=info) control them, and the level can be changed at runtime through the loggers endpoint below. On boot the server prints a one-time ASCII banner (version, maintainer, project URL, and spec pins) to stdout ahead of the logs; it is suppressed under FERROEHR__LOG__FORMAT=json so machine log consumers see only structured lines.
  • Traces export to any OpenTelemetry collector (Tempo, Jaeger, and so on) over OTLP — but only when you configure an endpoint; with none set, the tracing layer is not installed at all (zero overhead). Root spans are named by route template, never by a path containing ids.
  • Metrics 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 variableDefaultMeaning
FERROEHR__TELEMETRY__OTLP_ENDPOINTunset (layer not installed)OTLP collector endpoint
FERROEHR__TELEMETRY__SERVICE_NAMEferroehrreported service name
FERROEHR__TELEMETRY__ENVIRONMENTdevreported deployment environment
FERROEHR__TELEMETRY__TRACES_SAMPLE_RATIO1.0head sampling ratio (start at 0.1 in production)
FERROEHR__TELEMETRY__METRICS_PUSHfalsealso 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 no ehr_id parameter 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 single name/version row). 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 --set overrides). 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/ehrs with {"ehr_ids": ["…"]} — mark every named EHR (and all its versioned content) archived. 204 on success.
  • POST {base}/admin/archive/parties with {"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:

    FieldValuesDefault
    logical_formatopenehr_canonical_json or openehr_canonical_xmlcanonical JSON
    compression_formatzip or 7z — omit for loose filesuncompressed
    segment_split_sizesegment size in kb (a positive integer)1024
  • POST {base}/admin/load with {"file_sys_loc": "…"} — populate the repository from an archive. It takes the location and nothing else: the container (loose files, a single archive.zip, or a single archive.7z) is detected from what the location holds, and the logical format is read from the archive’s own manifest, so a load never has to be told how the dump was written.

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_xml writes each version to its own versions/<version_uid>.xml entry instead — a complete ORIGINAL_VERSION document under the openEHR-published <version> root, ready to hand to any tool that reads canonical openEHR XML. The archive’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/all without 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 the ADMIN role 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 one EXTRACT that carries every versioned object of the EHR, latest versions only. 404 if the EHR does not exist; 400 if ehr_id is not a well-formed identifier, which is refused before any lookup.
  • POST {base}/message/export with an EXTRACT_SPEC body — export by specification. 200 with one EXTRACT per manifest entity, in manifest order; a manifest with no entities yields []. Each entity must name the record by ehr_id or subject_id, otherwise 400; an identifier that names nothing is 404. extract_type must be one of the codes the openEHR Reference Model names — openehr-ehr, openehr-demographic, openehr-synchronisation, openehr-generic, generic-emr — or the catch-all other; anything else is 400.
  • POST {base}/message/import with an EXTRACT body — clone a whole EHR. Add ?ehr_id=<uuid> to fix the identifier the clone lands under; leave it off and the source identifier the extract carries is re-used. 201 with {"uid": "<ehr_id>"}, so a caller that supplied no id still learns what was created. The target must not already exist (409), and the extract must carry an EHR_STATUS (400).
  • POST {base}/message/import/{ehr_id} with an EXTRACT body — add the extract’s content to an existing EHR as new versions. 204. 404 if the EHR does not exist; 409 if the extract carries an EHR_STATUS or EHR_ACCESS other than the one the EHR already holds.

TDD import

  • POST {base}/message/tdd/{ehr_id} with an application/xml body — import one Template Data Document. It is converted against the operational template its root names and committed through the ordinary validated composition path, so 201 with {"uid": "<version_uid>"}. The template must already be uploaded through the definition API (404 otherwise); 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}/batch with a JSON array of TDD documents — import several at once. 201 with the created version ids in input order. The batch is all-or-nothing: every document is converted before any is committed, so one bad document rejects the whole batch and commits nothing. An empty array is a fulfilled no-op — 200 with [], since nothing was created — but the target EHR is 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 /message group is a FerroEHR extension: the openEHR service model defines a Message component, but the released REST API publishes no message, extract, or TDD 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

EndpointContractUse it for
GET /healthconstant 200 OK (plain text OK), touches nothingload balancers, docker HEALTHCHECK, anything that must never be auth-gated
GET /health/livenessidentical to /health — the same constant answer under the orchestrator-conventional pathKubernetes livenessProbe and startupProbe
GET /health/readiness200 when the aggregate is up or degraded, 503 when a required component is down; JSON body with every indicator (database ping, migrations applied, audit sender, events), each bounded to one secondKubernetes readinessProbe, ops dashboards
GET /ferroehr/rest/statusproduct status document: server version, ITS-REST version, timestampversion/identity checks; the URL the container’s ferroehr healthcheck subcommand probes
GET /management/*ops introspection — see belowoperators, 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. Wire livenessProbe and startupProbe to /health/liveness and readinessProbe to /health/readiness. The Helm chart does exactly this out of the box; probes.exec.enabled=true switches to the container’s ferroehr healthcheck subcommand 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 variableDefaultMeaning
FERROEHR__MANAGEMENT__ENABLEDfalseenable the management surface
FERROEHR__MANAGEMENT__BASE_PATH/managementbase path for the surface
FERROEHR__MANAGEMENT__PORTunset (main listener)serve management on its own port
FERROEHR__MANAGEMENT__ACCESS_DEFAULTadmin_onlydefault access level

The ops endpoints:

EndpointPurposeDefault access
GET {base}/infobuild, version, and pinned spec versionsadmin_only
GET {base}/prometheusPrometheus text expositionadmin_only (re-expose to the scraper via network policy)
GET {base}/metricsJSON registry viewadmin_only
GET {base}/enveffective configuration, with secrets redactedadmin_only
GET/POST/DELETE {base}/loggersread and change the log level at runtimeadmin_only

Warning

{base}/env and {base}/loggers expose and change server internals — keep them admin_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

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:

ChapterScope
EHR / EHR_STATUSEHR service and status operations
COMPOSITION / CONTRIBUTION / DIRECTORYClinical content, change sets, folder trees
DEFINITIONADL 1.4 + ADL 2 template and stored-query provisioning
QUERYAQL query execution with committed result-set grounds
CONTENTReference-Model and archetype-constraint accept/reject tables
DEMOGRAPHIC / ADMIN / MESSAGINGParty, admin, and messaging services
SF / SEC / PERFSimplified 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 under tools/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:

  • 874 case-by-format executions: 837 passed, 0 failed, 0 inconclusive, 37 not applicable with a machine-readable citation (run of 2026-08-01).
  • 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-java composes the official ehrbase/ehrbase image (with its companion PostgreSQL) on fresh volumes and runs the same catalogue with upstream’s own committed party set. Its measured artifacts live under docs/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.json naming the instances and credential environment variables, and a statement.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. The ixit.json may 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; and terminology, 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

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

ClassCorpusOffered-load floorp99 budgetError budgetMeasured sustainedVerdict
POCcnf.scale.10k2/s≤ 1000 ms02.0/sEARNED
Scnf.scale.100k15/s≤ 1000 ms0not measured
Lcnf.scale.1m150/s≤ 1000 ms0not measured
Rcnf.scale.10m1500/s≤ 1000 ms0not measured

Measured run PERF-hospital_sim-class_POC — class POC, offered load 2.04/s sustained over 3600 s (after 300 s warmup), environment: consumer-laptop (8 cores, 16 GB, nvme, single-node docker compose (8-CPU/8GB Docker VM) on Apple M2, the SMART resource-server posture (docker/sut-smart.yml overlays the base stack) with the external-terminology profile composed beside it (a seeded HAPI FHIR R4 server, docker compose –profile terminology + docker/sut-terminology.yml, fail-open); alongside it a second deployment of the same image in the openPGP version-signing posture (project ferroehr-cnf-pgp, docker/sut-signing-pgp.yml + docker/sut-terminology-failclosed.yml + docker/sut-pgp-parallel.yml, host port 8081) declared as the sut_pgp instance, which carries the fail-closed terminology posture — the measured-performance stage drives the primary deployment alone).

OperationRequestsErrorsp50 (ms)p90 (ms)p99 (ms)
adhoc_query7920293864
admin_contribution_report600159176269
analytics_query2306276197
archetype_adl2_list600131828
composition_commit280050106214
composition_commit_flat70425353
composition_delete40283535
composition_read15830243366
composition_read_current8960273657
composition_read_flat70172525
composition_revision_history8890142131
composition_update6804667239
composition_version_read300283549
contribution_commit48054138187
contribution_read820202860
directory_create120254086
directory_read8030172440
directory_update120283442
ehr_create120283233
ehr_extract_export7920149182268
ehr_read720182537
ehr_status_read240162325
ehr_status_update240213655
party_create60222828
party_read60162424
party_relationship_create60263030
party_relationship_read60111717
party_update60152727
readonly_write_denied70304646
smart_configuration_read70152424
stored_query_execute18004155103
system_options70101818
tags_put300243341
tags_read300172638
tdd_import70356060
template_adl2_list610131954
template_example60030102261
template_get60064134240
template_list600538090
terminology_query230192673
unauthenticated_probe70182323
ward_query1800283871

Resources (measured context, never a verdict input) — sampled every 10 s; CPU/RSS derived over the measured phase:

ContainerCPU meanCPU peakRSS peak
sut ferroehr-cnf-ferroehr-13.6%7.7%218 MB
db ferroehr-cnf-ferroehr-postgres-15.9%21.0%1.5 GB

Disk anchors: empty 133 MB → after scale seed 13 GB (≈ 13 KB / composition over 1,000,000 committed) → after ward seed 13 GB → after window 13 GB.

Benchmarks

FerroEHR measures performance with the same instrument family that measures conformance — the built-in CNF runner. There is no separate benchmark harness: every published number is measured by a committed, re-runnable instrument, reported in both directions, and regenerated from committed 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 by CONF_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

ferroehrupstream (Java)
Productferroehr 3.17.0ehrbase-java 2.34.0
Run date2026-08-012026-08-01
Party statementtools/cnf-runner/party/ferroehr/tools/cnf-runner/party/ehrbase-java/
Stackroot compose, built from the current sourcesdocker/sut-ehrbase-java.yml (official images)

Methodology

Both systems execute the same committed CNF 2.0 catalogue (874 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

Profileferroehrupstream (Java)
COREpassfail
STANDARDpassfail
OPTIONSpassnot claimed
SEC-BASICpassnot claimed

In-scope outcomes

Runs compared: ferroehr (run of 2026-08-01) vs upstream EHRbase 2.34.0 (run of 2026-08-01) — the SAME catalogue through the same runner, each with its own committed party statement. Per the presentation rule, the headline is each party’s VERDICT SCOPE (the cases its own declarations select), never the raw record: a raw count would book release-dated and unclaimed surfaces against a party that never claimed them.

verdict scope (selected)drivenin-scope passedin-scope failedin-scope inconclusive
ferroehr87483783700
upstream (Java)499459136132191

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

Capabilityferroehrupstream (Java)
ActivityReportpassednot_evidenced
Adl14ArchetypeProvisioningpassednot_evidenced
Adl14OptProvisioningpassedfailed
Adl2ArchetypeProvisioningpassednot_evidenced
Adl2OptProvisioningpassednot_evidenced
AdminApipassednot_evidenced
AnonymousEhrspassednot_evidenced
AqlAdvancedpassedinconclusive
AqlBasicpassedfailed
AqlTerminologypassednot_evidenced
ArchetypeValidationpassedfailed
AuditAccountabilitypassednot_evidenced
AuthenticatedAccesspassedpassed
AuthorizationSeparationpassednot_evidenced
BulkEhrLoadpassednot_evidenced
ChangeSetspassedfailed
CompositionOpspassedinconclusive
DefinitionApipassedfailed
DemographicApipassednot_evidenced
DemographicArchetypeValidationpassednot_evidenced
DemographicArchivepassednot_evidenced
DirectoryOpspassedfailed
EhrApipassedfailed
EhrArchivepassednot_evidenced
EhrDemographicSeparationpassedpassed
EhrDumpLoadpassednot_evidenced
EhrExtractpassednot_evidenced
EhrOperationspassedfailed
EhrStatuspassedfailed
ItemTagspassednot_evidenced
MessageApipassednot_evidenced
PartyOperationspassednot_evidenced
PartyRelationshipOperationspassednot_evidenced
PhysicalDeletionpassednot_evidenced
QueryApipassedfailed
QueryProvisioningpassedfailed
Signingpassednot_evidenced
SimplifiedFormatspassednot_evidenced
SmartAppLaunchpassednot_evidenced
SystemApipassednot_evidenced
Tdspassednot_evidenced
TemplateExamplespassednot_evidenced
Versioningpassedfailed

Failures — both directions

ferroehr failures (with the upstream outcome on the identical case)

CaseFormatFailureupstream outcome
none — zero failing cases

Upstream failures by schedule chapter

Chapterfailed cases
CONT67
I_EHR_STATUS24
I_EHR_DIRECTORY12
I_DEFINITION_QUERY10
I_DEFINITION_ADL27
I_EHR_CONTRIBUTION7
I_DEFINITION_ADL146
I_EHR_SERVICE5
I_QUERY_SERVICE3
I_EHR_COMPOSITION1
I_ITS_REST_REVISION_HISTORY1
SIG1
Every upstream-failed case, with the ferroehr outcome on the identical case
CaseFormatUpstream failureferroehr outcome
CONT-COMP-content_card_1plus-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_1plus-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_3plus-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_3plus-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_3to5-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_3to5-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_any-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_any-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_mand-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_mand-context_mandexpected created, observed validation_failedpassed
CONT-COMP-content_card_opt-context_anyexpected created, observed validation_failedpassed
CONT-COMP-content_card_opt-context_mandexpected created, observed validation_failedpassed
CONT-COMPOSITION-content_cardinality_count6expected created, observed validation_failedpassed
CONT-COMPOSITION-context_existenceexpected created, observed validation_failedpassed
CONT-DV_CODED_TEXT-validate_openexpected created, observed validation_failedpassed
CONT-DV_DATE-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_DATE-validate_rangeexpected created, observed validation_failedpassed
CONT-DV_DATE_TIME-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_DATE_TIME-validate_rangeexpected created, observed validation_failedpassed
CONT-DV_DURATION-validate_fieldsexpected created, observed validation_failedpassed
CONT-DV_DURATION-validate_fields_rangeexpected created, observed validation_failedpassed
CONT-DV_DURATION-validate_rangeexpected created, observed validation_failedpassed
CONT-DV_IDENTIFIER-validate_all_listexpected created, observed validation_failedpassed
CONT-DV_IDENTIFIER-validate_all_patternexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DATE-validate_lower_upper_rangeexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DATE_TIME-validate_lower_upper_rangeexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DURATION-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_DURATION-validate_rangeexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_ORDINAL-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_PROPORTION-validate_ratio_rangeexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_SCALE-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_constraintexpected created, observed validation_failedpassed
CONT-DV_INTERVAL_DV_TIME-validate_lower_upper_rangeexpected created, observed validation_failedpassed
CONT-DV_MULTIMEDIA-validate_media_typeexpected created, observed validation_failedpassed
CONT-DV_PARSABLE-validate_value_formalismexpected created, observed validation_failedpassed
CONT-DV_TEXT-validate_openexpected created, observed validation_failedpassed
CONT-DV_TIME-validate_constraintexpected created, observed validation_failedpassed
CONT-DV_TIME-validate_rangeexpected created, observed validation_failedpassed
CONT-EVENT-state_ex_mandexpected created, observed validation_failedpassed
CONT-EVENT-state_ex_optexpected created, observed validation_failedpassed
CONT-EVENT-type_anyexpected created, observed validation_failedpassed
CONT-EVENT-type_interval_eventexpected created, observed validation_failedpassed
CONT-EVENT-type_point_eventexpected created, observed validation_failedpassed
CONT-HIST-events_card_1plus-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_1plus-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_3plus-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_3plus-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_3to5-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_3to5-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_any-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_any-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_mand-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_mand-summary_ex_optexpected created, observed validation_failedpassed
CONT-HIST-events_card_opt-summary_ex_mandexpected created, observed validation_failedpassed
CONT-HIST-events_card_opt-summary_ex_optexpected created, observed validation_failedpassed
CONT-HISTORY-events_cardinality_count6expected created, observed validation_failedpassed
CONT-ITEM_STR-type_anyexpected created, observed validation_failedpassed
CONT-ITEM_STR-type_item_listexpected created, observed validation_failedpassed
CONT-ITEM_STR-type_item_singleexpected created, observed validation_failedpassed
CONT-ITEM_STR-type_item_tableexpected created, observed validation_failedpassed
CONT-ITEM_STR-type_item_treeexpected created, observed validation_failedpassed
CONT-OBS-state_ex_mand-protocol_ex_mandexpected created, observed validation_failedpassed
CONT-OBS-state_ex_mand-protocol_ex_optexpected created, observed validation_failedpassed
CONT-OBS-state_ex_opt-protocol_ex_mandexpected created, observed validation_failedpassed
CONT-OBS-state_ex_opt-protocol_ex_optexpected created, observed validation_failedpassed
I_DEFINITION_ADL14.upload_opt-invalid_optexpected validation_failed, observed not_acceptablepassed
I_DEFINITION_ADL14.upload_opt-valid_optexpected created, observed not_acceptablepassed
I_DEFINITION_ADL14.upload_opt-valid_opt_twice_conflictexpected created, observed not_acceptablepassed
I_DEFINITION_ADL14.upload_opt-valid_opt_twice_no_conflictexpected created, observed not_acceptablepassed
I_DEFINITION_ADL14.validate_opt-invalid_optexpected validation_failed, observed not_acceptablepassed
I_DEFINITION_ADL14.validate_opt-valid_optexpected created, observed not_acceptablepassed
I_DEFINITION_ADL2.get_artefact-example_unknownexpected not_found, observed not_acceptablepassed
I_DEFINITION_ADL2.get_artefact-version_prefixexpected created, observed not_acceptablepassed
I_DEFINITION_ADL2.upload_artefact-duplicate_conflictexpected created, observed not_acceptablepassed
I_DEFINITION_ADL2.upload_artefact-invalid_artefactsexpected validation_failed, observed not_acceptablepassed
I_DEFINITION_ADL2.upload_artefact-valid_optexpected created, observed not_acceptablepassed
I_DEFINITION_ADL2.valid_artefact-invalidexpected validation_failed, observed not_acceptablepassed
I_DEFINITION_ADL2.valid_artefact-validexpected created, observed not_acceptablepassed
I_DEFINITION_QUERY.list_queries-prefix_all_versions[0]/name: path resolves to nothingpassed
I_DEFINITION_QUERY.list_queries-version_get_xml_not_acceptableexpected not_acceptable, observed okpassed
I_DEFINITION_QUERY.list_queries-xml_not_acceptableexpected not_acceptable, observed okpassed
I_DEFINITION_QUERY.store_query-default_slot_with_higher_versionheader Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/orgpassed
I_DEFINITION_QUERY.store_query-dotted_nameexpected stored, observed bad_requestpassed
I_DEFINITION_QUERY.store_query-unqualified_nameexpected stored, observed bad_requestpassed
I_DEFINITION_QUERY.store_query-update_in_placeheader Location: value “http://localhost:8091/ehrbase/rest/openehr/v1/definition/query/orgpassed
I_DEFINITION_QUERY.store_query-version_duplicate_case_variant_nameexpected conflict, observed storedpassed
I_DEFINITION_QUERY.store_query-version_prefix_rejectedexpected bad_request, observed storedpassed
I_DEFINITION_QUERY.store_query-version_prerelease_rejectedexpected bad_request, observed storedpassed
I_EHR_COMPOSITION.get_versioned_composition-malformed_uidexpected bad_request, observed not_foundpassed
I_EHR_CONTRIBUTION.commit_contribution-delete_directoryexpected created, observed not_foundpassed
I_EHR_CONTRIBUTION.commit_contribution-deleted_member_with_dataexpected validation_failed, observed createdpassed
I_EHR_CONTRIBUTION.commit_contribution-ehr_status_incomplete_lifecycleexpected validation_failed, observed createdpassed
I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_typeexpected conflict, observed validation_failedpassed
I_EHR_CONTRIBUTION.commit_contribution-ehr_status_invalid_change_type_deletedexpected conflict, observed not_foundpassed
I_EHR_CONTRIBUTION.commit_contribution-fail_modify_non_existing_directoryexpected validation_failed, observed precondition_failedpassed
I_EHR_CONTRIBUTION.commit_contribution-non_exiting_optexpected template_not_found, observed validation_failedpassed
I_EHR_DIRECTORY.create_directory-ehr_not_modifiableexpected updated, observed precondition_missingpassed
I_EHR_DIRECTORY.delete_directory-ehr_with_directoryheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.delete_directory-empty_ehrexpected not_found, observed precondition_failedpassed
I_EHR_DIRECTORY.delete_directory-etag_names_new_versionheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.get_directory-deleted_headheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.get_directory-directory_with_structureequivalent: retrieved content differs from committed (modulo the normative ignore-set); $/passed
I_EHR_DIRECTORY.get_directory_at_time-deleted_at_timeheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.get_directory_at_version-deleted_versionheader Last-Modified: expected present, got nonepassed
I_EHR_DIRECTORY.update_directory-empty_ehrexpected not_found, observed precondition_failedpassed
I_EHR_DIRECTORY.update_directory-invalid_folderexpected validation_failed, observed precondition_missingpassed
I_EHR_DIRECTORY.update_directory-stale_if_matchheader ETag: expected the latest version uid, got nonepassed
I_EHR_DIRECTORY.update_directory-xmlcanonical-xmlexpected updated, observed precondition_missing
I_EHR_SERVICE.create_ehr-bulk_load_populationexpected created, observed validation_failedpassed
I_EHR_SERVICE.create_ehr-committal_headerscommit_audit/description/value: path resolves to nothingpassed
I_EHR_SERVICE.create_ehr-invalid_statusexpected validation_failed, observed createdpassed
I_EHR_SERVICE.create_ehr-wrong_methodheader Allow: expected a value matching “.*(GET.*POST|POST.GET).”, got nonepassed
I_EHR_SERVICE.get_ehr-malformed_ehr_idexpected bad_request, observed not_foundpassed
I_EHR_STATUS.clear_ehr_modifiable-bad_ehrexpected not_found, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_modifiable-existing_ehrexpected updated, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_modifiable-stale_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_queryable-bad_ehrexpected not_found, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_queryable-existing_ehrexpected updated, observed precondition_missingpassed
I_EHR_STATUS.clear_ehr_queryable-stale_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_ehr_status-at_time_futureexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_ehr_status-at_time_omittedexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_ehr_status_at_version-addressed_versionexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_versioned_ehr_status-at_time_futureexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_versioned_ehr_status-at_time_omittedexpected updated, observed precondition_missingpassed
I_EHR_STATUS.get_versioned_ehr_status-contained_uid_formheader Last-Modified: expected present, got nonepassed
I_EHR_STATUS.get_versioned_ehr_status-container_shapeowner_id/type: “ehr” != expected “EHR”passed
I_EHR_STATUS.get_versioned_ehr_status-xmlcanonical-xmlheader Last-Modified: expected present, got nonepassed
I_EHR_STATUS.set_ehr_modifiable-bad_ehrexpected not_found, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_modifiable-existing_ehrexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_modifiable-missing_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_modifiable-stale_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_modifiable-xml_bodycanonical-xmlexpected updated, observed precondition_missing
I_EHR_STATUS.set_ehr_queryable-bad_ehrexpected not_found, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_queryable-existing_ehrexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_queryable-missing_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_queryable-stale_if_matchexpected updated, observed precondition_missingpassed
I_EHR_STATUS.set_ehr_queryable-xml_bodycanonical-xmlexpected updated, observed precondition_missing
I_ITS_REST_REVISION_HISTORY.versioned_ehr_status_revision_history-two_versionscanonical-jsonexpected updated, observed precondition_missingpassed
I_QUERY_SERVICE.execute_ad_hoc_query-empty_db_bare_ehrrow count 100 != expected 1passed
I_QUERY_SERVICE.execute_ad_hoc_query-unknown_ehr_scopeexpected not_found, observed okpassed
I_QUERY_SERVICE.execute_stored_query-fetch_with_topexpected stored, observed bad_requestpassed
SIG-VERSION-ehr_status_signaturesignature: expected present, the ORIGINAL_VERSION envelope carries no signaturepassed

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-Match value is rejected (400 "UUID string too large") — including the server’s own echoed ETag — while only the non-standard unquoted form is accepted. The quoted form dates to Release 1.0.2.
  • Semantic model violations answer 400 instead of 422 (a Release 1.0.1 correction), and some model-invalid documents are accepted outright — an EHR_STATUS without its mandatory archetype details commits as 201.
  • The openehr-audit-details committal header is ignored (both the current and the deprecated spelling), and the stored audit description is itself model-invalid (a DV_TEXT with no value).
  • 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_compositions as a valid example; a stale If-Match on a directory delete answers 404 where the specification requires 412; and 405 responses omit the required Allow header.
  • 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 answers 200 [] for any shorter prefix, so a client cannot discover what a namespace holds.
  • A malformed identifier in the path answers 404, not 400. 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 answer 400 for 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 report Precondition Failed with 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 is deleted returns 500 "An internal error has occurred", where the specification assigns that family a 400 (“the modification type does not match the operation”). The neighbouring row is worse than an error: a creation whose lifecycle state is deleted is 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 throughputworst p99 at the kneeDB peak CPU at the kneeSUT peak RSS at the knee
ferroehr512 req/s134 ms101 %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 (from libxml2), used by the canonical-XML parity tests.

Install the shared git hooks once with bash scripts/install-hooks.sh.

The checks every pull request must pass

CI runs the same set of gates locally and on every pull request — none of them are advisory:

cargo build --workspace
cargo nextest run --workspace          # unit + integration (real PostgreSQL 18)
cargo test --workspace --doc
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all --check
cargo deny check && cargo machete      # deny subsumes cargo-audit (same RustSec DB + yanked/licenses/bans/sources)
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 EDIT header is produced by the code generator; change the generator and regenerate instead. And never weaken, skip, or delete a test to make a build pass, or edit a test to route around a bug it exposes.

A few more conventions worth knowing before you open a pull request:

  • Branch from develop, and target your pull request at develop.
  • 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.