Introduction
EHRbase-rs 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.
EHRbase-rs implements that standard natively. It speaks the openEHR REST API (ITS-REST 1.0.3), 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 — currently CORE: PASS, STANDARD: PASS, OPTIONS: OBTAINED, with zero failing cases. See Conformance.
- The latest openEHR specifications, generated directly from the official machine-readable models: REST API 1.0.3, 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
EHRbase-rs is built in two layers. A specification layer is generated deterministically from openEHR’s published models — the Reference Model types, canonical JSON/XML serialization, the REST contract, and the AQL front end. On top of it sits the application — the server, the PostgreSQL-native storage, the AQL execution engine, validation, and security. The System architecture chapter walks through this in user terms; if you are new to openEHR itself, start with the openEHR primer.
Where to go next
- Just want to try it? Getting started takes you from
docker compose upto a stored composition and an AQL result in a few minutes. - Deploying it? Installation covers Docker Compose, Kubernetes/Helm, building from source, and the full configuration reference.
- Integrating an application? Using the API and Querying with AQL are the core reference for client developers.
- Modelling clinical data? Templates & validation explains how templates drive what the server will accept.
Note
EHRbase-rs 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. It is licensed under Apache-2.0.
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 EHRbase-rs 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
ehrbase/ehrbaseand 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 (ehrbase) 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/ehrbase.dev.toml,
which ships one Basic-auth user (ehrbase / ehrbase) and one admin user
(ehrbase-admin / ehrbase) 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/ehrbase/rest/status
All clinical API routes live under the base path
/ehrbase/rest/openehr/v1. Interactive OpenAPI documentation is served at
http://localhost:8080/ehrbase/swagger-ui, and the full endpoint reference is
published on the documentation site under /ehrbase-rs/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 ehrbase:ehrbase -X POST -i \
http://localhost:8080/ehrbase/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 ehrbase:ehrbase \
-H 'Content-Type: application/xml' \
--data-binary @my-template.opt \
http://localhost:8080/ehrbase/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 ehrbase:ehrbase \
http://localhost:8080/ehrbase/rest/openehr/v1/definition/template/adl1.4
# Fetch the WebTemplate for one template
curl -u ehrbase:ehrbase \
-H 'Accept: application/openehr.wt+json' \
http://localhost:8080/ehrbase/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 ehrbase:ehrbase \
-H 'Content-Type: application/json' \
-H 'Prefer: return=representation' \
--data-binary @my-composition.json \
http://localhost:8080/ehrbase/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 ehrbase:ehrbase \
-H 'Content-Type: application/json' \
-d '{"q":"SELECT e/ehr_id/value FROM EHR e"}' \
http://localhost:8080/ehrbase/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 ehrbase:ehrbase -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/ehrbase/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/ehrbase/swagger-ui to browse and try every
endpoint from your browser, or read the static API reference on the
documentation site (the API tab, under /ehrbase-rs/api/) for the complete
contract.
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 EHRbase-rs is built, if the terms above were unfamiliar.
Installation
EHRbase-rs 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
EHRBASE_*environment variable, grouped by area, with types, defaults, and meaning.
Whichever path you take, the server is configured entirely through EHRBASE_*
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 EHRbase-rs 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
EHRbase-rs publishes two container images to GHCR:
| Image | Contents |
|---|---|
ghcr.io/rubentalstra/ehrbase-rs | The ehrbase server binary. A distroless, non-root, shell-less multi-arch image (amd64 + arm64). Configured entirely via EHRBASE_* environment variables. |
ghcr.io/rubentalstra/ehrbase-rs-postgres | postgres:18.4 with the application role, the layered group roles (ehrbase_migrator, ehrbase_app, ehrbase_reader), database, schemas (ehr, ext), and required extensions (uuid-ossp, pgcrypto, pg_trgm, btree_gist) pre-created, so the app role never needs superuser. |
The PostgreSQL image is init-scripts only — it creates roles, schemas, and extensions, but does not bake in migration state. The server owns the schema content and applies its migrations idempotently at every boot, so a fresh database self-provisions and a restart is a no-op.
Note
PostgreSQL init scripts run only when the data volume is empty. If you see startup notices like
skipping role creation (no CREATEROLE privilege)orroles absent, your volume predates the image’s role setup (or you are running a plainpostgresimage): either recreate the volume (docker compose down -v— destroys data) or create the three group roles once by hand as a superuser. The server runs fine either way — the grants are a defense-in-depth layer, not a functional requirement.
Bringing up the stack
docker compose up --build
This starts two core services:
ehrbase-postgres— the database image, with a named data volume and apg_isreadyhealthcheck.ehrbase— the server, which waits for the database to report healthy (depends_on: condition: service_healthy), then boots, migrates, and serves on port 8080. Its healthcheck is the binary’s ownhealthchecksubcommand (there is no shell in the image).
The server’s development configuration is mounted read-only from
docker/ehrbase.dev.toml and pointed at by EHRBASE_REST_CONFIG. That file
enables Basic auth with the throwaway users ehrbase / ehrbase and
ehrbase-admin / ehrbase, and turns on permissive CORS — development only.
Warning
PostgreSQL 18’s official image stores data in a major-version subdirectory, so the data volume mounts at
/var/lib/postgresql(the parent), not the pre-18/var/lib/postgresql/data. The bundled Compose file already does this correctly; keep the convention if you adapt it.
Environment variables
The Compose file reads these host environment variables (with the defaults shown), so you can retune without editing it:
| Variable | Default | Effect |
|---|---|---|
EHRBASE_IMAGE | ghcr.io/rubentalstra/ehrbase-rs:local | Server image to run (set to a published tag to skip the build). |
EHRBASE_POSTGRES_IMAGE | ghcr.io/rubentalstra/ehrbase-rs-postgres:local | Database image to run. |
EHRBASE_PORT | 8080 | Host port mapped to the server. |
EHRBASE_DB_PORT | 5432 | Host port mapped to PostgreSQL. |
EHRBASE_DB_USER / EHRBASE_DB_PASSWORD / EHRBASE_DB_NAME | ehrbase | App role, password, and database created by the DB image’s init script. |
POSTGRES_PASSWORD | postgres | Bootstrap superuser password (init only). |
EHRBASE_LOG_FORMAT | pretty | Log rendering for docker compose logs. Set json for log collectors. |
The server container itself is passed EHRBASE_DB_URL (assembled from the
DB variables) and EHRBASE_REST_CONFIG. Any other EHRBASE_* setting from the
configuration reference can be added under the ehrbase
service’s environment: block.
Optional services
The Compose file also defines two services the quickstart does not depend on — the server defaults to Basic auth and inline multimedia, so neither is required:
seaweedfs— an S3 gateway for largeDV_MULTIMEDIAexternalization (development/test only). To try it, setEHRBASE_MULTIMEDIA_ENABLED=true,EHRBASE_MULTIMEDIA_ENDPOINT=http://seaweedfs:8333,EHRBASE_MULTIMEDIA_BUCKET=openehr-multimedia, and (dev only)EHRBASE_MULTIMEDIA_ALLOW_HTTP=true. In production, point the multimedia settings at a real, credentialed, HTTPS S3 endpoint instead.keycloak— an OIDC provider with a preloadedehrbaserealm, on port 8081. To use bearer auth instead of Basic, point the auth OIDC settings athttp://localhost:8081/auth/realms/ehrbase.
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
This is the easiest way to see the server’s metrics and traces without wiring up a collector by hand. See Operations for what the server exports and how to consume it in production.
Next
- Configuration reference — every setting you can pass.
- Kubernetes & Helm — the production deployment.
Kubernetes & Helm
The deploy/helm/ehrbase-rs chart deploys EHRbase-rs 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, 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 ehrbase create secret generic ehrbase-db \
--from-literal=EHRBASE_DB_URL='postgres://ehrbase_app:***@pg-host:5432/ehrbase?sslmode=verify-full'
helm install ehrbase-rs deploy/helm/ehrbase-rs -n ehrbase \
--set database.existingSecret=ehrbase-db \
--set image.tag=3.0.0
Always pin image.tag to an immutable version or, better, a @sha256 digest —
never latest. Every values.yaml key documents the exact EHRBASE_*
environment variable it maps to; the configuration reference
is the full list.
Database roles — who runs migrations
The chart expects a four-role PostgreSQL model, so the runtime pod is never a superuser:
| Role | Purpose |
|---|---|
| owner | owns the database (provisioning only) |
ehrbase_migrator | runs the append-only schema migrations |
ehrbase_app | day-to-day reads and writes — the running pod connects as this |
ehrbase_reader | read-only, for replicas and reporting |
The binary calls its migrations on boot, so you choose one of two flows:
- (a) Grant the runtime DSN the migrator role — simplest for single-tenant or small deployments; the pod migrates itself at startup.
- (b) Run migrations out of band with a migrator DSN (a CI step or a
one-shot
Job), then start the pods with the lower-privilegedehrbase_appDSN — recommended for least-privilege production. Gate the Deployment rollout on the migration step so two server versions never race the schema.
The chart does not ship a migration Job; migrations.runByMigratorRole is an
informational marker surfaced in the install NOTES.
Secrets and mounted config
Some settings do not fit cleanly in environment variables — the Basic-auth user
store, a full OIDC block, RBAC role-claim lists, ABAC policies, the external
terminology provider map, ATNA TLS certificates, and the PGP signing key. Supply
these as files via config.files, which the chart mounts read-only from a
Secret at /etc/ehrbase/<key>; point the matching EHRBASE_*_CONFIG /
*_PATH variable at the file through extraEnv. Secret-bearing scalar values
(DB DSN, HMAC secret, broker URLs, S3 keys, PGP passphrase) go into the chart’s
Secret, never the ConfigMap.
Security posture
The chart pins — and its validate.sh gate asserts on every render — the
following:
| Field | Value |
|---|---|
runAsNonRoot | true (uid/gid 65532, the distroless nonroot user) |
readOnlyRootFilesystem | true (a writable emptyDir is mounted at /tmp) |
allowPrivilegeEscalation | false |
capabilities.drop | [ALL] |
seccompProfile.type | RuntimeDefault (pod and container) |
| ServiceAccount token | not mounted (the workload never calls the K8s API) |
| NetworkPolicy | default-deny ingress; only the API (and management) port admitted |
Egress restriction is opt-in (networkPolicy.egress.enabled) because egress
targets — the database, broker, terminology server — are deployment-specific;
when you enable it the chart always admits DNS and you add rules for the rest.
The database-side controls (TLS with sslmode=verify-full, pgaudit, at-rest
encryption, WAL archiving / PITR) belong to whoever provisions PostgreSQL — the
chart references them but cannot enforce them. See
Operations.
Health probes
Probes use the management surface’s public, unauthenticated, PHI-free health routes:
| Probe | Route | Contract |
|---|---|---|
| liveness | {management.basePath}/health/liveness | 200 while the process is up |
| readiness | {management.basePath}/health/readiness | 200 (UP/DEGRADED) or 503 (DOWN): checks DB ping, migrations applied, audit sender, events — each 1s-bounded |
| startup | liveness route | gates a slow first boot |
Because the bare binary ships the management surface off, the chart turns it
on (management.enabled=true, management.probesEnabled=true) as a deliberate
deployment deviation — the probe routes carry no PHI. Set management.port to
serve the management surface on its own internal listener, so /management is
never reachable on the clinical API port. To probe without HTTP (management
off), set probes.exec.enabled=true to use the binary’s healthcheck
subcommand instead.
Set metrics.enabled=true to expose {management.basePath}/prometheus
(access level public) with the prometheus.io/* scrape annotations. The
JSON /management/metrics, /info, /env, and /loggers endpoints stay
admin_only or off unless you opt in.
Optional integrations
Every integration is off by default, matching the binary — enabling one is
an explicit, auditable decision. Each has a values.yaml switch and maps to a
config env prefix:
| Integration | Values key | Notes |
|---|---|---|
| ADMIN API | rest.adminEnabled | Physical, irreversible delete. Gate behind admin RBAC. |
| Terminology extension API | rest.terminologyEnabled | 404 when off. |
| Event-subscription API | rest.eventSubscriptionEnabled | Admin CRUD over event filters. |
| Multi-tenancy | tenancy.enabled | Tenant from a JWT claim; leave tenancy.header unset in prod. Pairs with PG row-level security. |
| OAuth2/OIDC auth | auth.oidc.* | Prefer JWKS/discovery over an HS256 secret. |
| ABAC | authz.abac.enabled | Cedar or a remote policy decision point; policies via a mounted TOML. |
| Eventing → AMQP | events.enabled | Envelopes are PHI-free by design. Use amqps:///tls=true. |
| FHIR inbound/façade | rest.fhirEnabled | Read façade + inbound mapping. |
| FHIR outbound → AMQP | fhirOutbound.enabled | ⚠ Carries PHI (the mapped FHIR resource). Separate exchange; TLS broker only. |
| S3 multimedia | multimedia.enabled | ⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket. |
| External terminology | externalTerminology.enabled | FHIR terminology server; provider map via a mounted TOML. |
| ATNA system log | atna.enabled | Use transport: tls for PHI-adjacent audit. |
| Version signing | signing.* | On by default (digest). pgp mode fails closed at boot without a usable key. |
| OTLP telemetry | telemetry.otel.* | Unset endpoint ⇒ the OTel layer is not installed (zero overhead). |
Full detail on each is in Beyond the core, Security & multi-tenancy, and Operations.
Upgrades
Migrations are append-only — a schema change is a new file, never an edit to
an applied one — so a rolling upgrade stays compatible with the previous schema
during the window where both versions run: additive DDL first, destructive
changes in a later release once all pods are on the new version. Keep
replicaCount >= 2 (or autoscaling) and the default PodDisruptionBudget so
upgrades and node drains never fully interrupt the API; the default
terminationGracePeriodSeconds covers the binary’s shutdown drain. Roll back by
re-pinning the prior image tag or digest.
Render and validate the chart before applying with deploy/helm/validate.sh
(helm lint + template + the security-field gate + golden-render diff).
From source
You can build the ehrbase binary yourself — for a platform without a
published image, for local development, or to run the test suite. This chapter
covers the prerequisites and the build. Most operators should prefer the
published container images (Docker Compose,
Kubernetes & Helm); build from source when you need to.
Prerequisites
- The pinned Rust toolchain. The repository pins Rust 1.96.1 (edition
2024) via
rust-toolchain.toml, sorustupinstalls and selects it automatically the first time you build in the checkout — you do not choose a version by hand. - Docker — required only for the integration tests, which spin up a real PostgreSQL 18 in a container.
xmllint— required only for the canonical-XML tests.
Building
From the repository root:
cargo build --workspace
To build just the server binary in release mode (what the container image ships):
cargo build --release --locked -p ehrbase
The resulting binary is target/release/ehrbase. 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 EHRBASE_* environment variables
(see the configuration reference). At minimum it needs a
database URL:
export EHRBASE_DB_URL='postgres://ehrbase:ehrbase@localhost:5432/ehrbase'
target/release/ehrbase
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
EHRbase-rs is configured entirely through EHRBASE_* environment variables,
optionally backed by TOML files for values that do not fit cleanly in env (a
Basic-auth user store, a full OIDC block, ABAC policies, and so on). This
chapter is the complete reference: how configuration loads, the two naming
conventions you must know, and a table per area listing every key with its
type, default, and meaning. Everything here is drawn from the server’s own
configuration code.
- How configuration loads
- Server (REST)
- Authentication
- Authorization (RBAC + ABAC)
- Database
- Telemetry and logging
- Management surface
- Version signing
- System log (ATNA auditing)
- Change events (AMQP outbox)
- FHIR outbound emitter
- S3 multimedia externalization
- External terminology validation
- Process / CLI
- What belongs in a mounted file
How configuration loads
There is no single global configuration object. The server is composed of independent modules, each of which loads its own settings from three layers, in increasing precedence:
- Built-in defaults (the values in the tables below),
- an optional TOML file for that module (pointed at by an
EHRBASE_<AREA>_CONFIGvariable), then EHRBASE_<AREA>_-prefixed environment variables, which win.
The development Compose stack, for example, points EHRBASE_REST_CONFIG at
docker/ehrbase.dev.toml to supply the Basic-auth user store (which env cannot
carry as a list), while everything else stays on defaults or env overrides.
Warning
Two naming conventions. Most modules use a double underscore (
__) to separate nested fields —EHRBASE_REST_AUTH__ENABLEDmaps toauth.enabled. There are two exceptions:
- Telemetry is flat:
EHRBASE_OTEL_*andEHRBASE_LOG_*have no nesting.- Management uses a single underscore for its one nested group:
EHRBASE_MANAGEMENT_ENDPOINTS_HEALTH, not__.Getting the separator wrong is the most common configuration mistake.
Enum values are case-sensitive on the wire. Where a column lists
enum{a,b,c}, use exactly those lowercase (or snake_case) tokens. Secret
values (EHRBASE_SIGNING_KEY_PASSPHRASE, EHRBASE_REST_AUTH__OIDC__HMAC_SECRET,
Basic-auth password hashes) are redacted from the management /env endpoint and
from logs.
Server (REST)
Prefix EHRBASE_REST_, separator __, optional file EHRBASE_REST_CONFIG.
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_REST_CONFIG | path | none | Path to the REST TOML config file (loaded before env). |
EHRBASE_REST_BIND | socket address | 0.0.0.0:8080 | Address the API listener binds. |
EHRBASE_REST_BASE_PATH | string | /ehrbase/rest/openehr/v1 | Base path all API routes hang off. |
EHRBASE_REST_SWAGGER_UI | boolean | true | Serve Swagger UI + the OpenAPI JSON. Consider off in production. |
EHRBASE_REST_CORS_PERMISSIVE | boolean | false | Enable a permissive (development) CORS policy. |
EHRBASE_REST_ADMIN__ENABLED | boolean | false | Mount the ADMIN API group (routes 404 when off). |
EHRBASE_REST_TERMINOLOGY__ENABLED | boolean | false | Mount the terminology extension API group. |
EHRBASE_REST_EVENT_SUBSCRIPTION__ENABLED | boolean | false | Mount the event-subscription admin extension API. |
EHRBASE_REST_FHIR__ENABLED | boolean | false | Mount the FHIR R4 inbound/façade routes. |
EHRBASE_REST_TENANCY__ENABLED | boolean | false | Activate multi-tenancy (tenant middleware + row-level scoping). |
EHRBASE_REST_TENANCY__CLAIM | string | tenant | JWT-claim path carrying the tenant key. |
EHRBASE_REST_TENANCY__HEADER | string | none | Dev-only request-header tenant override. Leave unset in production — a client header must not select a tenant. |
Authentication
Nested under EHRBASE_REST_AUTH__ (part of the REST config). The Basic-auth
user store is a list and is realistically supplied via the mounted TOML file;
the env forms are shown for completeness.
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_REST_AUTH__ENABLED | boolean | true | Master auth switch. false = all requests pass unauthenticated (development only). |
EHRBASE_REST_AUTH__ADMIN_SCOPE | string | none | Deprecated back-compat scope→role alias; still consulted by the management admin gate. |
EHRBASE_REST_AUTH__BASIC__USERS | list of {username, password_hash, roles} | none (Basic off) | Basic-auth user store. Passwords are Argon2 PHC hashes; per-user roles default to ["USER"]. Set via TOML. |
EHRBASE_REST_AUTH__OIDC__ISSUER | URL | none (bearer off) | Expected token issuer (iss); also the OIDC discovery base. |
EHRBASE_REST_AUTH__OIDC__AUDIENCES | list | [] (not checked) | Accepted aud values. |
EHRBASE_REST_AUTH__OIDC__ALGORITHMS | list | ["RS256"] | Accepted JWT signature algorithms. |
EHRBASE_REST_AUTH__OIDC__HMAC_SECRET | string (secret) | none | Symmetric HS256 secret (development/test). Prefer JWKS/discovery in production. |
EHRBASE_REST_AUTH__OIDC__JWKS_JSON | string (JSON) | none | Static JWKS document; preferred over discovery when present. |
Authorization (RBAC + ABAC)
Prefix EHRBASE_AUTHZ_, separator __, optional file EHRBASE_AUTHZ_CONFIG.
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_AUTHZ_CONFIG | path | none | Path to the authz TOML config file. |
EHRBASE_AUTHZ_RBAC__ENABLED | boolean | true | Coarse role gate (active only when auth is enabled). |
EHRBASE_AUTHZ_RBAC__ADMIN_ROLE | string | ADMIN | Role required for admin-class operations. |
EHRBASE_AUTHZ_RBAC__USER_ROLE | string | USER | Baseline clinical role. |
EHRBASE_AUTHZ_RBAC__ROLE_CLAIMS | list | ["realm_access.roles","scope"] | JWT claim paths mined for roles. |
EHRBASE_AUTHZ_RBAC__MANAGEMENT_ACCESS | enum{admin_only,private,public} | admin_only | Access level for the management surface. |
EHRBASE_AUTHZ_ABAC__ENABLED | boolean | false | Master ABAC (attribute-based) switch. |
EHRBASE_AUTHZ_ABAC__ENGINE | enum{cedar,remote} | cedar | Policy engine: embedded Cedar or a remote decision point. |
EHRBASE_AUTHZ_ABAC__ORGANIZATION_CLAIM | string | organization_id | JWT claim carrying the caller’s organization. |
EHRBASE_AUTHZ_ABAC__PATIENT_CLAIM | string | patient_id | JWT claim carrying the patient id (blank disables the subject gate). |
EHRBASE_AUTHZ_ABAC__CEDAR__POLICY_DIR | path | none | Directory of *.cedar policy files (required for the cedar engine). |
EHRBASE_AUTHZ_ABAC__CEDAR__RELOAD_SECS | integer | none | Optional Cedar hot-reload interval (seconds). |
EHRBASE_AUTHZ_ABAC__REMOTE__SERVER | URL | none | Remote decision-point base URL (required for the remote engine). |
EHRBASE_AUTHZ_ABAC__REMOTE__CONNECT_TIMEOUT_MS | integer (ms) | 2000 | Remote-PDP connect timeout. |
EHRBASE_AUTHZ_ABAC__REMOTE__REQUEST_TIMEOUT_MS | integer (ms) | 5000 | Remote-PDP request timeout. |
Database
Prefix EHRBASE_DB_, no nesting, environment-only (no config file).
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_DB_URL | URL | none (required) | PostgreSQL connection URL, postgres://user:pass@host:port/db. DATABASE_URL is accepted as a fallback. |
EHRBASE_DB_MAX_CONNECTIONS | integer | 10 | Upper bound of the connection pool. |
EHRBASE_DB_MIN_CONNECTIONS | integer | 0 | Idle connections the pool keeps open. |
EHRBASE_DB_ACQUIRE_TIMEOUT_SECS | integer (s) | 30 | Wait for a free connection before failing. |
Note
EHRBASE_DB_NAME,EHRBASE_DB_USER, andEHRBASE_DB_PASSWORDare not read by the server — they configure the PostgreSQL init image. The server takes a singleEHRBASE_DB_URL.
Telemetry and logging
Prefixes EHRBASE_OTEL_ and EHRBASE_LOG_. Flat (no __ nesting),
environment-only.
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_OTEL_OTLP_ENDPOINT | URL | none | OTLP/gRPC collector endpoint. Unset = the OTel layer is not installed (zero overhead). |
EHRBASE_OTEL_SERVICE_NAME | string | ehrbase | service.name resource attribute. |
EHRBASE_OTEL_ENVIRONMENT | string | dev | deployment.environment resource attribute. |
EHRBASE_OTEL_TRACES_SAMPLE_RATIO | float | 1.0 | Head-sampling ratio. |
EHRBASE_OTEL_METRICS_PUSH | boolean | false | Also push metrics over OTLP (alongside Prometheus pull). |
EHRBASE_LOG_FORMAT | enum{auto,json,pretty} | auto | Log rendering. json for cluster log collectors; auto picks JSON when stdout is not a TTY. |
EHRBASE_LOG_FILTER | string | info,ehrbase=info | Log-filter directives (RUST_LOG is the fallback when unset). |
Management surface
Prefix EHRBASE_MANAGEMENT_, single-underscore nesting for endpoints,
optional file EHRBASE_MANAGEMENT_CONFIG. Off in the bare binary; the Helm
chart turns it on for probes.
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_MANAGEMENT_CONFIG | path | none | Path to the management TOML config file. |
EHRBASE_MANAGEMENT_ENABLED | boolean | false | Master switch; off = no management routes mounted. |
EHRBASE_MANAGEMENT_BASE_PATH | string | /management | Base path for the management endpoints. |
EHRBASE_MANAGEMENT_PORT | integer (u16) | none | Serve management on its own listener/port instead of the main API listener. |
EHRBASE_MANAGEMENT_ACCESS_DEFAULT | enum{off,admin_only,private,public} | admin_only | Global default access level (a per-endpoint level wins). |
EHRBASE_MANAGEMENT_PROBES_ENABLED | boolean | false | Mount the public /health/liveness + /health/readiness probes. |
EHRBASE_MANAGEMENT_ENDPOINTS_HEALTH | enum{off,admin_only,private,public} | off | Access level of /management/health. |
EHRBASE_MANAGEMENT_ENDPOINTS_INFO | enum{off,admin_only,private,public} | off | Access level of /management/info. |
EHRBASE_MANAGEMENT_ENDPOINTS_METRICS | enum{off,admin_only,private,public} | off | Access level of /management/metrics. |
EHRBASE_MANAGEMENT_ENDPOINTS_PROMETHEUS | enum{off,admin_only,private,public} | off | Access level of /management/prometheus. |
EHRBASE_MANAGEMENT_ENDPOINTS_ENV | enum{off,admin_only,private,public} | off | Access level of /management/env (redacted config). |
EHRBASE_MANAGEMENT_ENDPOINTS_LOGGERS | enum{off,admin_only,private,public} | off | Access level of /management/loggers (runtime log control). |
Version signing
Prefix EHRBASE_SIGNING_, separator __, optional file
EHRBASE_SIGNING_CONFIG. On by default in digest mode.
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_SIGNING_CONFIG | path | none | Path to the signing TOML config file. |
EHRBASE_SIGNING_ENABLED | boolean | true | Server-side signing of committed versions. |
EHRBASE_SIGNING_MODE | enum{digest,pgp} | digest | SHA-256 integrity digest, or an OpenPGP detached signature. |
EHRBASE_SIGNING_KEY_PATH | path | none | Armored RFC 4880 secret key (required for pgp). |
EHRBASE_SIGNING_KEY_PASSPHRASE | string (secret) | none | Key passphrase (kept in memory, never serialized). |
EHRBASE_SIGNING_VERIFY_ON_READ | enum{off,warn,strict} | off | Read-time recompute-and-compare policy. |
Warning
pgpmode fails closed at boot if the key is missing or unusable — the server will not start. Verify the key and passphrase before switching modes.
System log (ATNA auditing)
Prefix EHRBASE_ATNA_, separator __, optional file EHRBASE_ATNA_CONFIG.
Off by default.
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_ATNA_CONFIG | path | none | Path to the ATNA TOML config file. |
EHRBASE_ATNA_ENABLED | boolean | false | Master ATNA audit switch. |
EHRBASE_ATNA_ENTERPRISE_SITE_ID | string | none | Enterprise/site id (AuditEnterpriseSiteID). |
EHRBASE_ATNA_REPOSITORY_HOST | string | localhost | Audit Record Repository (ARR) host. |
EHRBASE_ATNA_REPOSITORY_PORT | integer (u16) | 514 | ARR port (514 UDP / 6514 TLS typical). |
EHRBASE_ATNA_TRANSPORT | enum{udp,tls} | udp | Syslog transport to the ARR. Use tls for PHI-adjacent audit. |
EHRBASE_ATNA_SOURCE_ID | string | ehrbase | Audit source id. |
EHRBASE_ATNA_VALUE_IF_MISSING | string | UNKNOWN | Fill value for empty mandatory fields. |
EHRBASE_ATNA_SUPPRESS_LOGIN_EVENTS | boolean | true | Skip auth/login activity events. |
EHRBASE_ATNA_FAIL_MODE | enum{open,closed} | open | On undeliverable audit: succeed and meter (open) or reject with 503 (closed). |
EHRBASE_ATNA_RESOLVE_SUBJECT | boolean | false | Enrich the patient participant via a subject lookup. |
EHRBASE_ATNA_QUEUE_CAPACITY | integer | 1024 | Bounded audit queue capacity. |
EHRBASE_ATNA_SERVER_HOST | string | none | This node’s advertised address (NetworkAccessPointID). |
EHRBASE_ATNA_TLS_CA_PATH | path | none | PEM CA file to trust for TLS transport. |
EHRBASE_ATNA_TLS_IDENTITY_CERT_PATH | path | none | Client-certificate PEM for mutual TLS. |
EHRBASE_ATNA_TLS_IDENTITY_KEY_PATH | path | none | Client-key PEM for mutual TLS. |
Change events (AMQP outbox)
Prefix EHRBASE_EVENTS_, separator __, optional file EHRBASE_EVENTS_CONFIG.
Off by default. Envelopes are PHI-free by design.
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_EVENTS_CONFIG | path | none | Path to the events TOML config file. |
EHRBASE_EVENTS_ENABLED | boolean | false | Spawn the outbox publisher. |
EHRBASE_EVENTS_URL | AMQP URL | amqp://guest:guest@localhost:5672/%2f | RabbitMQ broker URL. |
EHRBASE_EVENTS_EXCHANGE | string | ehrbase.events | Topic exchange for PHI-free event envelopes. |
EHRBASE_EVENTS_TLS | boolean | false | Upgrade an amqp:// URL to amqps://. |
EHRBASE_EVENTS_BATCH_SIZE | integer | 128 | Outbox rows drained per poll. |
EHRBASE_EVENTS_POLL_INTERVAL_MS | integer (ms) | 1000 | Poll interval when the outbox is idle. |
EHRBASE_EVENTS_RETENTION_DAYS | integer (days) | 7 | Published-row retention window. |
EHRBASE_EVENTS_PRUNE_INTERVAL_SECS | integer (s) | 3600 | Retention-prune cadence. |
EHRBASE_EVENTS_PUBLISH_MAX_RETRIES | integer | 3 | Per-row publish retries before backing off. |
FHIR outbound emitter
Prefix EHRBASE_FHIR_OUTBOUND_, separator __, optional file
EHRBASE_FHIR_OUTBOUND_CONFIG. Off by default.
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_FHIR_OUTBOUND_CONFIG | path | none | Path to the FHIR-outbound TOML config file. |
EHRBASE_FHIR_OUTBOUND_ENABLED | boolean | false | Enable the FHIR resource emitter. |
EHRBASE_FHIR_OUTBOUND_URL | AMQP URL | amqp://guest:guest@localhost:5672/%2f | Broker URL. |
EHRBASE_FHIR_OUTBOUND_EXCHANGE | string | ehrbase.fhir | Topic exchange (separate from events, for PHI isolation). |
EHRBASE_FHIR_OUTBOUND_TLS | boolean | false | Upgrade an amqp:// URL to amqps://. |
EHRBASE_FHIR_OUTBOUND_BATCH_SIZE | integer | 128 | Outbox rows scanned per poll. |
EHRBASE_FHIR_OUTBOUND_POLL_INTERVAL_MS | integer (ms) | 1000 | Poll interval when idle. |
EHRBASE_FHIR_OUTBOUND_PUBLISH_MAX_RETRIES | integer | 3 | Per-message publish retries before backing off. |
Warning
This 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.
S3 multimedia externalization
Prefix EHRBASE_MULTIMEDIA_, separator __, optional file
EHRBASE_MULTIMEDIA_CONFIG. Off by default (blobs stay inline, byte-identical).
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_MULTIMEDIA_CONFIG | path | none | Path to the multimedia TOML config file. |
EHRBASE_MULTIMEDIA_ENABLED | boolean | false | Externalize large multimedia data to an object store. |
EHRBASE_MULTIMEDIA_THRESHOLD_BYTES | integer (bytes) | 262144 (256 KiB) | Decoded size strictly above which data is offloaded. |
EHRBASE_MULTIMEDIA_ENDPOINT | URL | none | S3-compatible endpoint. None = AWS default resolution. |
EHRBASE_MULTIMEDIA_BUCKET | string | openehr-multimedia | Target bucket. |
EHRBASE_MULTIMEDIA_REGION | string | us-east-1 | AWS region (required even for non-AWS endpoints). |
EHRBASE_MULTIMEDIA_ACCESS_KEY_ID | string | none | S3 access key id (none + no secret = anonymous). |
EHRBASE_MULTIMEDIA_SECRET_ACCESS_KEY | string (secret) | none | S3 secret access key. |
EHRBASE_MULTIMEDIA_ALLOW_HTTP | boolean | false | Allow plain-HTTP endpoints — development/test only. |
External terminology validation
Prefix EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_, separator __, optional file
EHRBASE_VALIDATION_CONFIG. Off by default (the in-process openEHR bundle is
used). Providers are a map keyed by a provider name (below shown as <NAME>,
conventionally default).
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_VALIDATION_CONFIG | path | none | Path to the terminology-validation TOML config file. |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_ENABLED | boolean | false | Activate external terminology validation. |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_FAIL_ON_ERROR | boolean | false | On TS/connectivity error, reject (fail-closed) vs accept (fail-open). |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__TYPE | enum{fhir} | fhir | Provider kind (FHIR R4). |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__URL | URL | none (required) | FHIR R4 base URL of the terminology server. |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__OPERATION | enum{validate_code,expand} | validate_code | Value-set membership operation. |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__CONNECT_TIMEOUT_MS | integer (ms) | 2000 | Per-provider connect timeout. |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__REQUEST_TIMEOUT_MS | integer (ms) | 10000 | Per-provider request timeout. |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__OAUTH2_CLIENT | string | none | Name of an OAuth2 client-credentials client for the provider. |
Process / CLI
| Key | Type | Default | Description |
|---|---|---|---|
EHRBASE_HEALTHCHECK_URL | URL | http://127.0.0.1:8080/ehrbase/rest/status | Target URL for the binary’s healthcheck subcommand (container HEALTHCHECK and Kubernetes exec probes). |
What belongs in a mounted file
Env variables cannot carry lists or nested structures cleanly. Put these in the
module’s TOML file (via EHRBASE_<AREA>_CONFIG, or the Helm chart’s
config.files) instead:
- the Basic-auth user store (
[[auth.basic.users]]), - a full OIDC block with multiple audiences/algorithms,
- RBAC role-claim lists and ABAC (Cedar) policies,
- the external terminology provider map,
- ATNA TLS certificate paths and the PGP signing key.
See docker/ehrbase.dev.toml in the repository for a worked example of the
REST config file (bind address, CORS, admin, and the Basic-auth users).
Concepts
This part explains the ideas you need to use EHRbase-rs 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 EHRbase-rs 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 EHRbase-rs — 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. EHRbase-rs 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.
EHRbase-rs 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.
EHRbase-rs 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 EHRbase-rs realises them.
System architecture
This chapter explains how EHRbase-rs is built and where your data lives, in practical terms. You do not need any of it to use the API — but it clarifies why the server behaves the way it does: why compliance claims are trustworthy, why versioning is exact, and why AQL is fast. Two ideas run through everything: the openEHR specification layer is generated from the official models rather than hand-written, and the storage is designed natively for PostgreSQL 18.
Two layers
flowchart TB
specs["openEHR machine-readable specifications<br/>(Reference Model · XML schemas · OpenAPI — vendored & pinned)"]
subgraph gen ["Specification layer (generated, never hand-edited)"]
types["RM 1.2.0 types · canonical JSON & XML<br/>ITS-REST 1.0.3 contract · 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. EHRbase-rs 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 EHRbase-rs 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. EHRbase-rs 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, EHRbase-rs 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). EHRbase-rs 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
EHRbase-rs exposes the openEHR REST API (ITS-REST 1.0.3): 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 /ehrbase-rs/api/); this book explains how to use
it.
Base path
All clinical API routes hang off a configurable base path, which defaults to:
/ehrbase/rest/openehr/v1
Every path in these chapters is relative to that base. So “POST /ehr” means
POST http://your-host:8080/ehrbase/rest/openehr/v1/ehr. The base path is set
with EHRBASE_REST_BASE_PATH (see the
configuration reference).
The public, unauthenticated status probe lives just outside the base path at
/ehrbase/rest/status, and interactive docs at /ehrbase/swagger-ui when
enabled.
Authentication
Requests are authenticated unless auth is explicitly disabled. Two mechanisms ship in Stage 1:
- HTTP Basic — a configured user store; send
Authorization: Basic .... The examples in this book use-u user:passwordwith curl. - OAuth2 / OIDC bearer tokens — send
Authorization: Bearer <token>, validated against a configured issuer (Keycloak, Active Directory, any standards-compliant provider).
A request with no or invalid credentials gets 401 Unauthorized; an
authenticated caller lacking the required role gets 403 Forbidden.
Authorization is coarse role-based access control by default (a USER role for
clinical operations, an ADMIN role for admin operations), with optional
attribute-based policies. The full picture — mechanisms, roles, multi-tenancy —
is in Security & multi-tenancy.
Note
The development stack ships throwaway Basic users (
ehrbase/ehrbase). Replace them before any real use.
The chapters here
- Resource walkthroughs — EHR, EHR_STATUS, COMPOSITION, DIRECTORY, and CONTRIBUTION, each with real curl examples, the headers they need, and the status codes they return.
- Content negotiation & errors — choosing JSON or
XML, the
Preferheader,ETag/If-Matchoptimistic concurrency, and the error response shape.
For querying, see Querying with AQL; for loading templates, Templates & validation.
Resource walkthroughs
This chapter walks through the core openEHR resources — EHR, EHR_STATUS,
COMPOSITION, DIRECTORY, and CONTRIBUTION — with real curl examples
you can adapt. For each resource it shows the operations, the headers they need,
and the status codes they return. Paths are relative to the base
/ehrbase/rest/openehr/v1 (see Using the API); examples use Basic
auth (-u ehrbase:ehrbase) 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.
EHR
An EHR is the top-level container for one subject’s health record.
Create an EHR
curl -u ehrbase:ehrbase -X POST -i \
http://localhost:8080/ehrbase/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 ehrbase:ehrbase \
http://localhost:8080/ehrbase/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.
Read the current status
curl -u ehrbase:ehrbase \
http://localhost:8080/ehrbase/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 ehrbase:ehrbase -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/ehrbase/rest/openehr/v1/ehr/$EHR_ID/ehr_status
PUT /ehr/{ehr_id}/ehr_status returns 200 (with representation) or 204
(minimal), plus the new ETag. A stale or wrong If-Match returns 412
Precondition Failed with the current version id in ETag.
Status version history
The versioned_ehr_status sub-resource exposes the full version history:
GET .../versioned_ehr_status— theVERSIONED_EHR_STATUSobject,GET .../versioned_ehr_status/revision_history— the revision history,GET .../versioned_ehr_status/version(optionally?version_at_time=) and.../version/{version_uid}— a specific version.
COMPOSITION
A COMPOSITION is a committed clinical document, validated against its template.
Create a composition
curl -u ehrbase:ehrbase \
-H 'Content-Type: application/json' \
-H 'Prefer: return=representation' \
--data-binary @composition.json \
http://localhost:8080/ehrbase/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 ehrbase:ehrbase \
http://localhost:8080/ehrbase/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 ehrbase:ehrbase -X PUT \
-H 'Content-Type: application/json' \
-H 'If-Match: "<current-version-uid>"' \
--data-binary @composition.json \
http://localhost:8080/ehrbase/rest/openehr/v1/ehr/$EHR_ID/composition/$OBJECT_UUID
# Delete — the URL uses the FULL version id
curl -u ehrbase:ehrbase -X DELETE \
http://localhost:8080/ehrbase/rest/openehr/v1/ehr/$EHR_ID/composition/$VERSION_UID
PUT returns 200/204 (per Prefer) with the new version id, 412 on
an If-Match mismatch, 422 on validation failure. DELETE is a logical
delete — the history is retained — returning 204; deleting something already
deleted returns 400, and a version id that is not the latest returns 409.
Note
Watch the id you pass.
PUTtakes the object uuid (the versioned object), whileDELETEtakes the full version id (the version you are superseding).GETaccepts either.
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. There is one directory per EHR.
# Create the directory
curl -u ehrbase:ehrbase \
-H 'Content-Type: application/json' \
--data-binary @folder.json \
http://localhost:8080/ehrbase/rest/openehr/v1/ehr/$EHR_ID/directory
# Read it (optionally at a time, or a sub-path)
curl -u ehrbase:ehrbase \
'http://localhost:8080/ehrbase/rest/openehr/v1/ehr/'$EHR_ID'/directory?path=episodes/2024'
POST /ehr/{ehr_id}/directory— create the root folder; 201.PUT /ehr/{ehr_id}/directory— update it; requiresIf-Match; 200/204.DELETE /ehr/{ehr_id}/directory— logical delete; requiresIf-Match; 204.GET /ehr/{ehr_id}/directory— the current folder tree, optionally filtered by?version_at_time=and?path=(slash-separated folder names). 204 if deleted at that time.GET /ehr/{ehr_id}/directory/{version_uid}— a specific version, optionally?path=.
CONTRIBUTION
A CONTRIBUTION is an atomic change-set: a group of versioned-object changes (compositions, statuses, folders) committed together with one shared audit. Use it when several changes must land as a unit.
curl -u ehrbase:ehrbase \
-H 'Content-Type: application/json' \
--data-binary @contribution.json \
http://localhost:8080/ehrbase/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.
Note
The contribution envelope is always canonical JSON (or XML). The FLAT and STRUCTURED formats, when used, apply only to the inner composition
dataof each version, not to the envelope.
Status-code summary
| Code | Meaning across these resources |
|---|---|
| 200 | Retrieved, or updated with Prefer: return=representation. |
| 201 | Created (EHR, composition, directory, contribution). |
| 204 | Success with no body (return=minimal), or deleted / deleted-at-time. |
| 400 | Malformed request, missing required header/parameter, or already-deleted. |
| 404 | Unknown EHR, object, version, or no version at the requested time. |
| 409 | Conflict — duplicate subject/id, or a version that is not the latest. |
| 412 | If-Match did not match the latest version (current id returned in ETag). |
| 422 | Composition is well-formed but fails template/semantic validation. |
The Content negotiation & errors chapter covers the error body shape and the headers referenced above in full.
Content negotiation & errors
Three HTTP mechanisms cut across every openEHR resource: choosing the wire
format (JSON or XML), controlling how much a write returns (the Prefer
header), and versioned optimistic concurrency (ETag and If-Match). This
chapter explains all three and the shape of error responses, so the examples in
Resource walkthroughs make sense in general.
JSON and XML
EHRbase-rs speaks canonical JSON and canonical XML for the RM-typed resources. Choose with the standard HTTP headers:
- Request body: set
Content-Type: application/jsonorapplication/xml. - Response: set
Accept: application/jsonorapplication/xml.
JSON is wired end to end for every operation. XML is supported for the
spec-typed RM objects — a single composition, EHR_STATUS, EHR, FOLDER, and
the version family (versioned objects and revision history) — whose canonical
XML shape the openEHR ITS-XML schemas define. Responses that are not a
spec-typed RM value (collections, item tags, and the query and terminology
DTOs) are JSON-only, as is the CONTRIBUTION envelope.
# Commit a composition as XML, ask for XML back
curl -u ehrbase:ehrbase \
-H 'Content-Type: application/xml' \
-H 'Accept: application/xml' \
--data-binary @composition.xml \
http://localhost:8080/ehrbase/rest/openehr/v1/ehr/$EHR_ID/composition
For compositions and templates, several endpoints additionally accept the Better
WebTemplate, FLAT (simSDT), and STRUCTURED (structSDT) JSON media
types — application/openehr.wt+json, application/openehr.wt.flat+json, and
application/openehr.wt.structured+json. These are covered in
Templates & validation.
The query API is JSON only — it does not accept XML or the WebTemplate media types.
The Prefer header
Write operations (create/update) accept a Prefer header controlling the
response body. Its default is return=minimal:
Prefer value | Effect |
|---|---|
return=minimal (default) | Empty body; the identifier is in ETag/Location. Status 204 on update, 201 on create. |
return=representation | The full created/updated resource in the body, status 200/201. |
return=identifier | Just the resource identifier object. |
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.
ETag and If-Match — optimistic concurrency
openEHR objects are versioned, and updates use HTTP preconditions to prevent lost updates:
-
Every read and successful write returns an
ETagheader carrying the object or version identifier (a weak ETag,W/"..."). -
Updating or deleting a versioned object requires an
If-Matchheader set to the current version id, in double quotes: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.
Location may appear on responses too, but treat it as informational for reads
(it is marked deprecated on retrieval responses in the contract); the ETag is
the authoritative identifier.
Tip
The round-trip is: read the resource → keep its
ETagvalue → send it back asIf-Matchon the update → get a newETagfor the version you just created. Never fabricate a version id; always echo the one the server gave you.
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 ..." }
Match on the HTTP status first; read the body for the human-readable detail and, for validation, the per-node list.
Querying with AQL
The Archetype Query Language (AQL 1.1) is how you read data out of
EHRbase-rs. Instead of querying hidden database tables, you query the clinical
model directly: you name the RM types and archetypes you want, express
structural nesting with CONTAINS, and select values by their path within an
archetype. The same query runs unchanged on any conformant openEHR system. This
chapter is a practical walkthrough — the language, how to run queries over
HTTP, parameters, stored queries, version scope, terminology, pagination, and
the supported feature envelope.
- The shape of a query
- Running a query over HTTP
- The result set
- Parameters
- Stored queries
- Version scope: LATEST_VERSION and ALL_VERSIONS
- Terminology in queries
- Pagination and limits
- What is supported
The shape of a query
An AQL statement has the familiar SELECT … FROM … WHERE … ORDER BY skeleton,
but the “tables” are RM types and the “columns” are archetype paths:
SELECT
c/name/value AS composition_name,
o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic
FROM EHR e
CONTAINS COMPOSITION c
CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140
ORDER BY systolic DESC
FROMbinds variables to RM types (EHR e,COMPOSITION c,OBSERVATION o). A type can be constrained by archetype id in square brackets (OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]).CONTAINSexpresses structural containment — “an EHR that contains a composition that contains a blood-pressure observation”. Chains can nest several deep, and combine withAND,OR, andNOT.SELECTprojects values by path. Paths use archetype node ids (at0004) and RM attribute names (value/magnitude);ASnames a column.WHEREfilters on typed leaf values, with comparisons,EXISTS,LIKE,MATCHES, and boolean combinators.ORDER BY,LIMIT, andOFFSETbehave as you expect; quantities order by their openEHR magnitude semantics.
Running a query over HTTP
The query API lives under the base path at /query/aql. The simplest form is a
POST with a JSON body:
curl -u ehrbase:ehrbase \
-H 'Content-Type: application/json' \
-d '{"q":"SELECT e/ehr_id/value FROM EHR e"}' \
http://localhost:8080/ehrbase/rest/openehr/v1/query/aql
The body fields are:
| Field | Meaning |
|---|---|
q | The AQL text (required). |
offset | Rows to skip (default 0). |
fetch | Maximum rows to return. |
query_parameters | An object of named parameter values (see below). |
There is also a GET /query/aql form taking q, offset, fetch, an optional
ehr_id, and query_parameters as query-string parameters — convenient for
simple, cacheable reads.
The query API is JSON only (Accept: application/json).
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 (schema version, creation
time, the executed AQL).
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 ehrbase:ehrbase -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/ehrbase/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 ehrbase:ehrbase -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/ehrbase/rest/openehr/v1/definition/query/org.example::bp_over/1.0.0
# List and fetch stored queries
curl -u ehrbase:ehrbase \
http://localhost:8080/ehrbase/rest/openehr/v1/definition/query/org.example::bp_over
# Execute it
curl -u ehrbase:ehrbase \
http://localhost:8080/ehrbase/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).GET /definition/query/{name}[/{version}]— list or fetch.GET|POST /query/{name}[/{version}]— execute, taking the sameoffset,fetch, andquery_parametersas ad-hoc queries. A version can be given exactly (1.0.0) or as a prefix (1).
Version scope: LATEST_VERSION and ALL_VERSIONS
By default a query sees the latest version of each object. EHRbase-rs 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. Inside a matches clause,
TERMINOLOGY('expand', …) expands a value set so a coded field matches any code
in it, rather than listing codes by hand. This requires 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. Queries that run too
long return 408 Request Timeout — narrow the query (add archetype
constraints or a WHERE filter) rather than retrying unchanged.
Tip
The more specific your
FROM/CONTAINS(name the archetype, scope byehr_id), the faster the query: those constraints map to indexed columns, while broad “everything that contains anything” queries do the most work.
What is supported
EHRbase-rs implements the core AQL 1.1 envelope and rejects out-of-envelope constructs with an explicit, typed error rather than silently returning wrong results. Supported today includes:
SELECTof paths, literals, aliases,DISTINCT, and the aggregatesCOUNT(includingCOUNT(DISTINCT)),MIN,MAX,SUM,AVG;FROMoverEHR,VERSION(LATEST_VERSION/ALL_VERSIONS), and RM classes with archetype and name predicates;CONTAINStrees withAND,OR, andNOT CONTAINS;WHEREcomparisons on typed leaves (with openEHR magnitude ordering for quantities),EXISTS,LIKE,MATCHESvalue lists, and range predicates;ORDER BYtyped leaves,LIMIT/OFFSET, named query parameters, and theehr_id,offset, andfetchrequest parameters;- terminology-backed
TERMINOLOGY('expand', …)insidematches.
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 EHRbase-rs 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
EHRbase-rs ingests templates in the OPT 1.4 XML format. Upload one with
Content-Type: application/xml:
curl -u ehrbase:ehrbase \
-H 'Content-Type: application/xml' \
--data-binary @vital_signs.opt \
http://localhost:8080/ehrbase/rest/openehr/v1/definition/template/adl1.4
A successful upload returns 201 Created. Uploading a template whose id already exists returns 409 Conflict — templates are immutable once loaded. On upload the server checks the template itself for artefact validity (that its constraints are internally consistent) and rejects an invalid one with 400 Bad Request and the specific error.
List and retrieve loaded templates:
# List all templates
curl -u ehrbase:ehrbase \
http://localhost:8080/ehrbase/rest/openehr/v1/definition/template/adl1.4
# Get the canonical OPT XML for one template
curl -u ehrbase:ehrbase -H 'Accept: application/xml' \
http://localhost:8080/ehrbase/rest/openehr/v1/definition/template/adl1.4/vital_signs
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 ehrbase:ehrbase \
-H 'Accept: application/openehr.wt+json' \
http://localhost:8080/ehrbase/rest/openehr/v1/definition/template/adl1.4/vital_signs
EHRbase-rs follows the widely used Better web-template semantics (format
version 2.3), so tooling built for that model works unchanged.
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.
Composition formats
When committing or retrieving a composition, the canonical openEHR JSON (or XML) is always available, but two flatter formats are offered for convenience, keyed to a template:
-
FLAT (simSDT) —
application/openehr.wt.flat+json. The whole composition as a single flat map ofpath|attribute → value, which is compact and easy to produce from a form. For example:{ "vital_signs/blood_pressure/any_event:0/systolic|magnitude": 120, "vital_signs/blood_pressure/any_event:0/systolic|unit": "mm[Hg]", "vital_signs/blood_pressure/any_event:0/diastolic|magnitude": 80, "vital_signs/blood_pressure/any_event:0/diastolic|unit": "mm[Hg]", "vital_signs/language|code": "en", "vital_signs/language|terminology": "ISO_639-1" } -
STRUCTURED (structSDT) —
application/openehr.wt.structured+json. The same data as a nested JSON tree that mirrors the template structure, rather than a flat map.
Send the matching Content-Type when committing, or the matching Accept when
retrieving, and the server converts between the flat/structured form and the
canonical composition. These formats are a Better/EHRbase interoperability
convenience; the canonical JSON and XML remain the openEHR-standard wire format.
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.
Validation on commit
Every composition is validated against its template at commit time — this is where the template earns its keep. The server checks:
- structure — required sections and fields are present, and cardinality and occurrence constraints are respected;
- leaf values — data types, units, value ranges, string patterns, decimal precision, and date/time constraints match the template;
- terminology — coded values are members of the value sets the template binds, using the bundled openEHR terminology or a configured external FHIR terminology server (see Terminology servers).
If a composition is well-formed but breaks its template, the commit fails with
422 Unprocessable Entity and a validationErrors list — one entry per
offending node, as "<path>: <message>" — so a client can show the user exactly
what to fix. A syntactically malformed request instead gets 400 Bad Request.
The error shapes are described in
Content negotiation & errors.
Next
- Using the API → Resource walkthroughs — the full composition create/update/delete lifecycle.
- Querying with AQL — reading the data back out by path.
Beyond the core
The core of EHRbase-rs 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.
- Change events (AMQP) — a transactional outbox that publishes a PHI-free, at-least-once, per-EHR-ordered event for every commit to AMQP/RabbitMQ, so downstream systems can respond to changes.
- FHIR connectors — mapping-driven inbound ingestion of FHIR R4 resources, a read façade that returns openEHR data as FHIR, and event-driven outbound emission of mapped FHIR resources.
- S3 multimedia — threshold-based, content-addressed
offload of large
DV_MULTIMEDIAblobs to any S3-compatible object store, with integrity verification and expand-on-read.
Security, multi-tenancy, and the audit trail are covered separately in Security & multi-tenancy; running the server in production — including the observability and health surfaces the integrations feed — is covered in Operations.
EHR Extract & messaging
Moving a patient’s record between openEHR systems — migrating to another CDR, replicating an EHR to a downstream repository, or importing an externally produced document — is what openEHR’s EHR Extract and messaging services are for. EHRbase-rs 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.0.3 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.
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. EHRbase-rs 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 openEHR ITS-REST 1.0.3 contract does not define a demographic wire API, so this surface is served by direct analogy with the EHR group and is an Options-profile capability, not part of the Core or Standard profile. Party relationships specifically are an EHRbase-rs extension.
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 (/ehrbase/rest/openehr/v1), and
{kind} is one of agent, group, organisation, person, or role.
| Method | Path | Purpose |
|---|---|---|
POST | /demographic/{kind} | create a party |
GET | /demographic/{kind}/{uid_based_id} | read a party |
PUT | /demographic/{kind}/{uid_based_id} | update a party |
DELETE | /demographic/{kind}/{uid_based_id} | delete a party |
GET | /demographic/versioned_party/{versioned_object_uid} | the versioned container |
GET | /demographic/versioned_party/{versioned_object_uid}/revision_history | revision history |
GET | /demographic/versioned_party/{versioned_object_uid}/version | version at time (query parameter) |
GET | /demographic/versioned_party/{versioned_object_uid}/version/{version_uid} | a specific version |
Party changes can also be committed and read as contributions
(POST /demographic/contribution,
GET /demographic/contribution/{contribution_uid}), and parties support item
tags (/demographic/tags, /demographic/{kind}/{uid_based_id}/tags, and
DELETE …/tags/{key}).
Relationships
Party relationships are managed through a parallel set of routes (an EHRbase-rs extension), with the same versioned shape as parties:
| Method | Path | Purpose |
|---|---|---|
POST | /demographic/party_relationship | create a relationship |
GET/PUT/DELETE | /demographic/party_relationship/{uid_based_id} | read / update / delete |
GET | /demographic/versioned_party_relationship/{versioned_object_uid} | the versioned container |
GET | /demographic/versioned_party_relationship/{versioned_object_uid}/revision_history | revision history |
GET | /demographic/versioned_party_relationship/{versioned_object_uid}/version[/{version_uid}] | version at time / by id |
The demographic endpoints are always mounted (not behind a feature switch), and are subject to the same authentication and authorization as the rest of the API — see Security & multi-tenancy.
Terminology servers
openEHR records carry coded values — a diagnosis, a route of administration, a laboratory unit. Some codes come from openEHR’s own terminology; others must be validated against an external code system such as SNOMED CT or LOINC. EHRbase-rs 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
EHRBASE_REST_TERMINOLOGY__ENABLED=true, and it serves:
| Method | Path | Purpose |
|---|---|---|
GET | /terminology | list terminologies |
GET | /terminology/{terminology_id} | describe one terminology |
GET | /terminology/{terminology_id}/term/{code} | look up a term |
GET | /terminology/{terminology_id}/subsumes?ref_code=&candidate= | subsumption test |
GET | /terminology/{terminology_id}/value_set/{value_set_id} | get a value set |
GET | /terminology/{terminology_id}/value_set/{value_set_id}/validate?candidate_code=&at_date= | validate a code |
(All paths are relative to the API base path, /ehrbase/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. EHRbase-rs 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. Configuration uses the
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_ prefix, with __ separating nested
keys (providers are a map, so complex blocks are usually supplied through a
mounted TOML file referenced by EHRBASE_VALIDATION_CONFIG):
| Key | Meaning |
|---|---|
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_ENABLED | master switch (default false) |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_FAIL_ON_ERROR | on a server error: true rejects (fail-closed), false accepts (fail-open) |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__TYPE | provider type — fhir (R4) |
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__URL | the FHIR base URL, e.g. http://terminology:8090/fhir |
A provider can carry per-provider OAuth2 client-credentials and mutual-TLS settings for servers that require them. A short worked example, pointing the CDR at a HAPI FHIR container over Docker Compose:
services:
ehrbase:
environment:
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_ENABLED: "true"
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__DEFAULT__TYPE: "fhir"
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__DEFAULT__URL: "http://terminology:8090/fhir"
Tip
A FHIR terminology server starts empty. Seed the value sets your templates reference by uploading their CodeSystem and ValueSet resources over plain FHIR REST (
PUTto/fhir/CodeSystem/<id>and/fhir/ValueSet/<id>); value sets are expanded on upload, so validation answers from the pre-computed expansion.
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.
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, EHRbase-rs 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/>ehrbase.events"]
consumer["your consumer<br/>(bound queue)"]
commit --> tx
tx --> node
tx --> outbox
drain -->|"poll pending, publish, await confirm"| broker
outbox -.->|"drained in seq order"| drain
broker --> consumer
consumer -.->|"fetch bodies via authenticated API"| commit
The event envelope
Each published message is JSON (application/json). One contribution can touch
several versioned objects, and the publisher emits one message per version,
each under its own routing key. The envelope carries:
| Field | Meaning |
|---|---|
contribution_id | the contribution this change belongs to |
ehr_id | the EHR (may be null for a demographic contribution) |
committed_at | the commit instant |
versions[] | one entry per changed versioned object |
seq | the delivery sequence number (monotonic) |
version_index | which entry in versions this message is for |
Each versions[] entry has vo_id, kind (the RM type — COMPOSITION,
EHR_STATUS, FOLDER, EHR_ACCESS), sys_version, change_type (a numeric
audit change-type code — 249 creation, 251 modification, 523 deleted,
666 attestation), and template_id (or null).
Tip
Deduplicate on the pair
(contribution_id, version_index)and process inseqorder. That handles the at-least-once redelivery and preserves per-EHR ordering at the consumer.
Routing keys and subscriptions
Messages are published to a topic exchange (default name ehrbase.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 (EHRBASE_REST_EVENT_SUBSCRIPTION__ENABLED), each enabled
subscription row causes the server to declare and bind a durable queue named
<exchange>.<name> (for the default exchange, ehrbase.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. Configuration uses the EHRBASE_EVENTS_ prefix
(the server loads configuration from defaults, an optional TOML file, then
environment variables, with __ separating nested keys):
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_EVENTS_ENABLED | false | master switch |
EHRBASE_EVENTS_URL | amqp://guest:guest@localhost:5672/%2f | broker connection URL |
EHRBASE_EVENTS_EXCHANGE | ehrbase.events | topic exchange name (also the queue-name prefix) |
EHRBASE_EVENTS_TLS | false | when true, upgrades an amqp:// URL to amqps:// |
EHRBASE_EVENTS_BATCH_SIZE | 128 | rows drained per cycle |
EHRBASE_EVENTS_POLL_INTERVAL_MS | 1000 | poll interval while the outbox is idle |
EHRBASE_EVENTS_PUBLISH_MAX_RETRIES | 3 | retries per message before the batch stops |
EHRBASE_EVENTS_RETENTION_DAYS | 7 | how long published rows are kept |
EHRBASE_EVENTS_PRUNE_INTERVAL_SECS | 3600 | how often published rows are pruned |
Warning
The broker URL carries credentials, so keep it in a secret, not a plain environment file. For anything beyond a local broker, use a TLS connection (
EHRBASE_EVENTS_TLS=trueor anamqps://URL). The commit path never blocks on the broker — if it is down, events buffer in the outbox and drain when it recovers.
Consuming events
A minimal consumer declares nothing new — it binds a queue to the exchange and reads. In shell form with the RabbitMQ tooling:
# bind a queue to every composition creation, then consume
rabbitmqadmin declare queue name=my-consumer durable=true
rabbitmqadmin declare binding source=ehrbase.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. EHRbase-rs 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
(/ehrbase/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 ehrbase.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 (
ehrbase.fhir, notehrbase.events): broker access control can then isolate the PHI-bearing stream. Enable it only against a TLS, access-controlled broker, and treat every consumer as a PHI processor.
Mappings are data you manage
There are no bundled mapping files. Each mapping is a stored definition managed through an admin API (classed under admin authorization):
| Method | Path | Purpose |
|---|---|---|
GET | /admin/fhir_mapping | list mappings |
POST | /admin/fhir_mapping | create a mapping (201) |
GET | /admin/fhir_mapping/{mapping_id} | get a mapping |
PUT | /admin/fhir_mapping/{mapping_id} | update a mapping |
DELETE | /admin/fhir_mapping/{mapping_id} | delete a mapping (204) |
A mapping definition binds one FHIR resource type (optionally scoped to a
meta.profile URL) to one openEHR template, and lists field bindings —
each mapping an openEHR FLAT path to a FHIR path (or a constant), shaped by a
transform (plain text, date, quantity with unit, or a coded value with a
code-system-to-terminology mapping). The FHIR-path support is a deliberate
subset covering field navigation and array indexing (for example
component[1].valueQuantity.value), and the mapping is symmetric: the same
definition drives inbound ingest, the read façade, and outbound emission.
Note
The template a mapping references must already be ingested (see Templates & validation) — creating a mapping against an unknown template is a
400. Mapping names are immutable once set, and a duplicate name is a409.
Enabling the connectors
Both switches are off by default. The inbound/read-façade switch lives in the REST config; the outbound emitter has its own config group:
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_REST_FHIR__ENABLED | false | enable inbound ingest, the read façade, and the mapping API |
EHRBASE_FHIR_OUTBOUND_ENABLED | false | enable outbound emission to AMQP |
EHRBASE_FHIR_OUTBOUND_URL | amqp://guest:guest@localhost:5672/%2f | outbound broker URL |
EHRBASE_FHIR_OUTBOUND_EXCHANGE | ehrbase.fhir | outbound topic exchange (kept distinct from the event stream) |
EHRBASE_FHIR_OUTBOUND_TLS | false | upgrade an amqp:// URL to amqps:// |
EHRBASE_FHIR_OUTBOUND_BATCH_SIZE | 128 | commits drained per cycle |
EHRBASE_FHIR_OUTBOUND_POLL_INTERVAL_MS | 1000 | poll interval while idle |
EHRBASE_FHIR_OUTBOUND_PUBLISH_MAX_RETRIES | 3 | retries per message |
When the inbound switch is off, the /fhir/r4/* and /admin/fhir_mapping
routes answer 404 without touching the backend. When the outbound switch is
off, no emitter task runs.
S3 multimedia
Clinical records sometimes carry large binary attachments — scanned documents,
images, waveforms — as DV_MULTIMEDIA values. Keeping big blobs inline in the
database bloats storage and slows queries. EHRbase-rs can transparently offload
large multimedia blobs to any S3-compatible object store, keeping only a small
content-addressed reference in the composition, and re-materialize them on
demand when a record is read back.
How offload works
Offload is a commit-path transformation applied to DV_MULTIMEDIA nodes
(including a node’s nested thumbnail, which is itself a multimedia value):
- A node qualifies only when it is purely inline (it has
dataand nouri) and its decoded byte length is strictly greater than the configured threshold. A value at or below the threshold stays inline; a value that already references external media (has auri) is stored verbatim, never touched. - The raw decoded bytes are written to the object store under a key that is the SHA-256 hash of those bytes (lowercase hex). Because the key is the content hash, identical blobs deduplicate automatically, and the upload is a no-op if the key already exists.
- The node is rewritten in place: its inline
datais removed and replaced with auriof the forms3://<bucket>/<hash>, plus anintegrity_check(the SHA-256 digest), anintegrity_check_algorithmcode phrase (SHA-256), and the originalsize.
Uploads happen before anything is persisted, so a failed upload aborts the commit — a record is never half-stored.
Note
What lives where after offload: the object store holds the blob bytes; the composition in PostgreSQL holds a compact, spec-legal
DV_MULTIMEDIAthat points at the blob by content hash. Everything remains canonical openEHR JSON — thes3://reference and integrity fields are standard RM attributes.
Reading blobs back
By default a read returns the stored (offloaded) form — the compact reference.
To get the inline bytes back, request expansion on the read
(?expand_multimedia=true). The server fetches each of its own externalized
blobs (only URIs of the exact form s3://<configured-bucket>/<hash> are
treated as its own; foreign https:// or other-bucket references are left
alone), verifies the SHA-256 hash of the fetched bytes against the key, and
only then re-inlines the data. A hash mismatch is a hard error, so a
corrupted or tampered blob is never silently served.
Enabling it
Offload is off by default. Configuration uses the EHRBASE_MULTIMEDIA_ prefix
(defaults, then an optional TOML file, then environment variables):
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_MULTIMEDIA_ENABLED | false | master switch |
EHRBASE_MULTIMEDIA_THRESHOLD_BYTES | 262144 (256 KiB) | offload blobs larger than this; smaller stay inline |
EHRBASE_MULTIMEDIA_ENDPOINT | unset | S3 endpoint URL (unset uses AWS default resolution) |
EHRBASE_MULTIMEDIA_BUCKET | openehr-multimedia | target bucket |
EHRBASE_MULTIMEDIA_REGION | us-east-1 | S3 region |
EHRBASE_MULTIMEDIA_ACCESS_KEY_ID | unset | access key (see note on credentials) |
EHRBASE_MULTIMEDIA_SECRET_ACCESS_KEY | unset | secret key |
EHRBASE_MULTIMEDIA_ALLOW_HTTP | false | permit plain-HTTP endpoints (development only) |
If both the access key and secret are unset, the client runs unsigned (anonymous) — the mode a local development SeaweedFS accepts with no credentials. Set both to use signed requests against a real store.
Warning
Offloaded blobs are PHI. In production the bucket must be private, encrypted, and reached over HTTPS (
EHRBASE_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 EHRBASE_MULTIMEDIA_ENABLED=true
export EHRBASE_MULTIMEDIA_ENDPOINT=http://127.0.0.1:8333
export EHRBASE_MULTIMEDIA_BUCKET=openehr-multimedia
export EHRBASE_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 EHRbase-rs: 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 an optional TOML file, then environment variables, with __ separating
nested keys. The three security configuration groups use distinct prefixes —
EHRBASE_REST_ (authentication and tenancy), EHRBASE_AUTHZ_ (authorization),
and EHRBASE_ATNA_ (audit) — and each also accepts a TOML file path
(EHRBASE_REST_CONFIG, EHRBASE_AUTHZ_CONFIG, EHRBASE_ATNA_CONFIG).
Authentication
Authentication is on by default (EHRBASE_REST_AUTH__ENABLED=true). Setting
it to false lets all requests through unauthenticated — a development-only
mode.
There is no single “mode” switch. The server offers two mechanisms and enables each by the presence of its configuration block:
- HTTP Basic is active when a
basicblock with a user list is configured. Each user has a username, an Argon2 password hash (a PHC string beginning$argon2id$), and a set of roles (default["USER"]). Because it is a list of users, the Basic block is normally supplied through the TOML configuration file rather than environment variables. - OAuth2/OIDC bearer tokens are active when an
oidcblock is configured. The server validates the token’s signature, issuer, and (optionally) audience.
The OIDC settings:
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_REST_AUTH__OIDC__ISSUER | — (required to enable OIDC) | expected iss, and the OIDC discovery base |
EHRBASE_REST_AUTH__OIDC__AUDIENCES | empty (not checked) | accepted aud values |
EHRBASE_REST_AUTH__OIDC__ALGORITHMS | ["RS256"] | accepted signing algorithms |
EHRBASE_REST_AUTH__OIDC__HMAC_SECRET | unset | an HS256 symmetric secret (development/testing) |
EHRBASE_REST_AUTH__OIDC__JWKS_JSON | unset | a static JWKS document |
There is no separate JWKS or discovery URL to set: the server discovers the
JWKS URI from the issuer’s .well-known/openid-configuration unless you supply
a static JWKS_JSON (preferred when present) or an HMAC_SECRET.
Tip
Keycloak example. Point the issuer at your realm and let discovery do the rest:
export EHRBASE_REST_AUTH__OIDC__ISSUER=https://keycloak.example/realms/ehrbase export EHRBASE_REST_AUTH__OIDC__AUDIENCES=ehrbase-apiThe same pattern works for Active Directory or any standards-compliant identity provider. Prefer JWKS/discovery over a shared HS256 secret in production.
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 two composable layers. The coarse layer is always on when authentication is enabled; the fine-grained layer is opt-in.
RBAC (role-based, coarse)
Every operation is classified as Public, Clinical, Management, or Admin, and a
role model gates each class. Roles are plain, case-insensitive strings; the
defaults are USER (the baseline clinical role) and ADMIN.
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_AUTHZ_RBAC__ENABLED | true | the coarse role gate (active only when auth is enabled) |
EHRBASE_AUTHZ_RBAC__ADMIN_ROLE | ADMIN | role required for admin operations |
EHRBASE_AUTHZ_RBAC__USER_ROLE | USER | the baseline clinical role |
EHRBASE_AUTHZ_RBAC__ROLE_CLAIMS | ["realm_access.roles","scope"] | JWT claim paths mined for roles |
EHRBASE_AUTHZ_RBAC__MANAGEMENT_ACCESS | admin_only | management-surface access: admin_only, private, or public |
Roles come from the JWT claims listed in ROLE_CLAIMS — by default the
Keycloak realm_access.roles array plus the space-separated scope claim —
or from a Basic user’s configured roles. A clinical operation needs at least one
role; an admin operation needs the admin role; the management surface follows
its tri-state setting. Disabling RBAC restores authentication-only behaviour.
ABAC (attribute-based, fine-grained)
For attribute-level decisions — “may this user touch this patient’s data, under this organisation, for this template?” — enable ABAC. A policy decision point is consulted per clinical operation with resolved attributes.
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_AUTHZ_ABAC__ENABLED | false | master ABAC switch |
EHRBASE_AUTHZ_ABAC__ENGINE | cedar | cedar (embedded) or remote (external PDP) |
EHRBASE_AUTHZ_ABAC__ORGANIZATION_CLAIM | organization_id | JWT claim for the organisation attribute |
EHRBASE_AUTHZ_ABAC__PATIENT_CLAIM | patient_id | JWT claim for the patient attribute (enables the subject gate) |
EHRBASE_AUTHZ_ABAC__CEDAR__POLICY_DIR | — (required for cedar) | directory of .cedar policy files |
EHRBASE_AUTHZ_ABAC__CEDAR__RELOAD_SECS | off | optional policy hot-reload interval |
EHRBASE_AUTHZ_ABAC__REMOTE__SERVER | — (required for remote) | PDP base URL (must end with /) |
EHRBASE_AUTHZ_ABAC__REMOTE__CONNECT_TIMEOUT_MS | 2000 | PDP connect timeout |
EHRBASE_AUTHZ_ABAC__REMOTE__REQUEST_TIMEOUT_MS | 5000 | PDP request timeout |
Two engines sit behind one interface. Cedar is the embedded default:
policies live in .cedar files, are schema-validated at boot (an invalid
policy set stops the server rather than silently denying), and need no
external service. The remote PDP option consults an external policy server
over HTTP for deployments that already run one.
Warning
Authorization is fail-closed: if the policy engine is unreachable or a policy cannot be evaluated, the request is refused (mapped to
500), never permitted. When a patient claim is configured, a local subject gate also rejects access to another patient’s EHR before any policy call. A denied decision is a403.
Multi-tenancy
Multi-tenancy lets one deployment host several isolated logical openEHR
systems, each with its own system_id. It is off by default; when off, the
server behaves byte-for-byte as a single-tenant system.
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_REST_TENANCY__ENABLED | false | enable multi-tenancy |
EHRBASE_REST_TENANCY__CLAIM | tenant | the JWT claim (a dotted path) carrying the tenant key |
EHRBASE_REST_TENANCY__HEADER | unset | a development header override for the tenant |
A request’s tenant is resolved from the configured JWT claim (a dotted path
such as realm_access.tenant is walked through nested objects). Isolation is
enforced in the database with PostgreSQL row-level security: the resolved
tenant scopes the connection so a query can only ever see its own tenant’s
rows.
Warning
Leave
EHRBASE_REST_TENANCY__HEADERunset in production — a client-supplied header must never be able to select a tenant; the tenant must come from the authenticated token. Isolation is also fail-safe by design: an absent or unresolvable tenant runs unscoped against a reserved default rather than guessing, and a cross-tenant access surfaces as an empty result set, never a403that would leak the existence of another tenant’s data.
ATNA audit trail
Separately from openEHR’s own provenance, EHRbase-rs can emit an IHE ATNA
security audit trail: one DICOM Audit Message (DICOM PS3.15 §A.5) per audited
operation, describing who did what to which resource, with what
outcome, from where, and when. Records are shipped to an Audit Record
Repository over syslog (RFC 5424 framing), transported over UDP (RFC 5426) or
TLS (RFC 5425). Every server operation is audited, and authentication failures
(401/403) are always recorded.
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_ATNA_ENABLED | false | master switch |
EHRBASE_ATNA_REPOSITORY_HOST | localhost | audit repository host |
EHRBASE_ATNA_REPOSITORY_PORT | 514 | audit repository port |
EHRBASE_ATNA_TRANSPORT | udp | udp or tls |
EHRBASE_ATNA_ENTERPRISE_SITE_ID | unset | enterprise/site identifier |
EHRBASE_ATNA_SOURCE_ID | ehrbase | audit source identifier |
EHRBASE_ATNA_VALUE_IF_MISSING | UNKNOWN | fill for an empty mandatory field |
EHRBASE_ATNA_SUPPRESS_LOGIN_EVENTS | true | skip the successful-login records |
EHRBASE_ATNA_FAIL_MODE | open | open (drop and continue) or closed (reject auditable ops if undeliverable) |
EHRBASE_ATNA_RESOLVE_SUBJECT | false | enrich the patient identifier from stored data |
EHRBASE_ATNA_QUEUE_CAPACITY | 1024 | bounded in-memory audit queue |
EHRBASE_ATNA_SERVER_HOST | unset | this node’s advertised network address |
EHRBASE_ATNA_TLS_CA_PATH | unset | PEM of the repository CA (TLS) |
EHRBASE_ATNA_TLS_IDENTITY_CERT_PATH | unset | client certificate PEM (mutual TLS) |
EHRBASE_ATNA_TLS_IDENTITY_KEY_PATH | unset | client key PEM (mutual TLS) |
For PHI-adjacent audit, use EHRBASE_ATNA_TRANSPORT=tls with a CA (and, where
the repository requires it, a client certificate and key for mutual TLS). The
fail_mode choice is a policy decision: open never blocks a clinical
request when the repository is down (records are dropped and metered), while
closed refuses auditable operations with 503 rather than proceed
unaudited.
Note
The ATNA trail is orthogonal to openEHR’s own
CONTRIBUTIONandAUDIT_DETAILS, which the server always writes in the same transaction as every change. openEHR audit records what a version says about its own authorship; ATNA records security surveillance of API access. Both coexist. Identified data never enters telemetry (metrics, traces, logs) — see Operations — so the audit trail is the single place where access to identified data is recorded.
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, health probes, and the management surface — drawn from how the container image and Helm chart are built to run.
- Database roles and least privilege
- Applying migrations
- TLS and database security
- Backup and point-in-time recovery
- The container image and pod hardening
- Upgrades
- Observability
- Health probes and the management surface
Database roles and least privilege
EHRbase-rs connects to an external PostgreSQL 18 — a managed service or an operator-run cluster, never a chart-side sidecar, because a database holding PHI must be independently backed up and recoverable. The server carries only a connection string, ideally sourced from a secret.
The database uses a four-role model — never a superuser at runtime:
| Role | Purpose | Used by |
|---|---|---|
| owner | owns the database | provisioning only |
ehrbase_migrator | runs the schema migrations; owns the helper functions | the migration step |
ehrbase_app | reads and writes clinical data | the running server |
ehrbase_reader | read-only | replicas and reporting |
The migrations create these roles idempotently, apply the per-schema grants,
and revoke the ability to create objects in the public schema. The running
server connects as ehrbase_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
ehrbase_appDSN — recommended for least-privilege production. Run the migration as a CI/CD step or a one-shot job with the migrator credential before rolling the deployment, and gate the rollout so two versions never race the schema.
TLS and database security
These are database-side settings that belong to whoever provisions PostgreSQL; the deployment references them but cannot enforce them:
- TLS in transit. Require
hostsslon the server and put?sslmode=verify-fullin the DSN so the client verifies the server certificate. - pgaudit. Run pgaudit as the database-layer complement to the openEHR
audit and the ATNA trail — for example
pgaudit.log = 'ddl, role, connection'globally plus object-level audit on the PHI tables — and ship the audit log to an immutable store with long (roughly six-year) retention. - Encryption at rest. Encrypt at the volume or disk layer. Do not encrypt the stored clinical JSON with pgcrypto — it would break AQL’s ability to query inside the data.
Backup and point-in-time recovery
Enable WAL archiving and point-in-time recovery from day one (pgBackRest or a
managed PITR), because a CDR’s data is not reconstructible. Clinical and audit
tables are never UNLOGGED. Test your restore, not just your backup.
The container image and pod hardening
The published image is distroless and non-root — shell-less, with no package manager — and is multi-architecture (amd64 and arm64) on GHCR. When run under Kubernetes with the provided Helm chart, the pod is hardened and the chart’s validation asserts these on every render:
| Setting | Value |
|---|---|
| run as non-root | uid/gid 65532 |
| read-only root filesystem | yes (a writable emptyDir at /tmp) |
| privilege escalation | disallowed |
| Linux capabilities | all dropped |
| seccomp | RuntimeDefault |
| service-account token | not mounted (the workload never calls the Kubernetes API) |
| NetworkPolicy | default-deny ingress; only the API/management port admitted |
Egress restriction is opt-in because egress targets (database, broker, terminology server) are deployment-specific. See Installation → Kubernetes & Helm for the chart itself.
Upgrades
- Backward-compatible migrations. Migrations are append-only and never edited once applied. A rolling upgrade must be compatible with the previous schema for the window where both versions run: apply additive changes first, and defer destructive changes to a later release once every pod is on the new version.
- Lock-safe DDL. The migration runner bounds DDL with a
lock_timeoutandstatement_timeoutso a migration cannot block live traffic indefinitely; on a busy table useCREATE INDEX CONCURRENTLYand add constraintsNOT VALIDthenVALIDATElater. - Pin the image. Deploy an immutable tag or, better, a
@sha256digest, neverlatest; roll back by re-pinning the prior digest — the schema’s backward compatibility makes that safe. - Stay available. Keep at least two replicas (or autoscaling) and a pod disruption budget so node drains and upgrades never fully interrupt the API. The default 30-second termination grace period covers the server’s short shutdown drain of the audit and event outboxes.
Observability
tracing is the single instrumentation API. From it, three signal families
fan out, and identified data never enters any of them — telemetry uses
only closed-set labels and opaque request/trace ids, so correlation to a
patient is possible only through the
ATNA audit trail.
- Logs go to stdout — JSON when not attached to a terminal, pretty on a
TTY — each line stamped with the trace and span id. Shipping and rotation
are the platform’s job.
EHRBASE_LOG_FORMAT(auto/json/pretty) andEHRBASE_LOG_FILTER(orRUST_LOG, defaultinfo,ehrbase=info) control them, and the level can be changed at runtime through theloggersendpoint below. - 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, database pool state, AQL query counts and latency, compositions committed, validation failures, and the audit pipeline’s health.
The telemetry environment variables:
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_OTEL_OTLP_ENDPOINT | unset (layer not installed) | OTLP collector endpoint |
EHRBASE_OTEL_SERVICE_NAME | ehrbase | reported service name |
EHRBASE_OTEL_ENVIRONMENT | dev | reported deployment environment |
EHRBASE_OTEL_TRACES_SAMPLE_RATIO | 1.0 | head sampling ratio (start at 0.1 in production) |
EHRBASE_OTEL_METRICS_PUSH | false | also push metrics over OTLP |
Tip
A single-container dev stack (
grafana/otel-lgtm, bundling an OTLP collector, Prometheus, Tempo, Grafana, and Loki) ships as a Compose overlay, together with a provisioned Grafana dashboard (request rate/errors/duration, database pool, AQL latency, validation failures, audit health) and a starter alert pack — point the server at it with the two OTLP variables above.
Health probes and the management surface
The management surface — health, info, metrics, and runtime log control — 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.
The Helm chart turns the health probes on by default because they carry no
PHI.
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_MANAGEMENT_ENABLED | false | enable the management surface |
EHRBASE_MANAGEMENT_BASE_PATH | /management | base path for the surface |
EHRBASE_MANAGEMENT_PORT | unset (main listener) | serve management on its own port |
EHRBASE_MANAGEMENT_ACCESS_DEFAULT | admin_only | default access level |
EHRBASE_MANAGEMENT_PROBES_ENABLED | false | expose the liveness/readiness probes as public |
The probe and ops endpoints:
| Endpoint | Purpose | Default access |
|---|---|---|
GET {base}/health/liveness | process is up (200), no I/O | public when probes enabled |
GET {base}/health/readiness | 200 (up/degraded) or 503 (down): database ping, migrations applied, audit sender, events — each bounded to one second | public when probes enabled |
GET {base}/health | aggregate component health | admin_only |
GET {base}/info | build, version, and pinned spec versions | admin_only |
GET {base}/prometheus | Prometheus text exposition | admin_only (re-expose to the scraper via network policy) |
GET {base}/metrics | JSON registry view | admin_only |
GET {base}/env | effective configuration, with secrets redacted | admin_only |
GET/POST/DELETE {base}/loggers | read and change the log level at runtime | admin_only |
Note
Under Kubernetes, wire liveness and startup to the liveness route and readiness to the readiness route; readiness reports
503when the database is unreachable or migrations are not applied, so a pod is only sent traffic once it can serve. If you keep the management surface off, the container’sehrbase healthchecksubcommand can back an exec probe instead. The public/rest/statusproduct endpoint remains available regardless.
For the full list of configuration keys across every subsystem, see
Installation → Configuration reference; to
explore the API itself, open the API reference at /ehrbase-rs/api/ (also
linked from the toolbar on every page).
Conformance
EHRbase-rs 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 yourself, and how to read the artefacts it produces — the report, the statement, and the certificate.
What is measured
Conformance is checked by the ehrbase-rs Conformance Catalogue (ECC) — an
enumerated set of test cases derived from the openEHR platform specifications
the server implements: every ITS-REST operation and documented status code,
every AQL 1.1 language construct exercised against a corpus with golden result
sets, and the Reference Model data-type and archetype-constraint semantics
turned into accept/reject matrices. Each case has a stable id
(ECC-<AREA>-<NNN>, for example ECC-EHR-005 or ECC-VAL-042), grouped into
areas:
| Area | Scope |
|---|---|
EHR / STA | EHR and EHR_STATUS operations |
COM / CTB / DIR | Composition, contribution (change sets), directory |
TPL / SQR | Template (OPT) and stored-query provisioning |
QRY / VAL | AQL execution and content/archetype validation |
DEM / ADM / MSG | Demographic, admin, and messaging services |
SEC / SIG / TS | Security, version signing, terminology-server integration |
The run is what turns cases into a claim. A profile verdict — Core, Standard, or Options — is computed all-or-nothing per capability directly from the run; no verdict is ever hand-asserted. Every case runs in both wire formats (JSON and XML), so the format is a first-class part of the result. A case that cannot run in the current configuration (for example, a native-API operation with no REST binding) is recorded as skipped with a reason rather than silently omitted.
Note
The catalogue is the project’s own framework, built from the currently pinned specifications (Reference Model 1.2.0, AQL 1.1.0, Terminology 3.1.0, ITS-REST 1.0.3). It is not a port of any external test harness — the vendored openEHR conformance corpus is design-time reading and a source of input payloads only.
The current result
The published run reports:
- 341 case-by-format executions, 315 passed, 0 failed.
- Core: PASS. Standard: PASS. Options: OBTAINED.
The executions that did not pass are documented skips, each with a stated reason, not failures. Options is obtained because it aggregates optional capabilities under an “any passes” rule, and the demographic, terminology, and admin APIs are evidenced.
Running the suite yourself
The suite runs against a real, composed 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:
bash scripts/conformance.sh
The script builds and starts the server and its PostgreSQL 18 database with
Docker Compose, runs the full catalogue in both formats against it, writes the
artefacts to docs/conformance/, and tears the stack down. Exit code 0
means every executed case passed; 1 means there were failures (the report is
still written so you can inspect them); 2 means the runner or the system
under test could not start.
To run against an already-deployed server instead of the composed stack, the runner accepts a base URL and credentials:
conformance run --base-url https://your-host/ehrbase/rest/openehr/v1 \
--auth basic:user:password \
--format both
You can also narrow a run to one area with --filter, or regenerate the
artefacts from a previous run’s machine record without re-running
(conformance report --from results.json).
Reading the artefacts
A run writes one machine record and three human-readable documents to
docs/conformance/. Each has a distinct job.
The machine record
results.json is the single source of truth for a run: one entry per case
with its id, title, capability, the profiles it feeds, the formats exercised,
the outcome, the number of data sets, and its duration, alongside the identity
of the system under test and the specification versions. Every other artefact
is generated from this file — nothing downstream is hand-edited.
The conformance report
CONFORMANCE_REPORT.md is the honest, scoped record of this run: the system
under test and its specification versions, a per-area execution matrix (how
many cases passed, failed, errored, or were skipped in each area), a per-case
detail table, the machine-computed profile verdicts, a failures section, and a
deviations section that lists every skip with its reason. 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 is a self-assessed certificate whose structure
follows 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 and whether each passed. This is the
document to hand to a procurement or evaluation reviewer who wants the
capability-by-capability picture.
Tip
The four conformance badges in the project README (overall, Core, Standard, Options) are generated from the same run. 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. It deliberately does not stand in for a performance benchmark (durations are telemetry only) and does not cover the Better-style FLAT/STRUCTURED interoperability formats, which have their own test suite. Optional capabilities left “not evidenced” in the certificate (for example ADL 2 provisioning or the more advanced AQL constructs) are exactly that — untested in this configuration — and are reported as such rather than claimed.
Contributing
EHRbase-rs is open source (Apache-2.0) and welcomes contributions. This chapter is a short orientation for anyone who wants to file an issue, report a vulnerability, or open a pull request; the authoritative documents live in the repository and are linked below.
Where to start
The three governing documents are kept in the repository root:
- CONTRIBUTING — the practical rules for setup, the required checks, and pull requests.
- Code of conduct — the Contributor Covenant (v2.1) the community follows.
- Security policy — how to report a vulnerability privately.
Setting up
The Rust toolchain is pinned by the repository’s rust-toolchain.toml, so
rustup installs the right version automatically on your first build. Two
extra tools are needed for the full test suite:
- Docker, for the PostgreSQL 18 integration tests (they spin up a real database via testcontainers).
xmllint(fromlibxml2), used by the canonical-XML parity tests.
Install the shared git hooks once with bash scripts/install-hooks.sh.
The checks every pull request must pass
CI runs the same set of gates locally and on every pull request — none of them are advisory:
cargo build --workspace
cargo nextest run --workspace # unit + integration (real PostgreSQL 18)
cargo test --workspace --doc
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all --check
cargo deny check && cargo audit && cargo machete
bash scripts/check-codegen-drift.sh # generated layer matches the vendored specs
Important
Two rules are absolute. Never hand-edit a generated file — anything under a
// @generated … DO NOT EDITheader is produced by the code generator; change the generator and regenerate instead. And never weaken, skip, or delete a test to make a build pass, or edit a test to route around a bug it exposes.
A few more conventions worth knowing before you open a pull request:
- Branch from
develop, and target your pull request atdevelop. - Keep changes focused, and describe what changed and why. For anything that touches openEHR behaviour, cite the relevant specification section.
- Behaviour changes come with tests. Snapshot changes must be reviewed, not blindly accepted.
- Any user-visible change (the REST surface, AQL, validation, configuration, the CLI, or the deployment artifacts) adds an entry to the changelog in the same pull request — a CI guard enforces this.
Reporting issues and vulnerabilities
Use the GitHub issue tracker for bugs and feature requests.
Warning
Do not open a public issue for a suspected security vulnerability. Report it privately through GitHub’s private vulnerability reporting (“Report a vulnerability” on the repository’s Security tab). Because the server handles PHI-class data by design, reports about data exposure through the API, AQL, telemetry, or the audit trail are in scope even when they look like “just configuration”. Coordinated disclosure is preferred — please allow a reasonable window for a fix before publishing details.