FerroEHR – a new Rust-based openEHR CDR, looking for testers

Hi everyone,

For the past months I’ve been working on a CDR written in Rust, called
FerroEHR. It started as a deep dive into EHRbase to understand how a CDR
works from the inside, and somewhere along the way it turned into a full
implementation of its own. Version 4.0.1 is out now, and I’m at the point
where I’m mostly testing my own assumptions. I’m looking for a few people
willing to poke at it and tell me where it falls apart.

What’s in it right now:

  • ITS-REST 1.1.0 with canonical JSON and XML, AQL 1.1, running on
    PostgreSQL 18. One static binary, no JVM.
  • The RM/BASE/AM types and the JSON/XML codecs are generated straight from
    the published machine-readable specs (BMM, XSDs, the REST OpenAPI). When a
    spec updates I regenerate. Where the spec text was ambiguous I kept a
    register of those decisions, and the defects and contradictions I found in
    the released specs along the way are being reported back to openEHR.
  • A spec_profile switch selects the specification generation the server
    runs on: the released texts (RM 1.1.0 with BASE 1.2.0) or the development
    generation (RM 1.2.0 with BASE 1.3.0, the default). Both are generated
    from the corresponding published BMMs, so you always know exactly which
    model you’re running.
  • ADL 2.4 source templates (parsing, AOM2 validation, flattening, OPT2) as
    well as classic OPT 1.4, WebTemplate, FLAT and STRUCTURED, EHR Extract,
    demographics, item tags, and an IHE ATNA audit log with a RESTful ATNA
    (ITI-81) read side.
  • A web admin console that ships as its own container image. It covers EHR
    browsing with rendered compositions, template management, stored queries,
    an AQL workbench with a point-and-click query builder, contribution
    authoring, and the audit browser. It talks to the server strictly over the
    same public REST API you would use.
  • Conformance testing is built in. A runner executes a machine-readable
    catalogue of about 1,100 cases against a freshly composed server and
    writes the report from the run artifacts. All the numbers in the repo come
    from those committed runs; I don’t type any of them by hand. I run the
    same catalogue against EHRbase for comparison and publish both directions.
    The measured performance classes come from open-loop sustained runs whose
    histograms are committed, so anyone can re-check them.

One thing to know if you’re coming from EHRbase: FerroEHR is
deliberately strict. It accepts exactly what the released specs admit, so
templates, compositions and queries that work on a more lenient server may
be refused here. Concretely:

  • Templates are validated while they are being constructed at upload. An OPT
    that doesn’t parse and validate cleanly never reaches the database. There
    is no “store now, fail later” path.
  • The JSON reader is strict: undeclared keys, duplicate keys, and malformed
    identifiers (EHR ids, version ids, archetype ids) get a 400. Nothing is
    silently ignored.
  • RM invariants and terminology bindings are enforced at commit time. A
    composition that a lenient validator waves through can come back as a 422
    with the exact path and rule that failed.
  • The AQL engine has a documented feature envelope. A construct it doesn’t
    support yet gets an explicit rejection, never an approximate answer.
  • The simplified formats (FLAT/STRUCTURED) are implemented from the spec
    text alone. There is no vendor-quirk compatibility mode, so payloads that
    rely on vendor-specific FLAT behaviour may not carry over unchanged.

In every one of these cases the error tells you what was wrong and where.
If you hit a refusal you believe is spec-valid, that’s exactly the kind of
report I’m hoping for.

Fair warning: this is a young project and I’m the only maintainer, so don’t
put patient data on it just yet. And since the test catalogue is something I
built myself from the spec text, a green run proves consistency with my
reading of the specs. Whether that matches your reading is exactly the thing
I can’t verify alone.

Everything is open source (MIT; the vendored openEHR artifacts stay under
their own Apache-2.0 terms). Trying it out needs no clone and no build. The
compose files ship with every release and pull the signed, published images:

  1. Download docker-compose.yml from the
    latest release
    into an empty directory, then:
docker compose up

The API then runs at http://localhost:8080/ferroehr/rest/openehr/v1
(dev login ferroehr / ferroehr, Swagger UI at
http://localhost:8080/ferroehr/rest/swagger-ui). The admin console is one
flag away: docker compose --profile admin-ui up, then open
http://localhost:3000. The same release page carries a Keycloak overlay
for the OIDC variant and an observability overlay with Prometheus and
Grafana. For Kubernetes there is a published Helm chart
(oci://ghcr.io/rubentalstra/charts/ferroehr). Docs live at
https://ferroehr.eu/.

Things I’d especially like to hear about: real-world templates and
compositions that don’t round-trip the way you expect, AQL queries from your
daily work that get rejected or answered differently than in your current
CDR, and anything that made you give up during setup. If you think the whole
approach is misguided, that’s useful too. Better to hear it now.

Best Greetings,
Ruben Talstra


PS: I’ll also be speaking at EHRCON26 in September, on a different topic.
See you there in person as well:

EHRCON26 — Tuesday, September 22, 13:45–14:00
A DICOM RT DVH Caching Layer: Bridging DICOM and openEHR
Ruben Talstra (Hanze University of Applied Sciences)

I like the transparency with the AI_STATEMENT FerroEHR/AI_STATEMENT.md at develop · rubentalstra/FerroEHR · GitHub

This deserves an award for the best 1st post on this forum :clap:
Impressive.

@rubentalstra I ran my performance tests on FerroEHR: Performance of an openEHR CDR based on a graph database - #6 by borut.jures

openEHR CDRs have a reputation for being slow. Please let me know if you add performance improvements so that I can rerun the tests.

@rubentalstra This is not meant as critique. Maybe a challenge :wink:

I was reading your code and it seems to me that it doesn’t handle a COMPOSITION which first sets EHR_STATUS.is_modifiable=true, does something with the EHR and then sets EHR_STATUS.is_modifiable=false.

It is an edge case, but it can happen.

I believe your current code checks EHR_STATUS.is_modifiable state only at now() at the start of processing.

Good catch, you read the code correctly. The check ran once, against the stored flag, before the transaction. A status flip inside the same CONTRIBUTION had no effect on the other members of that commit.

Before changing anything I went through the specs to see what they actually require here, and the honest answer is: almost nothing.

What IS stated: is_modifiable = false forbids writes to everything except the EHR_STATUS object (RM ehr, EHR Active Status), and the EHR_STATUS itself “can always be written to”. That sentence appears three times in the RM (EHR Creation and twice in the EHR_STATUS class), it’s how you reactivate a deactivated EHR.

What is NOT stated anywhere: when the flag is evaluated relative to a commit. I_EHR_CONTRIBUTION.commit_contribution has exactly one precondition (has_ehr) and no “not modifiable” error code even exists in the SM’s status enum. The commit itself is one atomic act, “similar to nested transactions” (RM common, Committal and Audits), and the versions list gets no ordering semantics from any text. Funny detail: the attribute is typed List but its own description in Contributions calls it a “Set of references”. I’ve written that one up here: upstream(RM): CONTRIBUTION.versions is typed List but described as a "Set of references" — the container semantics the attribute's own prose contradicts · Issue #2674 · rubentalstra/FerroEHR · GitHub

So member order can’t carry meaning, and there’s no “before” or “after” inside an atomic commit to hang a sequential check on. The rule I landed on is order-independent over the whole change set:

Content members are refused (409) exactly when the EHR is deactivated AND the contribution does not reactivate it. In other words: content is allowed if the stored flag is true, or if the same contribution carries an EHR_STATUS member with is_modifiable = true.

That makes both real-world cases work:

  • Deactivate with final updates: EHR active, one contribution with the last compositions plus the status set to false. Accepted. This is literally the first deactivation scenario in EHR Active Status (“the patient has died, and no further updates (typically relating to the death) are required”), where the deactivating act carries the death-related content.
  • Reactivate and write: EHR deactivated, one contribution with the status set to true plus new content. Accepted. The end state is identical to doing it in two contributions, and since the commit is atomic there is no intermediate state an order-dependent rule could observe.
  • Content against a deactivated EHR without a reactivating member stays refused, as before.

Your exact enable-write-disable sequence falls out of this too: it contains a member setting true, so the content is accepted, and the final status is whatever the contribution’s status members resolve to.

The fix plus tests for both orderings (content listed first, status second, so list order proves nothing) and two new conformance cases are landing in v4.0.2. The full adjudication with all the spec citations is on the tracker: bug(versioning): adjudicate the is_modifiable gate against a mixed CONTRIBUTION that flips EHR_STATUS.is_modifiable mid-commit · Issue #2673 · rubentalstra/FerroEHR · GitHub

Thanks for reading the code that closely, this is exactly the kind of review I was hoping for.

This is super impressive, at least from what I saw from the outside – congratulations!

I did not tested it yet. I know nothing about Rust, but the codebase and Git history look impressive, I can see you worked hard; GitHub workflows, actions, and policy are the same; docs are top-notch!

Performance and conformance were a super nice surprise to be tackled. And all these as AI-dev driven (of course) – super cool.

A few questions or remarks:

  • Relying on development specs of openEHR (RM 1.2.0, ADL 2.4) is a bit risky as they might change and introduce breaking changes between amendments until they are released formally; so consider specs and BMMs not stable on those development “branches”
  • The openEHR SM, CONF specs are stalled (not really maintained lately), even planned to be moved to a “paused” state; might not cover fully and correctly the RM/AM/REST released specs.

Once again: congratulations for this project!

Thank you @sebastian.iancu, that means a lot :slight_smile:

On the development-spec risk: this is exactly why FerroEHR generates both specification generations and lets the deployment choose. One configuration key, spec_profile, selects the whole set:

  • development (the default): RM 1.2.0, BASE 1.3.0, LANG 1.1.0
  • stable: RM 1.1.0, BASE 1.2.0, LANG 1.0.0 — the released generations of the profile-coupled model components

It is one coupled key instead of three independent ones because the generations reference each other: RM 1.1.0’s own published machine-readable model declares that it includes BASE 1.2.0 (you can see the includes clause in the ITS-BMM file itself), so a combination openEHR never published cannot even be configured.

Under stable the profile is a hard acceptance boundary in both directions. A query that addresses surface the released generations do not define gets a typed error naming the profile at planning time. Every commit also records whether the released generation’s reader can express the body, so a later stable deployment refuses such a version loudly (a 409 naming the profile, the version, and the remedy) instead of silently down-converting it. The active profile is printed in the boot banner and served on GET /management/info. The full behaviour, including the direction most implementations get wrong (released surface the development line later dropped stays accepted under stable), is documented here: Configuration reference - FerroEHR Documentation

When RM 1.2.0 releases formally we re-vendor, the previous generation stays emitted and selectable, and dropping one is an explicit decision, never a bump side effect. The spec layer is generated from the published machine-readable models (BMM, XSD, OpenAPI), so tracking an amendment is a re-vendor plus regeneration rather than a hand-port — that is what makes carrying two generations at once affordable. For the components outside the coupled profile we run a single pin per openEHR’s own release strategy: within a major line every release is a compatible superset (“significant additions that do not change the semantics of the existing part”), so the newer-generation pin accepts every valid older-minor instance.

On SM and CNF being stalled: agreed, and we treat them accordingly. The CNF schedule was a reference for which behaviours to cover, and we built our own conformance framework on top of it. The framework is a machine-readable catalogue, fully separated from any programming language: test cases, per-ITS wire bindings, closed vocabularies and the test corpus are all data artifacts with published JSON Schemas. A reference runner interprets them against a live CDR over the wire, and the verdicts are pure functions of the statement, the results and the catalogue. Every expectation in it is derived from the released components (RM, BASE, AM, QUERY, TERM, ITS-XML, and the ITS-REST docs text), never from the stalled schedule itself. Where a stalled source contradicts a released spec, the released spec wins. Where the specs are silent or contradict themselves we keep a public register of the ambiguities with our adjudication for each, and the defects we can verify first-hand get reported back upstream. We use the SM the same way: it is the structural authority for the service layer, and the released ITS-REST docs text binds it to the wire.

ADL is the one place we deliberately serve a development generation beside a released one, and there the spec itself mandates it: the BASE Architecture Overview says ADL 1.4 and ADL 2 are “maintained side by side, to enable implementers to work with the version(s) that suit their needs”.

Super exciting news @rubentalstra. And it’s nice to be able to sponsor, if only a little for now.
I was able to get ferroEHR running quite quickly.
The hoops I had to jump through is that I downloaded the docker compose by cloning the repo. This makes docker compose run from the override, which wants to use local images. They weren’t there, so docker started to built.
I created a PR to have the documentation reflect this early, in case it helps other people save time. feel free to delete. I did it mostly to show a willingness to contribute. Maybe I’m the only idiot unaware of this docker behaviour:)

and I haven’t done any ‘development’ on the machine recently. so had to update a lot of tools: docker, gh, brew, postman, Xcode etc. Might be worth it to create a (draft) code space. So also this part is a bit quicker and more isolated.

I followed the same steps (cloning the repo) and Docker pulled the images specified in the docker-compose.yml (lines 84 and 163) :thinking:

Thanks @joostholslag ,

merged your PR!

It is an great warning for new people to understand about the docker-compose.override.yml because my goal is that I want it to make it as seamless as possible for new people to start playing and testing it.

A Codespace for testers is a good idea, I will look into it.

Edit:
Thank you for the sponsor :slight_smile:

Very impressive and speedy work. Congratulations. I think your picture shows you standing in front of an IBM quantum computer - system 2. Any thoughts on when or how our openEHR world might start to align with and benefit from such technology and achieve even more stellar performance? :blush:

@joostholslag your Codespace idea is live, and I went one step further: there is now a hosted sandbox, so you can try FerroEHR without running Docker or anything else.

https://sandbox.ferroehr.eu opens straight into the Swagger UI. Sign in with ferroehr / ferroehr and you can create EHRs, upload templates, commit compositions and run AQL against a real server. It runs the latest release on a serverless stack, so the first request after an idle period takes a few seconds while it wakes up. The whole store is wiped and reseeded with demo data every night around midnight UTC, so feel free to break things. Demo data only, obviously.

For something closer to a dev setup there is also the one-click Codespace now: Sign in to GitHub · GitHub boots the published images on your GitHub account and forwards the API and admin console ports, nothing to install or update locally. Docs for both: Try it in Codespaces - FerroEHR Documentation

And thanks again for the sponsorship :heart:

Thank you @DavidIngram! Good eye, that is indeed IBM Quantum System Two, taken during a work visit to Zurich IBM research center.

Honestly: I don’t see quantum speeding up a CDR any time soon. Storing and querying health records is dominated by I/O, transactions and standards conformance, and none of that maps onto what quantum hardware is good at. Where I could imagine openEHR meeting quantum earlier is on the defensive side: post-quantum cryptography for signatures and audit trails, so records signed today still hold up decades from now. FerroEHR’s version signing is deliberately pluggable with that future in mind. :wink:

For stellar performance in the meantime I am betting on boring physics: Rust, PostgreSQL 18 and counting round trips. That already gets writes under 4 ms with the compliance features on.

Hi Ruben,

first of all: congrats, its very nice to have another take on openEHR implementations. Of course I will be bit biased because of EHRbase being something close to my heart, but I think it’s a quite bit problematic to test against your own interpretation of the specs and claim that they are fully met after the AI used these to build bigger parts of the application. Hence, I would be interested how the conformance specification compares to the large set of integration tests that we created over the years.

Thanks @birger.haarbrandt, fair question.

Grading my own “homework” was the first problem I had to deal with to be able to have an CNF setup to also test my RUST version.

The released spec texts are vendored in the repo at pinned versions and they are the only authority: FerroEHR/docs/specs/openehr at develop · rubentalstra/FerroEHR · GitHub. Every test case cites the spec file and section it asserts. Example case, with its spec_refs block: FerroEHR/tools/cnf-runner/artifacts/schedule/contribution/I_EHR_CONTRIBUTION.commit_contribution-imported_version_type.yaml at develop · rubentalstra/FerroEHR · GitHub

The catalogue also does not only test the happy path. For every behaviour the spec refuses, there is a negative case that pins the refusal and its exact error code, so a validator that quietly got more lenient turns the build red instead of drifting. And when triage shows a fixture was invalid and the server was right to reject it, the fixture does not get “fixed” and forgotten: it stays in the corpus as an invalid twin next to its corrected valid twin, one case proving acceptance, one proving the refusal. You can see that in the fixture names alone: FerroEHR/tools/cnf-runner/artifacts/corpus/fixtures/contribution at develop · rubentalstra/FerroEHR · GitHub (bad_charset, empty_code_string, fraction_non_integral, and so on). Strictness cuts both ways by the way: accepting what the spec refuses is a defect, and refusing what the spec allows is the same defect. Both directions have their own cases.

The important rule is what happens when a run goes red. There is a written triage procedure (FerroEHR/.claude/rules/cnf-triage.md at develop · rubentalstra/FerroEHR · GitHub) and it starts from the assumption that neither the server nor the runner is right. Each failing case gets a three-way comparison: what the spec text requires (with citation), what the catalogue expected, what the server actually put on the wire. The failure is then attributed to exactly one of server, runner, or catalogue. Adjusting an expectation to match the server is forbidden, and so is the argument “we wrote it to the spec”. For what it’s worth, the server has been the guilty one most often. The runner and the catalogue have both been wrong too, those fixes are in the git history.

When the spec is silent or contradicts itself, the case goes into a register with an explicit disposition (FerroEHR/tools/cnf-runner/artifacts/registers/ambiguities.yaml at develop · rubentalstra/FerroEHR · GitHub). The open and closed reports are all tagged: Issues · rubentalstra/FerroEHR · GitHub.

On the AI point: it does a lot of the work, but it does not get to decide anything. Every spec-silence ruling, every deviation, every weakened expectation needs my sign-off, and those rulings are written down in the issues. I have overruled it plenty of times, I would argue almost always.

And it is not only self-testing. The same catalogue runs against stock EHRbase (results committed under FerroEHR/docs/conformance at develop · rubentalstra/FerroEHR · GitHub, side-by-side in FerroEHR/docs/conformance/COMPARISON.md at develop · rubentalstra/FerroEHR · GitHub), and the fixtures lean on external corpora: the openEHR SDK canonical examples, CKM archetypes (all of them :wink:), Better’s web-template tests. When my catalogue and EHRbase disagree, that goes through the same triage, and the spec text decides. Some of those ended as fixes on my side, some as upstream reports.

Nothing here has to be taken on my word: runner, catalogue, per-case results and verdicts are all committed, and scripts/conformance.sh takes a bring-your-own SUT if you want to reproduce the EHRbase numbers on your own setup or run the catalogue against any other CDR. And if you spot a case where my catalogue expects something the released text does not back, please create an issue.

I’m curious to learn a little more about ‘why ferro exists’. I like that there’s a page for this. And it does describe the value of an open source openEHR CDR. And it briefly mentions it started as a fork from ehrbase.
What I’m interested in is a bit about your personal journey. What made you build a new CDR. What were your aims for the product. what made you personally interested in doing so. What did you miss in the currently available solutions?

Hi @joostholslag,

It honestly just started out of curiosity. I had already built some clinical projects in Rust in the past, so the language was not new to me. Then at some point I was playing with EHRbase and stress testing it, and I saw the memory usage during those tests, and I kept thinking: ugh, what if this was written in Rust? That thought never really left me, so I just started.

And let me tell you, I almost stopped with this project about three times. The complexity of openEHR is huge, the documentation is huge, and there is a lot of conflicting information in the docs. During my journey building it I found 228 inconsistencies or things that contradict each other so far, all filed with citations to the exact sections: Issues · rubentalstra/FerroEHR · GitHub. I want to curate those and bring them upstream at some point, because they cost me weeks and they will cost the next implementer the same weeks.

The other big reason was that almost every SDK and solution out there is built on Java, because of the Archie project. Which also means you always drag a JVM along. I wanted no runtime at all, just one static binary that you drop on a server next to Postgres and it runs. And I was wondering how fast I could make it if I wrote my own lexer, and my own code generation engine that generates the whole spec layer straight from the machine readable specs. That was the real bet for me. If the spec layer is generated, then a future spec release is mostly a regeneration plus reviewing the diff, and the CDR stays maintainable when the specs change.

The way it grew was very much step by step. First the openEHR spec crates in Rust. Once those worked I thought, well, maybe I can do the REST API on top of that. Then I implemented the SM service specs. And then came the hard part: storage. That was a total nightmare by itself. Big JSONB documents in Postgres are expensive to pick apart, so I ended up designing the storage from scratch on Postgres 18 instead of copying the usual current+history pattern. It is all written up in FerroEHR/docs/architecture.md at develop · rubentalstra/FerroEHR · GitHub if you want the details.

What did I miss in the existing solutions? Honestly, mostly the footprint, that is what started the whole thing. And a serious non Java option. But there was one more pain point for me: the split between the open source version and the commercial one. The features you actually need to run compliant in a clinical environment, things like RBAC and ABAC, or ATNA audit logging, are exactly the ones that tend to get stripped out and sold separately. Personally I am just not a fan of that model. Big institutions will most likely buy the product through a reseller anyway, for the SLA and the support contract, so in my view stripping features out of the open source edition mostly punishes everyone else. That is why FerroEHR has one edition. The access control, the ATNA audit trail, multi-tenancy, all of it is in the open source version. There is no paid variant holding the important parts.

I did not start with a grand product vision, the aims grew while I was building.

@joostholslag - good question!

@rubentalstra - the experience you gained through this voyage is of a high value - that is as much I can shortly say here. I am still impressed about your results so far, I can see you worked hard on this - even though you used ai-agents to code it. :+1:

As @SEC , I am interested on those inconsistencies of specs that you want to propagate upstream later - so don’t throw them away pleasse. :slight_smile:

I’m sure you are right as of now. I had to manoeuvre around read/write latency on storage devices for many years - see my book Chapter Five on this evolution. To keep us humble, we should maybe reflect that living cells achieve very much greater information density than our digital technologies. Some of the comparative numbers - also from von Neumann’s 1950s valedictory lectures on the computer and the brain - are in chapter six I think. Has anyone done a calculation of information density and access latency that might somehow be achievable in a loop of glass fibre, for example. Maybe it was a silly and physically unrealistic fantasy, but I sometimes imagined structured data encoded onto and flowing at the speed of light past decoders! A kind of optical content addressable file store.
I rather imagine that quantum computers will come more into play as large scale storage devices continue to close the gap with biology - it will no doubt take a while!
Re the system 2, I reflect that only a decade ago we had to create a new building at Oxford for the experimental entrained ion quantum devices of that time to be able to live several levels down in the basement, in a vibration free environment!!
Thank you for your great contribution and the spirit in which you are offering it.