Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

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

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 / ehrbase and a permissive CORS policy — this is a development configuration only. See Security & multi-tenancy and the configuration reference before exposing a server.

1. Start the stack

You need Docker with the Compose plugin. From a checkout of the repository:

docker compose up --build

This builds and starts two services: the server (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:

ImageContents
ghcr.io/rubentalstra/ehrbase-rsThe 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-postgrespostgres: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) or roles absent, your volume predates the image’s role setup (or you are running a plain postgres image): either recreate the volume (docker compose down -vdestroys data) or create the three group roles once by hand as a superuser. The server runs fine either way — the grants are a defense-in-depth layer, not a functional requirement.

Bringing up the stack

docker compose up --build

This starts two core services:

  • ehrbase-postgres — the database image, with a named data volume and a pg_isready healthcheck.
  • 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 own healthcheck subcommand (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:

VariableDefaultEffect
EHRBASE_IMAGEghcr.io/rubentalstra/ehrbase-rs:localServer image to run (set to a published tag to skip the build).
EHRBASE_POSTGRES_IMAGEghcr.io/rubentalstra/ehrbase-rs-postgres:localDatabase image to run.
EHRBASE_PORT8080Host port mapped to the server.
EHRBASE_DB_PORT5432Host port mapped to PostgreSQL.
EHRBASE_DB_USER / EHRBASE_DB_PASSWORD / EHRBASE_DB_NAMEehrbaseApp role, password, and database created by the DB image’s init script.
POSTGRES_PASSWORDpostgresBootstrap superuser password (init only).
EHRBASE_LOG_FORMATprettyLog 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 large DV_MULTIMEDIA externalization (development/test only). To try it, set EHRBASE_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 preloaded ehrbase realm, on port 8081. To use bearer auth instead of Basic, point the auth OIDC settings at http://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

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:

RolePurpose
ownerowns the database (provisioning only)
ehrbase_migratorruns the append-only schema migrations
ehrbase_appday-to-day reads and writes — the running pod connects as this
ehrbase_readerread-only, for replicas and reporting

The binary calls its migrations on boot, so you choose one of two flows:

  • (a) Grant the runtime DSN the migrator role — simplest for single-tenant or small deployments; the pod migrates itself at startup.
  • (b) Run migrations out of band with a migrator DSN (a CI step or a one-shot Job), then start the pods with the lower-privileged ehrbase_app DSN — recommended for least-privilege production. Gate the Deployment rollout on the migration step so two server versions never race the schema.

The chart does not ship a migration Job; migrations.runByMigratorRole is an informational marker surfaced in the install NOTES.

Secrets and mounted config

Some settings do not fit cleanly in environment variables — the Basic-auth user store, a full OIDC block, RBAC role-claim lists, ABAC policies, the external terminology provider map, ATNA TLS certificates, and the PGP signing key. Supply these as files via config.files, which the chart mounts read-only from a Secret at /etc/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:

FieldValue
runAsNonRoottrue (uid/gid 65532, the distroless nonroot user)
readOnlyRootFilesystemtrue (a writable emptyDir is mounted at /tmp)
allowPrivilegeEscalationfalse
capabilities.drop[ALL]
seccompProfile.typeRuntimeDefault (pod and container)
ServiceAccount tokennot mounted (the workload never calls the K8s API)
NetworkPolicydefault-deny ingress; only the API (and management) port admitted

Egress restriction is opt-in (networkPolicy.egress.enabled) because egress targets — the database, broker, terminology server — are deployment-specific; when you enable it the chart always admits DNS and you add rules for the rest. The database-side controls (TLS with sslmode=verify-full, pgaudit, at-rest encryption, WAL archiving / PITR) belong to whoever provisions PostgreSQL — the chart references them but cannot enforce them. See Operations.

Health probes

Probes use the management surface’s public, unauthenticated, PHI-free health routes:

ProbeRouteContract
liveness{management.basePath}/health/liveness200 while the process is up
readiness{management.basePath}/health/readiness200 (UP/DEGRADED) or 503 (DOWN): checks DB ping, migrations applied, audit sender, events — each 1s-bounded
startupliveness routegates 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:

IntegrationValues keyNotes
ADMIN APIrest.adminEnabledPhysical, irreversible delete. Gate behind admin RBAC.
Terminology extension APIrest.terminologyEnabled404 when off.
Event-subscription APIrest.eventSubscriptionEnabledAdmin CRUD over event filters.
Multi-tenancytenancy.enabledTenant from a JWT claim; leave tenancy.header unset in prod. Pairs with PG row-level security.
OAuth2/OIDC authauth.oidc.*Prefer JWKS/discovery over an HS256 secret.
ABACauthz.abac.enabledCedar or a remote policy decision point; policies via a mounted TOML.
Eventing → AMQPevents.enabledEnvelopes are PHI-free by design. Use amqps:///tls=true.
FHIR inbound/façaderest.fhirEnabledRead façade + inbound mapping.
FHIR outbound → AMQPfhirOutbound.enabledCarries PHI (the mapped FHIR resource). Separate exchange; TLS broker only.
S3 multimediamultimedia.enabled⚠ Offloaded blobs are PHI. Private, encrypted, HTTPS bucket.
External terminologyexternalTerminology.enabledFHIR terminology server; provider map via a mounted TOML.
ATNA system logatna.enabledUse transport: tls for PHI-adjacent audit.
Version signingsigning.*On by default (digest). pgp mode fails closed at boot without a usable key.
OTLP telemetrytelemetry.otel.*Unset endpoint ⇒ the OTel layer is not installed (zero overhead).

Full detail on each is in Beyond the core, Security & multi-tenancy, and Operations.

Upgrades

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

Render and validate the chart before applying with deploy/helm/validate.sh (helm lint + template + the security-field gate + golden-render diff).

From source

You can build the 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, so rustup installs and selects it automatically the first time you build in the checkout — you do not choose a version by hand.
  • Docker — required only for the integration tests, which spin up a real PostgreSQL 18 in a container.
  • xmllint — required only for the canonical-XML tests.

Building

From the repository root:

cargo build --workspace

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

cargo build --release --locked -p 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

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:

  1. Built-in defaults (the values in the tables below),
  2. an optional TOML file for that module (pointed at by an EHRBASE_<AREA>_CONFIG variable), then
  3. 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__ENABLED maps to auth.enabled. There are two exceptions:

  • Telemetry is flat: EHRBASE_OTEL_* and EHRBASE_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.

KeyTypeDefaultDescription
EHRBASE_REST_CONFIGpathnonePath to the REST TOML config file (loaded before env).
EHRBASE_REST_BINDsocket address0.0.0.0:8080Address the API listener binds.
EHRBASE_REST_BASE_PATHstring/ehrbase/rest/openehr/v1Base path all API routes hang off.
EHRBASE_REST_SWAGGER_UIbooleantrueServe Swagger UI + the OpenAPI JSON. Consider off in production.
EHRBASE_REST_CORS_PERMISSIVEbooleanfalseEnable a permissive (development) CORS policy.
EHRBASE_REST_ADMIN__ENABLEDbooleanfalseMount the ADMIN API group (routes 404 when off).
EHRBASE_REST_TERMINOLOGY__ENABLEDbooleanfalseMount the terminology extension API group.
EHRBASE_REST_EVENT_SUBSCRIPTION__ENABLEDbooleanfalseMount the event-subscription admin extension API.
EHRBASE_REST_FHIR__ENABLEDbooleanfalseMount the FHIR R4 inbound/façade routes.
EHRBASE_REST_TENANCY__ENABLEDbooleanfalseActivate multi-tenancy (tenant middleware + row-level scoping).
EHRBASE_REST_TENANCY__CLAIMstringtenantJWT-claim path carrying the tenant key.
EHRBASE_REST_TENANCY__HEADERstringnoneDev-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.

KeyTypeDefaultDescription
EHRBASE_REST_AUTH__ENABLEDbooleantrueMaster auth switch. false = all requests pass unauthenticated (development only).
EHRBASE_REST_AUTH__ADMIN_SCOPEstringnoneDeprecated back-compat scope→role alias; still consulted by the management admin gate.
EHRBASE_REST_AUTH__BASIC__USERSlist 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__ISSUERURLnone (bearer off)Expected token issuer (iss); also the OIDC discovery base.
EHRBASE_REST_AUTH__OIDC__AUDIENCESlist[] (not checked)Accepted aud values.
EHRBASE_REST_AUTH__OIDC__ALGORITHMSlist["RS256"]Accepted JWT signature algorithms.
EHRBASE_REST_AUTH__OIDC__HMAC_SECRETstring (secret)noneSymmetric HS256 secret (development/test). Prefer JWKS/discovery in production.
EHRBASE_REST_AUTH__OIDC__JWKS_JSONstring (JSON)noneStatic JWKS document; preferred over discovery when present.

Authorization (RBAC + ABAC)

Prefix EHRBASE_AUTHZ_, separator __, optional file EHRBASE_AUTHZ_CONFIG.

KeyTypeDefaultDescription
EHRBASE_AUTHZ_CONFIGpathnonePath to the authz TOML config file.
EHRBASE_AUTHZ_RBAC__ENABLEDbooleantrueCoarse role gate (active only when auth is enabled).
EHRBASE_AUTHZ_RBAC__ADMIN_ROLEstringADMINRole required for admin-class operations.
EHRBASE_AUTHZ_RBAC__USER_ROLEstringUSERBaseline clinical role.
EHRBASE_AUTHZ_RBAC__ROLE_CLAIMSlist["realm_access.roles","scope"]JWT claim paths mined for roles.
EHRBASE_AUTHZ_RBAC__MANAGEMENT_ACCESSenum{admin_only,private,public}admin_onlyAccess level for the management surface.
EHRBASE_AUTHZ_ABAC__ENABLEDbooleanfalseMaster ABAC (attribute-based) switch.
EHRBASE_AUTHZ_ABAC__ENGINEenum{cedar,remote}cedarPolicy engine: embedded Cedar or a remote decision point.
EHRBASE_AUTHZ_ABAC__ORGANIZATION_CLAIMstringorganization_idJWT claim carrying the caller’s organization.
EHRBASE_AUTHZ_ABAC__PATIENT_CLAIMstringpatient_idJWT claim carrying the patient id (blank disables the subject gate).
EHRBASE_AUTHZ_ABAC__CEDAR__POLICY_DIRpathnoneDirectory of *.cedar policy files (required for the cedar engine).
EHRBASE_AUTHZ_ABAC__CEDAR__RELOAD_SECSintegernoneOptional Cedar hot-reload interval (seconds).
EHRBASE_AUTHZ_ABAC__REMOTE__SERVERURLnoneRemote decision-point base URL (required for the remote engine).
EHRBASE_AUTHZ_ABAC__REMOTE__CONNECT_TIMEOUT_MSinteger (ms)2000Remote-PDP connect timeout.
EHRBASE_AUTHZ_ABAC__REMOTE__REQUEST_TIMEOUT_MSinteger (ms)5000Remote-PDP request timeout.

Database

Prefix EHRBASE_DB_, no nesting, environment-only (no config file).

KeyTypeDefaultDescription
EHRBASE_DB_URLURLnone (required)PostgreSQL connection URL, postgres://user:pass@host:port/db. DATABASE_URL is accepted as a fallback.
EHRBASE_DB_MAX_CONNECTIONSinteger10Upper bound of the connection pool.
EHRBASE_DB_MIN_CONNECTIONSinteger0Idle connections the pool keeps open.
EHRBASE_DB_ACQUIRE_TIMEOUT_SECSinteger (s)30Wait for a free connection before failing.

Note

EHRBASE_DB_NAME, EHRBASE_DB_USER, and EHRBASE_DB_PASSWORD are not read by the server — they configure the PostgreSQL init image. The server takes a single EHRBASE_DB_URL.

Telemetry and logging

Prefixes EHRBASE_OTEL_ and EHRBASE_LOG_. Flat (no __ nesting), environment-only.

KeyTypeDefaultDescription
EHRBASE_OTEL_OTLP_ENDPOINTURLnoneOTLP/gRPC collector endpoint. Unset = the OTel layer is not installed (zero overhead).
EHRBASE_OTEL_SERVICE_NAMEstringehrbaseservice.name resource attribute.
EHRBASE_OTEL_ENVIRONMENTstringdevdeployment.environment resource attribute.
EHRBASE_OTEL_TRACES_SAMPLE_RATIOfloat1.0Head-sampling ratio.
EHRBASE_OTEL_METRICS_PUSHbooleanfalseAlso push metrics over OTLP (alongside Prometheus pull).
EHRBASE_LOG_FORMATenum{auto,json,pretty}autoLog rendering. json for cluster log collectors; auto picks JSON when stdout is not a TTY.
EHRBASE_LOG_FILTERstringinfo,ehrbase=infoLog-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.

KeyTypeDefaultDescription
EHRBASE_MANAGEMENT_CONFIGpathnonePath to the management TOML config file.
EHRBASE_MANAGEMENT_ENABLEDbooleanfalseMaster switch; off = no management routes mounted.
EHRBASE_MANAGEMENT_BASE_PATHstring/managementBase path for the management endpoints.
EHRBASE_MANAGEMENT_PORTinteger (u16)noneServe management on its own listener/port instead of the main API listener.
EHRBASE_MANAGEMENT_ACCESS_DEFAULTenum{off,admin_only,private,public}admin_onlyGlobal default access level (a per-endpoint level wins).
EHRBASE_MANAGEMENT_PROBES_ENABLEDbooleanfalseMount the public /health/liveness + /health/readiness probes.
EHRBASE_MANAGEMENT_ENDPOINTS_HEALTHenum{off,admin_only,private,public}offAccess level of /management/health.
EHRBASE_MANAGEMENT_ENDPOINTS_INFOenum{off,admin_only,private,public}offAccess level of /management/info.
EHRBASE_MANAGEMENT_ENDPOINTS_METRICSenum{off,admin_only,private,public}offAccess level of /management/metrics.
EHRBASE_MANAGEMENT_ENDPOINTS_PROMETHEUSenum{off,admin_only,private,public}offAccess level of /management/prometheus.
EHRBASE_MANAGEMENT_ENDPOINTS_ENVenum{off,admin_only,private,public}offAccess level of /management/env (redacted config).
EHRBASE_MANAGEMENT_ENDPOINTS_LOGGERSenum{off,admin_only,private,public}offAccess level of /management/loggers (runtime log control).

Version signing

Prefix EHRBASE_SIGNING_, separator __, optional file EHRBASE_SIGNING_CONFIG. On by default in digest mode.

KeyTypeDefaultDescription
EHRBASE_SIGNING_CONFIGpathnonePath to the signing TOML config file.
EHRBASE_SIGNING_ENABLEDbooleantrueServer-side signing of committed versions.
EHRBASE_SIGNING_MODEenum{digest,pgp}digestSHA-256 integrity digest, or an OpenPGP detached signature.
EHRBASE_SIGNING_KEY_PATHpathnoneArmored RFC 4880 secret key (required for pgp).
EHRBASE_SIGNING_KEY_PASSPHRASEstring (secret)noneKey passphrase (kept in memory, never serialized).
EHRBASE_SIGNING_VERIFY_ON_READenum{off,warn,strict}offRead-time recompute-and-compare policy.

Warning

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

System log (ATNA auditing)

Prefix EHRBASE_ATNA_, separator __, optional file EHRBASE_ATNA_CONFIG. Off by default.

KeyTypeDefaultDescription
EHRBASE_ATNA_CONFIGpathnonePath to the ATNA TOML config file.
EHRBASE_ATNA_ENABLEDbooleanfalseMaster ATNA audit switch.
EHRBASE_ATNA_ENTERPRISE_SITE_IDstringnoneEnterprise/site id (AuditEnterpriseSiteID).
EHRBASE_ATNA_REPOSITORY_HOSTstringlocalhostAudit Record Repository (ARR) host.
EHRBASE_ATNA_REPOSITORY_PORTinteger (u16)514ARR port (514 UDP / 6514 TLS typical).
EHRBASE_ATNA_TRANSPORTenum{udp,tls}udpSyslog transport to the ARR. Use tls for PHI-adjacent audit.
EHRBASE_ATNA_SOURCE_IDstringehrbaseAudit source id.
EHRBASE_ATNA_VALUE_IF_MISSINGstringUNKNOWNFill value for empty mandatory fields.
EHRBASE_ATNA_SUPPRESS_LOGIN_EVENTSbooleantrueSkip auth/login activity events.
EHRBASE_ATNA_FAIL_MODEenum{open,closed}openOn undeliverable audit: succeed and meter (open) or reject with 503 (closed).
EHRBASE_ATNA_RESOLVE_SUBJECTbooleanfalseEnrich the patient participant via a subject lookup.
EHRBASE_ATNA_QUEUE_CAPACITYinteger1024Bounded audit queue capacity.
EHRBASE_ATNA_SERVER_HOSTstringnoneThis node’s advertised address (NetworkAccessPointID).
EHRBASE_ATNA_TLS_CA_PATHpathnonePEM CA file to trust for TLS transport.
EHRBASE_ATNA_TLS_IDENTITY_CERT_PATHpathnoneClient-certificate PEM for mutual TLS.
EHRBASE_ATNA_TLS_IDENTITY_KEY_PATHpathnoneClient-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.

KeyTypeDefaultDescription
EHRBASE_EVENTS_CONFIGpathnonePath to the events TOML config file.
EHRBASE_EVENTS_ENABLEDbooleanfalseSpawn the outbox publisher.
EHRBASE_EVENTS_URLAMQP URLamqp://guest:guest@localhost:5672/%2fRabbitMQ broker URL.
EHRBASE_EVENTS_EXCHANGEstringehrbase.eventsTopic exchange for PHI-free event envelopes.
EHRBASE_EVENTS_TLSbooleanfalseUpgrade an amqp:// URL to amqps://.
EHRBASE_EVENTS_BATCH_SIZEinteger128Outbox rows drained per poll.
EHRBASE_EVENTS_POLL_INTERVAL_MSinteger (ms)1000Poll interval when the outbox is idle.
EHRBASE_EVENTS_RETENTION_DAYSinteger (days)7Published-row retention window.
EHRBASE_EVENTS_PRUNE_INTERVAL_SECSinteger (s)3600Retention-prune cadence.
EHRBASE_EVENTS_PUBLISH_MAX_RETRIESinteger3Per-row publish retries before backing off.

FHIR outbound emitter

Prefix EHRBASE_FHIR_OUTBOUND_, separator __, optional file EHRBASE_FHIR_OUTBOUND_CONFIG. Off by default.

KeyTypeDefaultDescription
EHRBASE_FHIR_OUTBOUND_CONFIGpathnonePath to the FHIR-outbound TOML config file.
EHRBASE_FHIR_OUTBOUND_ENABLEDbooleanfalseEnable the FHIR resource emitter.
EHRBASE_FHIR_OUTBOUND_URLAMQP URLamqp://guest:guest@localhost:5672/%2fBroker URL.
EHRBASE_FHIR_OUTBOUND_EXCHANGEstringehrbase.fhirTopic exchange (separate from events, for PHI isolation).
EHRBASE_FHIR_OUTBOUND_TLSbooleanfalseUpgrade an amqp:// URL to amqps://.
EHRBASE_FHIR_OUTBOUND_BATCH_SIZEinteger128Outbox rows scanned per poll.
EHRBASE_FHIR_OUTBOUND_POLL_INTERVAL_MSinteger (ms)1000Poll interval when idle.
EHRBASE_FHIR_OUTBOUND_PUBLISH_MAX_RETRIESinteger3Per-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).

KeyTypeDefaultDescription
EHRBASE_MULTIMEDIA_CONFIGpathnonePath to the multimedia TOML config file.
EHRBASE_MULTIMEDIA_ENABLEDbooleanfalseExternalize large multimedia data to an object store.
EHRBASE_MULTIMEDIA_THRESHOLD_BYTESinteger (bytes)262144 (256 KiB)Decoded size strictly above which data is offloaded.
EHRBASE_MULTIMEDIA_ENDPOINTURLnoneS3-compatible endpoint. None = AWS default resolution.
EHRBASE_MULTIMEDIA_BUCKETstringopenehr-multimediaTarget bucket.
EHRBASE_MULTIMEDIA_REGIONstringus-east-1AWS region (required even for non-AWS endpoints).
EHRBASE_MULTIMEDIA_ACCESS_KEY_IDstringnoneS3 access key id (none + no secret = anonymous).
EHRBASE_MULTIMEDIA_SECRET_ACCESS_KEYstring (secret)noneS3 secret access key.
EHRBASE_MULTIMEDIA_ALLOW_HTTPbooleanfalseAllow 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).

KeyTypeDefaultDescription
EHRBASE_VALIDATION_CONFIGpathnonePath to the terminology-validation TOML config file.
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_ENABLEDbooleanfalseActivate external terminology validation.
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_FAIL_ON_ERRORbooleanfalseOn TS/connectivity error, reject (fail-closed) vs accept (fail-open).
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__TYPEenum{fhir}fhirProvider kind (FHIR R4).
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__URLURLnone (required)FHIR R4 base URL of the terminology server.
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__OPERATIONenum{validate_code,expand}validate_codeValue-set membership operation.
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__CONNECT_TIMEOUT_MSinteger (ms)2000Per-provider connect timeout.
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__REQUEST_TIMEOUT_MSinteger (ms)10000Per-provider request timeout.
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__OAUTH2_CLIENTstringnoneName of an OAuth2 client-credentials client for the provider.

Process / CLI

KeyTypeDefaultDescription
EHRBASE_HEALTHCHECK_URLURLhttp://127.0.0.1:8080/ehrbase/rest/statusTarget 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 &amp; pinned)"]

    subgraph gen ["Specification layer (generated, never hand-edited)"]
        types["RM 1.2.0 types · canonical JSON &amp; 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:password with curl.
  • OAuth2 / OIDC bearer tokens — send Authorization: Bearer <token>, validated against a configured issuer (Keycloak, Active Directory, any standards-compliant provider).

A request with no or invalid credentials gets 401 Unauthorized; an authenticated caller lacking the required role gets 403 Forbidden. Authorization is coarse role-based access control by default (a USER role for clinical operations, an ADMIN role for admin operations), with optional attribute-based policies. The full picture — mechanisms, roles, multi-tenancy — is in Security & multi-tenancy.

Note

The development stack ships throwaway Basic users (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 Prefer header, ETag/If-Match optimistic concurrency, and the error response shape.

For querying, see Querying with AQL; for loading templates, Templates & validation.

Resource walkthroughs

This chapter walks through the core openEHR resources — EHR, EHR_STATUS, COMPOSITION, DIRECTORY, and CONTRIBUTION — with real curl examples you can adapt. For each resource it shows the operations, the headers they need, and the status codes they return. Paths are relative to the base /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 — the VERSIONED_EHR_STATUS object,
  • GET .../versioned_ehr_status/revision_history — the revision history,
  • GET .../versioned_ehr_status/version (optionally ?version_at_time=) and .../version/{version_uid} — a specific version.

COMPOSITION

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

Create a composition

curl -u 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. PUT takes the object uuid (the versioned object), while DELETE takes the full version id (the version you are superseding). GET accepts 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; requires If-Match; 200/204.
  • DELETE /ehr/{ehr_id}/directory — logical delete; requires If-Match; 204.
  • GET /ehr/{ehr_id}/directory — the current folder tree, optionally filtered by ?version_at_time= and ?path= (slash-separated folder names). 204 if deleted at that time.
  • GET /ehr/{ehr_id}/directory/{version_uid} — a specific version, optionally ?path=.

CONTRIBUTION

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

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

Status-code summary

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

JSON is wired end to end for every operation. XML is supported for the spec-typed RM objects — a single composition, EHR_STATUS, EHR, FOLDER, and the version family (versioned objects and revision history) — whose canonical XML shape the openEHR ITS-XML schemas define. Responses that are not a spec-typed RM value (collections, item tags, and the query and terminology DTOs) are JSON-only, as is the CONTRIBUTION envelope.

# Commit a composition as XML, ask for XML back
curl -u 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 valueEffect
return=minimal (default)Empty body; the identifier is in ETag/Location. Status 204 on update, 201 on create.
return=representationThe full created/updated resource in the body, status 200/201.
return=identifierJust the resource identifier object.

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

  • Updating or deleting a versioned object requires an If-Match header 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 ETag value → send it back as If-Match on the update → get a new ETag for the version you just created. Never fabricate a version id; always echo the one the server gave you.

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

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

SELECT
    c/name/value AS composition_name,
    o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude AS systolic
FROM EHR e
    CONTAINS COMPOSITION c
        CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]
WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140
ORDER BY systolic DESC
  • FROM binds variables to RM types (EHR e, COMPOSITION c, OBSERVATION o). A type can be constrained by archetype id in square brackets (OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2]).
  • CONTAINS expresses structural containment — “an EHR that contains a composition that contains a blood-pressure observation”. Chains can nest several deep, and combine with AND, OR, and NOT.
  • SELECT projects values by path. Paths use archetype node ids (at0004) and RM attribute names (value/magnitude); AS names a column.
  • WHERE filters on typed leaf values, with comparisons, EXISTS, LIKE, MATCHES, and boolean combinators.
  • ORDER BY, LIMIT, and OFFSET behave as you expect; quantities order by their openEHR magnitude semantics.

Running a query over HTTP

The query API lives under the base path at /query/aql. The simplest form is a POST with a JSON body:

curl -u 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:

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

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

The query API is JSON only (Accept: application/json).

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 same offset, fetch, and query_parameters as ad-hoc queries. A version can be given exactly (1.0.0) or as a prefix (1).

Version scope: LATEST_VERSION and ALL_VERSIONS

By default a query sees the latest version of each object. 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 by ehr_id), the faster the query: those constraints map to indexed columns, while broad “everything that contains anything” queries do the most work.

What is supported

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:

  • SELECT of paths, literals, aliases, DISTINCT, and the aggregates COUNT (including COUNT(DISTINCT)), MIN, MAX, SUM, AVG;
  • FROM over EHR, VERSION (LATEST_VERSION / ALL_VERSIONS), and RM classes with archetype and name predicates;
  • CONTAINS trees with AND, OR, and NOT CONTAINS;
  • WHERE comparisons on typed leaves (with openEHR magnitude ordering for quantities), EXISTS, LIKE, MATCHES value lists, and range predicates;
  • ORDER BY typed leaves, LIMIT/OFFSET, named query parameters, and the ehr_id, offset, and fetch request parameters;
  • terminology-backed TERMINOLOGY('expand', …) inside matches.

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

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

Send the matching Content-Type when committing, or the matching Accept when retrieving, and the server converts between the flat/structured form and the canonical composition. These formats are a Better/EHRbase interoperability convenience; the canonical JSON and XML remain the openEHR-standard wire format.

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

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_MULTIMEDIA blobs to any S3-compatible object store, with integrity verification and expand-on-read.

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

EHR Extract & messaging

Moving a patient’s record between openEHR systems — migrating to another CDR, replicating an EHR to a downstream repository, or importing an externally produced document — is what openEHR’s EHR Extract and messaging services are for. 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.

MethodPathPurpose
POST/demographic/{kind}create a party
GET/demographic/{kind}/{uid_based_id}read a party
PUT/demographic/{kind}/{uid_based_id}update a party
DELETE/demographic/{kind}/{uid_based_id}delete a party
GET/demographic/versioned_party/{versioned_object_uid}the versioned container
GET/demographic/versioned_party/{versioned_object_uid}/revision_historyrevision history
GET/demographic/versioned_party/{versioned_object_uid}/versionversion at time (query parameter)
GET/demographic/versioned_party/{versioned_object_uid}/version/{version_uid}a specific version

Party changes can also be committed and read as contributions (POST /demographic/contribution, GET /demographic/contribution/{contribution_uid}), and parties support item tags (/demographic/tags, /demographic/{kind}/{uid_based_id}/tags, and DELETE …/tags/{key}).

Relationships

Party relationships are managed through a parallel set of routes (an EHRbase-rs extension), with the same versioned shape as parties:

MethodPathPurpose
POST/demographic/party_relationshipcreate a relationship
GET/PUT/DELETE/demographic/party_relationship/{uid_based_id}read / update / delete
GET/demographic/versioned_party_relationship/{versioned_object_uid}the versioned container
GET/demographic/versioned_party_relationship/{versioned_object_uid}/revision_historyrevision history
GET/demographic/versioned_party_relationship/{versioned_object_uid}/version[/{version_uid}]version at time / by id

The demographic endpoints are always mounted (not behind a feature switch), and are subject to the same authentication and authorization as the rest of the API — see Security & multi-tenancy.

Terminology servers

openEHR records carry coded values — a diagnosis, a route of administration, a laboratory unit. Some codes come from openEHR’s own terminology; others must be validated against an external code system such as SNOMED CT or LOINC. 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:

MethodPathPurpose
GET/terminologylist terminologies
GET/terminology/{terminology_id}describe one terminology
GET/terminology/{terminology_id}/term/{code}look up a term
GET/terminology/{terminology_id}/subsumes?ref_code=&candidate=subsumption test
GET/terminology/{terminology_id}/value_set/{value_set_id}get a value set
GET/terminology/{terminology_id}/value_set/{value_set_id}/validate?candidate_code=&at_date=validate a code

(All paths are relative to the API base path, /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):

KeyMeaning
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_ENABLEDmaster switch (default false)
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_FAIL_ON_ERRORon a server error: true rejects (fail-closed), false accepts (fail-open)
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__TYPEprovider type — fhir (R4)
EHRBASE_VALIDATION_EXTERNAL_TERMINOLOGY_PROVIDERS__<NAME>__URLthe FHIR base URL, e.g. http://terminology:8090/fhir

A provider can carry per-provider OAuth2 client-credentials and mutual-TLS settings for servers that require them. A short worked example, pointing the CDR at a HAPI FHIR container over Docker Compose:

services:
  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 (PUT to /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:

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

Each versions[] entry has vo_id, kind (the RM type — COMPOSITION, EHR_STATUS, FOLDER, EHR_ACCESS), sys_version, change_type (a numeric audit change-type code — 249 creation, 251 modification, 523 deleted, 666 attestation), and template_id (or null).

Tip

Deduplicate on the pair (contribution_id, version_index) and process in seq order. That handles the at-least-once redelivery and preserves per-EHR ordering at the consumer.

Routing keys and subscriptions

Messages are published to a topic exchange (default name 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 variableDefaultMeaning
EHRBASE_EVENTS_ENABLEDfalsemaster switch
EHRBASE_EVENTS_URLamqp://guest:guest@localhost:5672/%2fbroker connection URL
EHRBASE_EVENTS_EXCHANGEehrbase.eventstopic exchange name (also the queue-name prefix)
EHRBASE_EVENTS_TLSfalsewhen true, upgrades an amqp:// URL to amqps://
EHRBASE_EVENTS_BATCH_SIZE128rows drained per cycle
EHRBASE_EVENTS_POLL_INTERVAL_MS1000poll interval while the outbox is idle
EHRBASE_EVENTS_PUBLISH_MAX_RETRIES3retries per message before the batch stops
EHRBASE_EVENTS_RETENTION_DAYS7how long published rows are kept
EHRBASE_EVENTS_PRUNE_INTERVAL_SECS3600how often published rows are pruned

Warning

The broker URL carries credentials, so keep it in a secret, not a plain environment file. For anything beyond a local broker, use a TLS connection (EHRBASE_EVENTS_TLS=true or an amqps:// URL). The commit path never blocks on the broker — if it is down, events buffer in the outbox and drain when it recovers.

Consuming events

A minimal consumer declares nothing new — it binds a queue to the exchange and reads. In shell form with the RabbitMQ tooling:

# bind a queue to every composition creation, then consume
rabbitmqadmin declare queue name=my-consumer durable=true
rabbitmqadmin declare binding source=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, not ehrbase.events): broker access control can then isolate the PHI-bearing stream. Enable it only against a TLS, access-controlled broker, and treat every consumer as a PHI processor.

Mappings are data you manage

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

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

A mapping definition binds one FHIR resource type (optionally scoped to a meta.profile URL) to one openEHR template, and lists field bindings — each mapping an openEHR FLAT path to a FHIR path (or a constant), shaped by a transform (plain text, date, quantity with unit, or a coded value with a code-system-to-terminology mapping). The FHIR-path support is a deliberate subset covering field navigation and array indexing (for example component[1].valueQuantity.value), and the mapping is symmetric: the same definition drives inbound ingest, the read façade, and outbound emission.

Note

The template a mapping references must already be ingested (see Templates & validation) — creating a mapping against an unknown template is a 400. Mapping names are immutable once set, and a duplicate name is a 409.

Enabling the connectors

Both switches are off by default. The inbound/read-façade switch lives in the REST config; the outbound emitter has its own config group:

Environment variableDefaultMeaning
EHRBASE_REST_FHIR__ENABLEDfalseenable inbound ingest, the read façade, and the mapping API
EHRBASE_FHIR_OUTBOUND_ENABLEDfalseenable outbound emission to AMQP
EHRBASE_FHIR_OUTBOUND_URLamqp://guest:guest@localhost:5672/%2foutbound broker URL
EHRBASE_FHIR_OUTBOUND_EXCHANGEehrbase.fhiroutbound topic exchange (kept distinct from the event stream)
EHRBASE_FHIR_OUTBOUND_TLSfalseupgrade an amqp:// URL to amqps://
EHRBASE_FHIR_OUTBOUND_BATCH_SIZE128commits drained per cycle
EHRBASE_FHIR_OUTBOUND_POLL_INTERVAL_MS1000poll interval while idle
EHRBASE_FHIR_OUTBOUND_PUBLISH_MAX_RETRIES3retries per message

When the inbound switch is off, the /fhir/r4/* and /admin/fhir_mapping routes answer 404 without touching the backend. When the outbound switch is off, no emitter task runs.

S3 multimedia

Clinical records sometimes carry large binary attachments — scanned documents, images, waveforms — as DV_MULTIMEDIA values. Keeping big blobs inline in the database bloats storage and slows queries. 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):

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

Uploads happen before anything is persisted, so a failed upload aborts the commit — a record is never half-stored.

Note

What lives where after offload: the object store holds the blob bytes; the composition in PostgreSQL holds a compact, spec-legal DV_MULTIMEDIA that points at the blob by content hash. Everything remains canonical openEHR JSON — the s3:// reference and integrity fields are standard RM attributes.

Reading blobs back

By default a read returns the stored (offloaded) form — the compact reference. To get the inline bytes back, request expansion on the read (?expand_multimedia=true). The server fetches each of its own externalized blobs (only URIs of the exact form s3://<configured-bucket>/<hash> are treated as its own; foreign https:// or other-bucket references are left alone), verifies the SHA-256 hash of the fetched bytes against the key, and only then re-inlines the data. A hash mismatch is a hard error, so a corrupted or tampered blob is never silently served.

Enabling it

Offload is off by default. Configuration uses the EHRBASE_MULTIMEDIA_ prefix (defaults, then an optional TOML file, then environment variables):

Environment variableDefaultMeaning
EHRBASE_MULTIMEDIA_ENABLEDfalsemaster switch
EHRBASE_MULTIMEDIA_THRESHOLD_BYTES262144 (256 KiB)offload blobs larger than this; smaller stay inline
EHRBASE_MULTIMEDIA_ENDPOINTunsetS3 endpoint URL (unset uses AWS default resolution)
EHRBASE_MULTIMEDIA_BUCKETopenehr-multimediatarget bucket
EHRBASE_MULTIMEDIA_REGIONus-east-1S3 region
EHRBASE_MULTIMEDIA_ACCESS_KEY_IDunsetaccess key (see note on credentials)
EHRBASE_MULTIMEDIA_SECRET_ACCESS_KEYunsetsecret key
EHRBASE_MULTIMEDIA_ALLOW_HTTPfalsepermit plain-HTTP endpoints (development only)

If both the access key and secret are unset, the client runs unsigned (anonymous) — the mode a local development SeaweedFS accepts with no credentials. Set both to use signed requests against a real store.

Warning

Offloaded blobs are PHI. In production the bucket must be private, encrypted, and reached over HTTPS (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 basic block with a user list is configured. Each user has a username, an Argon2 password hash (a PHC string beginning $argon2id$), and a set of roles (default ["USER"]). Because it is a list of users, the Basic block is normally supplied through the TOML configuration file rather than environment variables.
  • OAuth2/OIDC bearer tokens are active when an oidc block is configured. The server validates the token’s signature, issuer, and (optionally) audience.

The OIDC settings:

Environment variableDefaultMeaning
EHRBASE_REST_AUTH__OIDC__ISSUER— (required to enable OIDC)expected iss, and the OIDC discovery base
EHRBASE_REST_AUTH__OIDC__AUDIENCESempty (not checked)accepted aud values
EHRBASE_REST_AUTH__OIDC__ALGORITHMS["RS256"]accepted signing algorithms
EHRBASE_REST_AUTH__OIDC__HMAC_SECRETunsetan HS256 symmetric secret (development/testing)
EHRBASE_REST_AUTH__OIDC__JWKS_JSONunseta static JWKS document

There is no separate JWKS or discovery URL to set: the server discovers the JWKS URI from the issuer’s .well-known/openid-configuration unless you supply a static JWKS_JSON (preferred when present) or an HMAC_SECRET.

Tip

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

export EHRBASE_REST_AUTH__OIDC__ISSUER=https://keycloak.example/realms/ehrbase
export EHRBASE_REST_AUTH__OIDC__AUDIENCES=ehrbase-api

The 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 variableDefaultMeaning
EHRBASE_AUTHZ_RBAC__ENABLEDtruethe coarse role gate (active only when auth is enabled)
EHRBASE_AUTHZ_RBAC__ADMIN_ROLEADMINrole required for admin operations
EHRBASE_AUTHZ_RBAC__USER_ROLEUSERthe baseline clinical role
EHRBASE_AUTHZ_RBAC__ROLE_CLAIMS["realm_access.roles","scope"]JWT claim paths mined for roles
EHRBASE_AUTHZ_RBAC__MANAGEMENT_ACCESSadmin_onlymanagement-surface access: admin_only, private, or public

Roles come from the JWT claims listed in ROLE_CLAIMS — by default the Keycloak realm_access.roles array plus the space-separated scope claim — or from a Basic user’s configured roles. A clinical operation needs at least one role; an admin operation needs the admin role; the management surface follows its tri-state setting. Disabling RBAC restores authentication-only behaviour.

ABAC (attribute-based, fine-grained)

For attribute-level decisions — “may this user touch this patient’s data, under this organisation, for this template?” — enable ABAC. A policy decision point is consulted per clinical operation with resolved attributes.

Environment variableDefaultMeaning
EHRBASE_AUTHZ_ABAC__ENABLEDfalsemaster ABAC switch
EHRBASE_AUTHZ_ABAC__ENGINEcedarcedar (embedded) or remote (external PDP)
EHRBASE_AUTHZ_ABAC__ORGANIZATION_CLAIMorganization_idJWT claim for the organisation attribute
EHRBASE_AUTHZ_ABAC__PATIENT_CLAIMpatient_idJWT 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_SECSoffoptional policy hot-reload interval
EHRBASE_AUTHZ_ABAC__REMOTE__SERVER— (required for remote)PDP base URL (must end with /)
EHRBASE_AUTHZ_ABAC__REMOTE__CONNECT_TIMEOUT_MS2000PDP connect timeout
EHRBASE_AUTHZ_ABAC__REMOTE__REQUEST_TIMEOUT_MS5000PDP request timeout

Two engines sit behind one interface. Cedar is the embedded default: policies live in .cedar files, are schema-validated at boot (an invalid policy set stops the server rather than silently denying), and need no external service. The remote PDP option consults an external policy server over HTTP for deployments that already run one.

Warning

Authorization is fail-closed: if the policy engine is unreachable or a policy cannot be evaluated, the request is refused (mapped to 500), never permitted. When a patient claim is configured, a local subject gate also rejects access to another patient’s EHR before any policy call. A denied decision is a 403.

Multi-tenancy

Multi-tenancy lets one deployment host several isolated logical openEHR systems, each with its own system_id. It is off by default; when off, the server behaves byte-for-byte as a single-tenant system.

Environment variableDefaultMeaning
EHRBASE_REST_TENANCY__ENABLEDfalseenable multi-tenancy
EHRBASE_REST_TENANCY__CLAIMtenantthe JWT claim (a dotted path) carrying the tenant key
EHRBASE_REST_TENANCY__HEADERunseta development header override for the tenant

A request’s tenant is resolved from the configured JWT claim (a dotted path such as realm_access.tenant is walked through nested objects). Isolation is enforced in the database with PostgreSQL row-level security: the resolved tenant scopes the connection so a query can only ever see its own tenant’s rows.

Warning

Leave EHRBASE_REST_TENANCY__HEADER unset in production — a client-supplied header must never be able to select a tenant; the tenant must come from the authenticated token. Isolation is also fail-safe by design: an absent or unresolvable tenant runs unscoped against a reserved default rather than guessing, and a cross-tenant access surfaces as an empty result set, never a 403 that would leak the existence of another tenant’s data.

ATNA audit trail

Separately from openEHR’s own provenance, 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 variableDefaultMeaning
EHRBASE_ATNA_ENABLEDfalsemaster switch
EHRBASE_ATNA_REPOSITORY_HOSTlocalhostaudit repository host
EHRBASE_ATNA_REPOSITORY_PORT514audit repository port
EHRBASE_ATNA_TRANSPORTudpudp or tls
EHRBASE_ATNA_ENTERPRISE_SITE_IDunsetenterprise/site identifier
EHRBASE_ATNA_SOURCE_IDehrbaseaudit source identifier
EHRBASE_ATNA_VALUE_IF_MISSINGUNKNOWNfill for an empty mandatory field
EHRBASE_ATNA_SUPPRESS_LOGIN_EVENTStrueskip the successful-login records
EHRBASE_ATNA_FAIL_MODEopenopen (drop and continue) or closed (reject auditable ops if undeliverable)
EHRBASE_ATNA_RESOLVE_SUBJECTfalseenrich the patient identifier from stored data
EHRBASE_ATNA_QUEUE_CAPACITY1024bounded in-memory audit queue
EHRBASE_ATNA_SERVER_HOSTunsetthis node’s advertised network address
EHRBASE_ATNA_TLS_CA_PATHunsetPEM of the repository CA (TLS)
EHRBASE_ATNA_TLS_IDENTITY_CERT_PATHunsetclient certificate PEM (mutual TLS)
EHRBASE_ATNA_TLS_IDENTITY_KEY_PATHunsetclient 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 CONTRIBUTION and AUDIT_DETAILS, which the server always writes in the same transaction as every change. openEHR audit records what a version says about its own authorship; ATNA records security surveillance of API access. Both coexist. Identified data never enters telemetry (metrics, traces, logs) — see Operations — so the audit trail is the single place where access to identified data is recorded.

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

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:

RolePurposeUsed by
ownerowns the databaseprovisioning only
ehrbase_migratorruns the schema migrations; owns the helper functionsthe migration step
ehrbase_appreads and writes clinical datathe running server
ehrbase_readerread-onlyreplicas and reporting

The migrations create these roles idempotently, apply the per-schema grants, and revoke the ability to create objects in the public schema. The running server connects as 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_app DSN — recommended for least-privilege production. Run the migration as a CI/CD step or a one-shot job with the migrator credential before rolling the deployment, and gate the rollout so two versions never race the schema.

TLS and database security

These are database-side settings that belong to whoever provisions PostgreSQL; the deployment references them but cannot enforce them:

  • TLS in transit. Require hostssl on the server and put ?sslmode=verify-full in the DSN so the client verifies the server certificate.
  • pgaudit. Run pgaudit as the database-layer complement to the openEHR audit and the ATNA trail — for example pgaudit.log = 'ddl, role, connection' globally plus object-level audit on the PHI tables — and ship the audit log to an immutable store with long (roughly six-year) retention.
  • Encryption at rest. Encrypt at the volume or disk layer. Do not encrypt the stored clinical JSON with pgcrypto — it would break AQL’s ability to query inside the data.

Backup and point-in-time recovery

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

The container image and pod hardening

The published image is distroless and non-root — shell-less, with no package manager — and is multi-architecture (amd64 and arm64) on GHCR. When run under Kubernetes with the provided Helm chart, the pod is hardened and the chart’s validation asserts these on every render:

SettingValue
run as non-rootuid/gid 65532
read-only root filesystemyes (a writable emptyDir at /tmp)
privilege escalationdisallowed
Linux capabilitiesall dropped
seccompRuntimeDefault
service-account tokennot mounted (the workload never calls the Kubernetes API)
NetworkPolicydefault-deny ingress; only the API/management port admitted

Egress restriction is opt-in because egress targets (database, broker, terminology server) are deployment-specific. See Installation → Kubernetes & Helm for the chart itself.

Upgrades

  • Backward-compatible migrations. Migrations are append-only and never edited once applied. A rolling upgrade must be compatible with the previous schema for the window where both versions run: apply additive changes first, and defer destructive changes to a later release once every pod is on the new version.
  • Lock-safe DDL. The migration runner bounds DDL with a lock_timeout and statement_timeout so a migration cannot block live traffic indefinitely; on a busy table use CREATE INDEX CONCURRENTLY and add constraints NOT VALID then VALIDATE later.
  • Pin the image. Deploy an immutable tag or, better, a @sha256 digest, never latest; roll back by re-pinning the prior digest — the schema’s backward compatibility makes that safe.
  • Stay available. Keep at least two replicas (or autoscaling) and a pod disruption budget so node drains and upgrades never fully interrupt the API. The default 30-second termination grace period covers the server’s short shutdown drain of the audit and event outboxes.

Observability

tracing is the single instrumentation API. From it, three signal families fan out, and identified data never enters any of them — telemetry uses only closed-set labels and opaque request/trace ids, so correlation to a patient is possible only through the ATNA audit trail.

  • Logs go to stdout — JSON when not attached to a terminal, pretty on a TTY — each line stamped with the trace and span id. Shipping and rotation are the platform’s job. EHRBASE_LOG_FORMAT (auto/json/pretty) and EHRBASE_LOG_FILTER (or RUST_LOG, default info,ehrbase=info) control them, and the level can be changed at runtime through the loggers endpoint 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 variableDefaultMeaning
EHRBASE_OTEL_OTLP_ENDPOINTunset (layer not installed)OTLP collector endpoint
EHRBASE_OTEL_SERVICE_NAMEehrbasereported service name
EHRBASE_OTEL_ENVIRONMENTdevreported deployment environment
EHRBASE_OTEL_TRACES_SAMPLE_RATIO1.0head sampling ratio (start at 0.1 in production)
EHRBASE_OTEL_METRICS_PUSHfalsealso push metrics over OTLP

Tip

A single-container dev stack (grafana/otel-lgtm, bundling an OTLP collector, Prometheus, Tempo, Grafana, and Loki) ships as a Compose overlay, together with a provisioned Grafana dashboard (request rate/errors/duration, database pool, AQL latency, validation failures, audit health) and a starter alert pack — point the server at it with the two OTLP variables above.

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 variableDefaultMeaning
EHRBASE_MANAGEMENT_ENABLEDfalseenable the management surface
EHRBASE_MANAGEMENT_BASE_PATH/managementbase path for the surface
EHRBASE_MANAGEMENT_PORTunset (main listener)serve management on its own port
EHRBASE_MANAGEMENT_ACCESS_DEFAULTadmin_onlydefault access level
EHRBASE_MANAGEMENT_PROBES_ENABLEDfalseexpose the liveness/readiness probes as public

The probe and ops endpoints:

EndpointPurposeDefault access
GET {base}/health/livenessprocess is up (200), no I/Opublic when probes enabled
GET {base}/health/readiness200 (up/degraded) or 503 (down): database ping, migrations applied, audit sender, events — each bounded to one secondpublic when probes enabled
GET {base}/healthaggregate component healthadmin_only
GET {base}/infobuild, version, and pinned spec versionsadmin_only
GET {base}/prometheusPrometheus text expositionadmin_only (re-expose to the scraper via network policy)
GET {base}/metricsJSON registry viewadmin_only
GET {base}/enveffective configuration, with secrets redactedadmin_only
GET/POST/DELETE {base}/loggersread and change the log level at runtimeadmin_only

Note

Under Kubernetes, wire liveness and startup to the liveness route and readiness to the readiness route; readiness reports 503 when 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’s ehrbase healthcheck subcommand can back an exec probe instead. The public /rest/status product 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:

AreaScope
EHR / STAEHR and EHR_STATUS operations
COM / CTB / DIRComposition, contribution (change sets), directory
TPL / SQRTemplate (OPT) and stored-query provisioning
QRY / VALAQL execution and content/archetype validation
DEM / ADM / MSGDemographic, admin, and messaging services
SEC / SIG / TSSecurity, 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 (from libxml2), used by the canonical-XML parity tests.

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

The checks every pull request must pass

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

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

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

  • Branch from develop, and target your pull request at develop.
  • Keep changes focused, and describe what changed and why. For anything that touches openEHR behaviour, cite the relevant specification section.
  • Behaviour changes come with tests. Snapshot changes must be reviewed, not blindly accepted.
  • Any user-visible change (the REST surface, AQL, validation, configuration, the CLI, or the deployment artifacts) adds an entry to the changelog in the same pull request — a CI guard enforces this.

Reporting issues and vulnerabilities

Use the GitHub issue tracker for bugs and feature requests.

Warning

Do not open a public issue for a suspected security vulnerability. Report it privately through GitHub’s private vulnerability reporting (“Report a vulnerability” on the repository’s Security tab). Because the server handles PHI-class data by design, reports about data exposure through the API, AQL, telemetry, or the audit trail are in scope even when they look like “just configuration”. Coordinated disclosure is preferred — please allow a reasonable window for a fix before publishing details.