Introduction
FerroBRIDGE is a pure-Rust, standalone bridge between openEHR and two interoperability targets: HL7 FHIR, driven by the FHIRconnect specification, and the OMOP Common Data Model, driven by the OMOCL specification. Mappings are specification-conformant YAML validated against published schemas, never hand-coded per FHIR resource or per OMOP table. FerroBRIDGE runs as its own server beside any openEHR CDR it reaches over the openEHR ITS-REST API, and it asks a FHIR terminology server for code systems and value sets.
Where the project is
FerroBRIDGE is in its design phase. The research pass over the primary sources is complete and the design it produced is recorded in the architecture. There is no Cargo workspace, no release, and no binary to run yet. Nothing in this book describes software you can download today, and every page says which parts are decided and which are still open.
What the design settles: one shared mapping foundation with two interpreters and two sinks, FHIRconnect v1.0.0 on FHIR R4 and OMOCL v1.0.0 on OMOP CDM v5.4, a FHIR facade over the CDR on one side and a batch ETL into a CDM database on the other. The build order page says what comes first.
How this book is organised
The four parts follow what you came to do.
- Evaluate answers whether FerroBRIDGE fits your problem: the design, the two mapping languages it reads, the version pins, the build order, and the licence.
- Operate covers what the running bridge needs around it and how it behaves when a mapping or an upstream call fails.
- Integrate covers the two target surfaces and the mapping files that drive them.
- Contribute covers how the work is tracked and which checks a change has to pass.
The tracker is the scope. Open issues are the worklist, milestones are releases, and the roadmap is the public view of both.
Architecture
FerroBRIDGE is a standalone bridge between openEHR and two interoperability
targets: HL7 FHIR through the FHIRconnect specification, and the OMOP Common
Data Model through the OMOCL specification. It runs beside an openEHR CDR and
reaches it only over the openEHR ITS-REST API, so it works against a CDR it did
not build. This page is the short tour. The design authority, with the ground
for every decision, is
docs/architecture.md
in the repository.
- The shape
- One foundation, two interpreters, two sinks
- The openEHR side
- The FHIR side
- The OMOP side
- Generated and hand-written
- Where the specifications are silent
The shape
graph LR
FHIRCLIENT["A FHIR client"] -->|"FHIR R4 REST"| FACADE["FerroBRIDGE: the FHIR facade"]
FACADE -->|"FHIRconnect mappings"| CORE["The shared mapping foundation"]
ETL["FerroBRIDGE: the OMOP ETL runner"] -->|"OMOCL mappings"| CORE
CORE -->|"openEHR ITS-REST"| CDR["An openEHR CDR"]
FACADE -->|"$lookup, $translate, $validate-code"| TERM["A FHIR terminology server"]
ETL -->|"typed CDM rows"| CDM["An OMOP CDM v5.4 database"]
VOCAB["The OHDSI vocabulary, loaded"] --> ETL
Both sides read openEHR content the same way and write to different places. The FHIR side answers requests one at a time and keeps no clinical data of its own. The OMOP side runs as a batch job, queries the CDR with AQL, and writes rows.
One foundation, two interpreters, two sinks
FHIRconnect and OMOCL are written by the same author and share one header
(grammar, type, metadata, spec, and spec.openEhrConfig pinning the
archetype and revision). Below that header they are different languages: one is
bidirectional and tree-shaped against FHIR paths, the other is one-directional
and flat against CDM columns. FerroBRIDGE shares the header model, the YAML
loader, the archetype-keyed mapping registry, and the RM-path model, then gives
each language its own parser, validator, and interpreter. A single intermediate
representation over both grammars would be the union of two dissimilar
languages, and it would break the first time either specification moved.
The two mapping specifications page has what each language actually says.
The openEHR side
Mapping paths in both languages are RM and archetype paths such as
$archetype/data[at0001]/items[at0077], never FLAT paths. A path alone is not
executable: the leaf RM type and the template-specific identifiers come from the
Web Template built from the template’s operational template. The order is the
operational template, then the Web Template, then path resolution, then the
composition. FerroBRIDGE fetches the operational template from the CDR, builds
the Web Template locally with the published openehr-* crates, resolves paths
against the canonical composition tree, and commits and reads canonical JSON.
FLAT never crosses the wire.
FHIRconnect requires the context mapping’s start entry to slot in a reusable
COMPOSITION.<archetype>.<Resource> mapping, and it defaults the
composition-level fields that have no FHIR counterpart: composer,
context/start_time, setting, language, territory, and category.
FerroBRIDGE follows those defaults and records every defaulted value in the
composition’s FEEDER_AUDIT, so a reader can tell a mapped value from a
defaulted one.
The FHIR side
FerroBRIDGE exposes a FHIR R4 REST facade and maps each request onto CDR
operations. It stores no clinical data. A transaction Bundle is all or nothing
and a batch Bundle answers per entry, as FHIR R4 defines them. Bundles are
split by the profiles in meta.profile, a resource that two mappings both need
becomes a linked mapping, and unresolved references are fetched from the
sending site with cycle protection. Beside the facade, the bridge serves the
two operations the FHIRconnect specification is adding in its draft REST API
chapter, $tofhir and $toopenehr, as the engine’s own conformance surface;
the facade is a client of the same in-process engine.
flowchart LR
F["FHIRconnect YAML<br/>model, extension, context"] --> M["model<br/>published schemas exercised,<br/>strict schemas, semantic checks"]
M --> R["resolve<br/>one immutable program<br/>per profile and template"]
R --> E["engine<br/>one traversal, both directions,<br/>data-type lenses"]
T["tree<br/>path model over FHIR JSON<br/>guided by the element table"] --- E
W["Web Template index"] --- R
E --> O1["$tofhir, $toopenehr"]
E --> O2["The facade over the CDR"]
E -->|"external codes only"| TS["Terminology server"]
FHIRconnect states that it “does not focus specifically on AQL and FHIRsearch”,
so FHIR search has no specification behind it here. The search design is
FerroBRIDGE’s own: an AQL projection per resource type declared beside the
context mapping, executed over POST /query/aql, with the CapabilityStatement
naming exactly which search parameters each resource supports and an
OperationOutcome refusing the rest. The FHIR facade
page has the detail.
The OMOP side
OMOP is a relational analytics schema rather than an API, so the deliverable is rows in a populated CDM v5.4 database. The OMOP engine is a batch ETL job runner over AQL, writing typed CDM rows into a PostgreSQL CDM database built from the OHDSI DDLs, with the derived tables generated rather than mapped.
OMOP concept resolution is SQL over the locally loaded OHDSI vocabulary: the
CONCEPT table, standard_concept, the domain, and the CONCEPT_RELATIONSHIP
“Maps to” traversal. It is never a FHIR terminology operation. An unmapped code
lands as concept_id = 0 with the source value kept, which is what the CDM
means by “no matching concept”, and it is never dropped. The
OMOP ETL page has the detail.
flowchart TB
Q["AQL result set, streamed"] --> C["One composition"]
C --> E["omocl engine"]
E --> G["Record graph<br/>rows and FACT_RELATIONSHIP links"]
G --> R["Concept resolver<br/>SQL over the loaded vocabulary"]
R --> K["Side table<br/>natural key, surrogate id, watermark"]
K --> W["binary COPY<br/>one composition, all or nothing"]
W --> DB[("OMOP CDM v5.4")]
W --> REP["Run report<br/>rows, concept 0, refusals"]
Generated and hand-written
Two models are generated, because both are published in machine-readable form
and a hand-transcribed copy drifts from its source with no way to detect it.
The FHIR model, the fhir-types crate, is emitted by fhir-codegen from the
HL7 FHIR packages; it moves into this repository from the sibling terminology
server, which then consumes it from crates.io. The OMOP CDM v5.4 row types are
emitted from the OHDSI CommonDataModel field definitions, with the OHDSI
PostgreSQL DDL vendored verbatim beside them. The openEHR model, and the
ITS-REST client the bridge calls the CDR through, come from the published
openehr-* crates. Everything that makes FerroBRIDGE a bridge is
hand-written: the mapping foundation, both mapping languages’ types, validators
and interpreters, the FHIR path model, the terminology client, the facade, and
the ETL runner.
Where the specifications are silent
FerroBRIDGE labels its own decisions as its own, in the code and in these pages. The FHIRconnect engine chapter is explicitly a set of recommendations, and no specification governs FHIR search, identity, extension ordering, or failure policy. Each of those is pinned as a FerroBRIDGE decision on the failure and identity page and in the architecture document.
The two mapping specifications
FerroBRIDGE reads two mapping languages. Both describe how openEHR content relates to something else, both are YAML, and both are published by the same author. Knowing where they agree tells you what the bridge can share; knowing where they differ tells you why it keeps two interpreters.
What they share
One header. grammar, type, metadata.name and metadata.version, spec,
and spec.openEhrConfig pinning the archetype and its revision. The FHIRconnect
header page states that the header “is standardized for both FHIRconnect and
OMOCL”.
Below the header they also share:
- RM and archetype paths, with
../navigation. - Archetype-keyed model files.
- An include mechanism:
slotArchetypein FHIRconnect,Includein OMOCL. - An escape hatch to code outside the mapping:
mappingCodein FHIRconnect,CustomMappingin OMOCL. - A code-translation concept:
conceptmapin FHIRconnect,conceptMapin OMOCL.
Nothing else is common.
FHIRconnect
FHIRconnect v1.0.0 is bidirectional. A mapping is a tree of entries, each
pairing a FHIR path with an openEHR path through with, typed from the
instances. followedBy nests entries and concatenates their paths onto the
parent’s. Conditions are AND-combined and apply to the input side only.
manual supplies literals, reference points at another resource, hierarchy
realigns a tree, and unidirectional marks an entry that runs in one direction
only.
There are three file types, and they compose in a fixed order:
| File type | What it carries |
|---|---|
model | an archetype mapped to an unprofiled resource, the shared layer |
extension | profile and template adaptation, executed after the model |
context | the entry point: the profile, the template, the archetypes, the extensions, and start |
Two JSON schemas are published, model-mapping.schema.json and
contextual-mapping.schema.json, both draft-07. They are the validation oracle,
and FerroBRIDGE validates every mapping against them before it runs anything.
The specification is at
https://sevkohler.github.io/FHIRconnect-spec/build/site/FHIRconnect/v1.0.0/index.html.
OMOCL
OMOCL v1.0.0 is one-directional, openEHR to OMOP, by construction. A mapping is
a flat list of records, each keyed by a CDM target such as Measurement,
ConditionOccurrence, or Person. Inside a record, the keys are CDM column
names, and each takes an ordered list of alternatives: an RM path, or a
literal code holding an OMOP concept_id. The value 0 is the CDM’s “no
matching concept”. Records also carry optional, inline conceptMap tables,
Include, and CustomMapping. The published files use YAML anchors and
aliases, so the loader has to resolve them.
OMOCL publishes no JSON schema. Its grammar comes as a railroad diagram and syntax tables, and its lexer and parser are marked work in progress. FerroBRIDGE authors a JSON schema from the grammar tables and the published mapping library, validates every OMOCL file against it, and offers that schema upstream. The specification is at https://github.com/SevKohler/OMOCL.
What this means for the bridge
The shared header, the path model, the include mechanism, and the YAML loader live in one foundation crate. Everything above them is per language, because a tree of bidirectional FHIR path pairs and a flat list of CDM column alternatives have no useful common form. That split is the first decision in the architecture.
Pinned versions
Every version FerroBRIDGE targets is pinned in one place,
docs/VERSIONS.md,
and a committed guard (scripts/checks/versions.sh) fails when any file that
repeats a pin disagrees with it. This page is the reader’s copy of the
specification pins and the reason each one is what it is.
The specification pins
| Component | Pin | Why |
|---|---|---|
| FHIRconnect | v1.0.0 | the only released version; its two published JSON schemas are vendored and exercised, and FerroBRIDGE adds a strict schema of its own because the published ones reject three of the specification’s own mapping types |
| FHIR | R4 (4.0.1) | the only value the FHIRconnect schemas admit for spec.version, and what the published mapping library targets |
| OMOCL | v1.0.0 | the only released grammar (grammar: OMOCL/v1.0.0); the corpus is pinned by commit, because the git tag v1.0.0 carries files that predate the grammar |
| OMOP CDM | v5.4 | the only version the published OMOCL files declare |
| openEHR ITS-REST | 1.1.0 | the released REST API a conformant CDR speaks |
The FHIRconnect prose says other FHIR releases should work, and nothing tests that claim, so FerroBRIDGE does not make it. A later FHIR release is feature-gated in the model crate and claimed once a mapping corpus proves it.
Terminology operations
The FHIR terminology client speaks CodeSystem/$lookup,
ConceptMap/$translate, and ValueSet/$validate-code, and tolerates a server
answering R4 or R4B. The terminology server is configured, never assumed. OMOP
concept resolution does not go through it at all; it is SQL over the loaded
OHDSI vocabulary.
The two model crates
The FHIR model is the fhir-types crate, generated in this repository from the
HL7 FHIR packages (R4 4.0.1, R4B 4.3.0, R5 5.0.0, R6 6.0.0-ballot5, THO 7.3.0)
and published to crates.io; its version line continues the one the sibling
project published up to 0.1.97 before the crate moved here, and the current
pin is the fhir-types row of docs/VERSIONS.md. The openEHR model comes
from seven published crates, pinned together on their 0.0 minor line (0.0.69
today; the pin matrix is the authority):
| Crate | Used for |
|---|---|
openehr-base | the RM foundation types, including partial dates |
openehr-rm | the RM 1.1.0 model, its canonical JSON codec, the path parser |
openehr-its | the OPT 1.4 codec, the canonical JSON codec, the ITS-REST data types |
openehr-sdt | the Web Template builder, the FLAT and STRUCTURED codecs, the composition builder, the RM-instance validation |
openehr-query | the AQL 1.1.0 parser and printer |
openehr-am | the AOM2 types an ADL 2 template decodes into |
openehr-adl | test only: compiles the ADL 2 fixtures from their .adls sources |
Language and toolchain
The Rust toolchain, edition, resolver, and MSRV are pinned in the matrix and
adopted by the workspace when it lands. The documentation toolchain is pinned
too: mdbook, mdbook-toc, and mdbook-mermaid all carry an exact version in
the matrix and in the composite action that installs them, so this book renders
the same way in CI as it does on a laptop.
Build order
FerroBRIDGE has no release. This page says what exists in the repository today and what gets built next, in order. The tracker is the scope: milestones are releases, and a release is cut when its milestone has no open issue left.
What exists today
- The design, recorded in
docs/architecture.md, with the pins and the decisions that follow from the primary sources. - The pin matrix,
docs/VERSIONS.md, and the guard that fails on cross-file version or licence drift. - The working discipline: the engineering rules, the tracker workflow, and the committed check scripts.
- The security and analysis workflows that work on a repository with no code: OpenSSF Scorecard, CodeQL over the workflow files, and SonarQube Cloud’s multi-language sweep.
- The release lane, which turns a signed
vX.Y.Ztag into a GitHub release whose notes are the changelog section for that version. It builds a Linux binary per architecture and the container image in isolated reusable workflows, so each carries provenance and an SBOM you can verify against the workflow that signed it. - This documentation site.
The Cargo workspace holds the foundation: the generated FHIR model and OMOP
CDM layer, the shared mapping core, the ITS-REST and terminology clients, the
ferrobridge binary with its serve surface (configuration, telemetry,
health and readiness, graceful shutdown), the test support crate, the
distroless container image with its compose quickstart, and the release lane
that builds and attests both. The FHIR and OMOP round trips are the next two
releases.
What comes next
The program is one verbatim round trip per target, on mapping files published upstream rather than files written for the occasion, in three releases:
- v0.0.2, the foundation. The Cargo workspace with every lint, the vendored
corpora with provenance, the two generated model crates (
fhir-types, moved in from the sibling terminology server, andomop-cdm), the shared mapping foundation, the ITS-REST and terminology clients, the server binary, the container image, and the attested release lane with the crates.io leg. Nothing maps yet; everything the mapping needs exists and is published. - v0.0.3, the FHIR round trip. The published
EVALUATION.problem_diagnosis.v1FHIRconnect mapping and its published extensions, with an R4Conditioncommitted to a CDR over ITS-REST, read back, and mapped to FHIR again. The result equals the input except for the set of fields the program declares defaulted or unmapped, and that set is asserted exactly. - v0.0.4, the OMOP round trip. The published laboratory OMOCL files,
emitting MEASUREMENT and FACT_RELATIONSHIP rows into a CDM 5.4 database.
Resolved
concept_idvalues are asserted, an unmapped code lands as0with its source value kept and is counted, and a second run produces an identical database.
Later releases widen each side to the whole published library, then add FHIR search, which no specification governs. Nothing is scaffolded before its issues are filed.
How correctness is measured
The published FHIRconnect and OMOCL mapping libraries get vendored verbatim by committed fetch scripts with provenance, and exercised in full: every file loads and validates, or carries a recorded skip with its reason. The composed test stack runs an openEHR CDR reached over ITS-REST only, and a FHIR terminology server. Both are configured deployments, never compile-time dependencies. None of this exists yet; it lands with the first engine work.
What is outside the design on purpose
Demographics, where FHIRconnect sends resources to an unspecified external endpoint; FHIR Subscriptions, which the specification never mentions; a materialised FHIR store, which would contradict the facade decision; and OMOP CDM versions other than 5.4. Each one is a tracker issue rather than silence, so you can read the reasoning and argue with it.
The current picture is the milestone list, and the open issues are the worklist behind it.
Licensing
FerroBRIDGE is source-available under the Business Source License 1.1. The
authoritative text is
LICENSE,
with the parameters in
NOTICE. This
page summarises it; where the two differ, the licence text wins.
What you may do without asking
Read, build, modify, and redistribute the source, with no fee and no conversation. That covers every non-production use: development, testing, evaluation, and prototyping.
Production use is free for Non-Commercial Purposes, which the licence defines as personal use, academic or scientific research, teaching, and use by a non-profit organisation or public body that is not acting in the course of a business, does not deliver a service for payment, and is not seeking commercial advantage.
What needs a commercial licence
Any other production use. A hospital, clinic, or care provider running FerroBRIDGE between its systems needs one. So does a vendor, an integrator, or any company running it in production. Offering FerroBRIDGE, or a work derived from it, to third parties as a hosted, managed, or embedded service that maps or exchanges health data needs one in every case, and so does selling, sublicensing, or distributing it for a fee, on its own or inside another product.
A commercial licence is arranged with Cadasto B.V., the Licensor, which handles
the business side of FerroBRIDGE: write to
info@cadasto.com or use
https://www.cadasto.com/contact/. Technical questions go to the maintainer
named in
MAINTAINERS.md.
The change date
Each version becomes available under the Apache License 2.0 four years after that version is published. The licence calls that the Change License, and it is the one place Apache 2.0 appears as a licence of this project’s own code.
There is no open-core tier
The bridge, the server, and the tools are in this repository under the one licence. Nothing is held back to be sold back to you.
Contributions and third-party material
A contribution is licensed under the same licence.
You keep your copyright, and you grant the Licensor the relicensing right in
CONTRIBUTING.md § Licensing of contributions, recorded by a checkbox in the
pull request.
Vendored specifications and third-party material keep their upstream terms,
recorded in a PROVENANCE.md beside each vendored tree.
What FerroBRIDGE runs beside
FerroBRIDGE is not released yet, so there is nothing to install today. What is decided is the shape of the deployment: which systems the bridge talks to, what it stores, and what you have to supply. Read this page to plan; the commands arrive with the first release.
- One process, four neighbours
- What FerroBRIDGE stores
- What it asks of the CDR
- What it asks of the terminology server
- What the OMOP side asks of the database
- Process model
One process, four neighbours
FerroBRIDGE is its own process and its own image. It is not a crate inside a CDR, not a plugin, and not a library a CDR links. It reaches every neighbour over a network protocol, which is what lets it work against a CDR it did not build.
| Neighbour | Protocol | Needed by |
|---|---|---|
| An openEHR CDR | openEHR ITS-REST 1.1.0 | both sides |
| A FHIR terminology server | FHIR terminology operations, R4 or R4B | the FHIR side |
| An OMOP CDM v5.4 database | SQL, PostgreSQL | the OMOP side |
| The OHDSI vocabulary, loaded into that database | SQL | the OMOP side |
You run each of these, or you point FerroBRIDGE at ones you already run. None of them is a compile-time dependency, and the bridge treats any conformant implementation the same way.
What FerroBRIDGE stores
Clinical data lives in the CDR and, on the OMOP side, in the CDM database.
FerroBRIDGE stores two things of its own: the id-map, a persistent store
holding the patient identifier to ehr_id relation, the external to internal
resource id relation and the resource id to composition relation; and, on the
OMOP side, a side table in its own schema beside the CDM that maps each source
record to its rows, which is what makes a re-run replace rather than
duplicate. Everything else it holds is configuration: the loaded
mapping files, the terminology server address, and the CDR address.
What it asks of the CDR
The FHIR side uses the composition and EHR endpoints:
POST /ehr and GET /ehr?subject_id=&subject_namespace=,
POST, PUT, and GET on /ehr/{ehr_id}/composition[/{uid}], and
POST /ehr/{ehr_id}/contribution for an atomic multi-object commit. Both sides
use POST /query/aql. Templates come from
GET /definition/template/adl1.4/{template_id} as canonical XML; FerroBRIDGE
builds the Web Template from that itself, so the CDR need not serve one.
Compositions cross the wire as canonical JSON.
A CDR that does not serve the operational template cannot drive the bridge, because a mapping path alone does not carry the leaf RM type.
What it asks of the terminology server
Three operations: CodeSystem/$lookup, ConceptMap/$translate, and
ValueSet/$validate-code. $lookup matters most, because openEHR’s
DV_CODED_TEXT.value is mandatory while FHIR’s display is not, so a display
often has to be resolved rather than copied. The client tolerates a server
answering R4 or R4B.
What the OMOP side asks of the database
A CDM v5.4 schema built from the OHDSI DDLs, and an Athena vocabulary export
loaded into the vocabulary tables. Concept resolution is SQL over CONCEPT,
standard_concept, the domain, and the CONCEPT_RELATIONSHIP “Maps to”
traversal. Without a loaded vocabulary every source code resolves to
concept_id = 0, which is legal in the CDM and useless for analysis.
Process model
No specification governs the process model, the storage mechanics, or the deployment topology: that is FerroBRIDGE’s own design. The decisions on record are a single binary, a container image, and a batch ETL that runs as a job rather than as a listener. Change notification is not part of ITS-REST 1.1.0, so an incremental OMOP mode would be a FerroBRIDGE extension, labelled as one, with the batch path staying the conformant baseline.
Configuring the server
FerroBRIDGE reads an optional TOML file, then environment variables over it. Nothing else configures the process. No specification governs this; it is FerroBRIDGE’s own design.
- Where the file comes from
- How an environment variable maps to a key
- The variables
- The secrets, in one place
- What a bad configuration does
- An example
Where the file comes from
ferrobridge serve --config /etc/ferrobridge/config.toml names the file. With
no flag, FERROBRIDGE_CONFIG names it. With neither, the defaults below stand
and the server serves with no upstream configured.
How an environment variable maps to a key
Prefix FERROBRIDGE__, then the dotted key with two underscores between
levels. Case does not matter; single underscores inside a key name stay as
they are.
FERROBRIDGE__SERVER__LISTEN -> [server] listen
FERROBRIDGE__CDR__BASE_URL -> [cdr] base_url
FERROBRIDGE__CDR__RETRY__MAX_ATTEMPTS -> [cdr.retry] max_attempts
A value takes the type of the key it sets. A string key takes the text as it
is, whatever its characters: FERROBRIDGE__CDR__CREDENTIALS__USER=12345 sets
the user name 12345, and true or 1.5 stay text too. Quotes are part of
the value, so do not add any. A number, boolean or array key reads the text as
TOML syntax: 5 is the number 5, true is a boolean and ["a","b"] is an
array. Text that does not read as the key’s type is refused at boot, naming
the key.
The variables
Every key below is settable in the file and as FERROBRIDGE__<SECTION>__<KEY>.
A key marked secret also accepts a <key>_file sibling naming a file the
server reads once at boot, with surrounding whitespace trimmed.
[server]
| Key | Default | Meaning |
|---|---|---|
listen | 127.0.0.1:8080 | The socket address to bind |
request_timeout_ms | 30000 | How long one request may take before the server answers 408 |
shutdown_timeout_ms | 10000 | How long the drain may take after SIGTERM or SIGINT |
body_limit_bytes | 1048576 | The largest request body read before the server answers 413 |
[telemetry]
| Key | Default | Meaning |
|---|---|---|
format | auto | auto, json or pretty; auto is pretty on a terminal and json otherwise |
filter | info,hyper=warn,tower=warn,h2=warn | A RUST_LOG-style directive; a directive that does not parse falls back to this default and says so once |
logged_query_parameters | [] | The query parameter names whose values may reach the request log, each value cut at 64 characters |
Nothing else about a request is logged. Add a name to
logged_query_parameters only for a parameter you know carries no identifying
content.
The console
Two more variables set the console for one run, and they win over the file
and over FERROBRIDGE__TELEMETRY__FORMAT and FERROBRIDGE__TELEMETRY__FILTER:
| Variable | Sets | Values |
|---|---|---|
FERROBRIDGE_LOG_FORMAT | [telemetry] format | auto, json or pretty; any other value refuses the start with exit 78 |
RUST_LOG | [telemetry] filter | A tracing filter directive such as debug,hyper=warn |
FERROBRIDGE_LOG_FORMAT=pretty RUST_LOG=debug ferrobridge serve
auto writes pretty with colour on a terminal and json without colour
anywhere else, so a container or a log pipeline gets one JSON object per line
with no configuration. An explicit pretty keeps its colour into a pipe.
Colour only wraps text, so a line reads the same once a collector strips the
escapes. In pretty, a carriage return or line feed inside a logged value is
written as the two characters \r or \n, so no value can start a second
line; json escapes it inside the string.
Under pretty, ferrobridge serve prints a banner to stdout before the first
log line: the wordmark, the version, the pins this build serves, the commit and
build instant, and each lane with the hosts it reaches. A host is the URL’s
host and port and never its user information. Under json nothing but log
lines reaches stdout.
The first boot events describe the process:
| Event | Fields |
|---|---|
console | format, rendering, filter (the one in effect, after a fallback), colour |
build | version, commit, built_at, rustc, openehr_crates, fhir_types |
lane | lane (facade, operations, etl or terminology), enabled, identifiable, and where set cdr_host, terminology_host, cdm_host, cdm_schema, bridge_schema, contexts, programs, templates |
listening is the last boot event. The etl lane reports what
ferrobridge etl run would reach; serve never runs it.
GET /health/info answers the same build facts and pins as JSON, so a scraper
reads what the banner shows:
{
"product": "FerroBRIDGE",
"version": "0.0.3",
"build": {
"commit": "0123456789abcdef0123456789abcdef01234567",
"commit_short": "0123456789ab",
"built_at": "2026-01-01T00:00:00Z",
"rustc": "rustc 1.98.1 (48a229cea 2026-09-01)"
},
"pins": [
{ "name": "FHIRconnect", "version": "v1.0.0" },
{ "name": "FHIR", "version": "R4 (4.0.1)" }
]
}
The pin list is shortened here; the route lists every pin the banner prints.
built_at is the SOURCE_DATE_EPOCH instant when the build sets one, and the
commit is unknown for a build outside a git checkout.
[cdr]
The openEHR CDR lane. Absent, the bridge holds no CDR client and readiness reports no CDR indicator. This section reaches identifiable data, and the server says so once at start-up, naming the section and never a value.
| Key | Default | Secret | Meaning |
|---|---|---|---|
base_url | none, required | The openEHR REST API root, usually ending in /v1 | |
timeout_ms | 30000 | How long one call may take, connection included |
[cdr.retry] and [terminology.retry]
| Key | Default | Meaning |
|---|---|---|
max_attempts | 3 | How many times a call is sent at most, the first try included; 1 disables retry |
initial_backoff_ms | 200 | The delay before the second attempt |
max_backoff_ms | 5000 | The ceiling every later delay is clamped to |
For the CDR, the bridge calls through the generated ITS-REST client of the
openehr-its crate, and the three keys are that client’s retry budget. Only a
GET, PUT or DELETE is sent again, after a failure to connect, a
timeout, or a 5xx other than 501; a POST is sent once. A 401, a 403
and a status the operation does not document are never retried.
[cdr] timeout_ms is the per-request timeout of the client’s HTTP engine.
[cdr.credentials] and [terminology.credentials]
Set one scheme. A bearer token and a user together is a boot error, and so is
an inline value beside its _file sibling.
| Key | Default | Secret | Meaning |
|---|---|---|---|
bearer_token | none | yes | An RFC 6750 bearer token |
user | none | The user name of RFC 7617 basic authentication; a user_file sibling names a file holding it, read at boot like a secret | |
password | none | yes | The password of RFC 7617 basic authentication |
For the CDR, the scheme becomes the Authorization header of every call the
generated client sends: Bearer <token> for a token, and Basic over
user:password for a user and a password.
[terminology]
The FHIR terminology lane. Absent, the bridge holds no terminology client, and the engines fail closed on anything that would need one.
| Key | Default | Meaning |
|---|---|---|
base_url | none, required | The FHIR service base URL |
wire_version | r4 | r4 or r4b, the release the server answers in |
timeout_ms | 30000 | How long one call may take, connection included |
[cdm]
The OMOP CDM database lane. This section reaches identifiable data, and the server says so once at start-up.
| Key | Default | Secret | Meaning |
|---|---|---|---|
url | none, required when the section is present | yes | The PostgreSQL connection URL; it must name its sslmode |
tls_ca | none | no | The PEM CA the database’s certificate is checked against |
tls_ca_file | none | no | A file holding that CA, read at boot |
schema | cdm | no | The schema the CDM tables live in, an unquoted lower-case identifier |
bridge_schema | ferrobridge | no | The schema the bridge keeps its natural-key side table, id sequences and watermarks in |
person_policy | create_on_first_sight | no | create_on_first_sight gives an EHR a person_id the first time a row refers to it; existing refuses a row whose EHR has no PERSON row yet |
Two clients open the database: the concept resolver’s pool and the CDM
writer’s own connection. Both read the sslmode of url and the CA from
tls_ca or tls_ca_file, so they agree on whether the connection is
encrypted and what it trusts. The modes are the libpq ones that never fall
back to plaintext
(https://www.postgresql.org/docs/current/libpq-ssl.html):
sslmode | What the clients do |
|---|---|
disable | Connect without TLS. A CA with disable is refused |
require | Connect over TLS. With a CA, the certificate must chain to it, as libpq checks when a root certificate is present; without one, it is not checked |
verify-ca | Connect over TLS; the certificate must chain to the CA, or to the webpki root set when no CA is set |
verify-full | As verify-ca, and the certificate must name the host in url |
prefer and allow are refused when the configuration is read, naming the
mode: a client that honours them connects in plaintext when the server offers
no TLS, and that fallback is silent. A URL that names no sslmode is refused
the same way, because libpq’s default is prefer: write sslmode=disable
for a database on a trusted local network or in a test container, and one of
the three TLS modes everywhere else. Any other ssl parameter in the URL
(sslrootcert, sslcert, sslkey and the rest) is refused too: the CA
comes from tls_ca_file, and the bridge sends no client certificate. For the
same reason the bridge refuses to start when PGSSLROOTCERT, PGSSLCERT or
PGSSLKEY is set in its environment: the pool’s PostgreSQL client reads
those variables whatever the configuration says, and the writer does not. The CA is public material, so it is
not a secret, and FERROBRIDGE__CDM__TLS_CA and
FERROBRIDGE__CDM__TLS_CA_FILE set the two keys from the environment; setting
both is refused. A typical production URL, with the CA mounted beside it:
[cdm]
url_file = "/run/secrets/cdm_url" # postgres://bridge:...@cdm.internal:5432/omop?sslmode=verify-full
tls_ca_file = "/run/secrets/cdm_ca.pem"
What cdm init does
ferrobridge cdm init reads which CDM tables [cdm] schema already holds
before it applies anything:
| The schema holds | cdm init |
|---|---|
| none of the 39 CDM v5.4 tables, or does not exist | Creates the schema when absent, then applies OHDSI’s tables, primary keys and indices in one transaction, and exits 0 |
| every CDM v5.4 table | Applies nothing, prints that the schema is already initialised with the cdm_version its CDM_SOURCE rows record, and exits 0 |
| some of the tables | Applies nothing and exits 1, naming every missing table |
It then creates the bridge schema, which is safe to repeat. Tables outside the CDM’s 39 are left alone and do not count. The check reads table names only, so a schema whose tables exist without their keys or indices counts as initialised.
OHDSI’s fourth file, OMOPCDM_postgresql_5.4_constraints.sql, carries the
foreign keys and is left out by default. cdm init --with-constraints
applies it after the tables, statement by statement in one transaction of its
own. At the pinned OHDSI tag v5.4.3 PostgreSQL refuses line 157, a foreign
key onto vocabulary (vocabulary_id) that the primary keys file gives no key
(SQLSTATE 42830), so the flag exits 1, prints that statement, and leaves the
tables in place and no foreign key behind.
[etl]
The OMOP ETL job that ferrobridge etl run runs. Both queries are parsed and
checked when the configuration loads, so a query the runner cannot read
refuses every job.
| Key | Default | Secret | Meaning |
|---|---|---|---|
aql | none, required | no | The composition query. It aliases ehr_id, version_uid and the whole composition, carries an ORDER BY and no LIMIT. It may name $since, which --since binds. It may also alias versioned_object_uid, which the runner then checks against the version; when it does not, the runner reads it from the version_uid, whose object_id part names the versioned object (openEHR RM Common 1.1.0, OBJECT_VERSION_ID) |
page_size | 100 | no | The fetch of each page of either query |
type_concept_id | none, required | no | The *_type_concept_id the mappings write, the provenance of the records |
observation_period_type_concept_id | none, required | no | The period_type_concept_id of every observation period |
[etl.visits]
The visit derivation, off until the section is present. The rows are grouped by EHR and source, each visit spanning the earliest start to the latest end.
| Key | Default | Secret | Meaning |
|---|---|---|---|
aql | none, required | no | The visit query, aliasing ehr_id, visit_source, visit_start and visit_end, with an ORDER BY |
visit_concept_id | none, required | no | The visit_concept_id of every visit |
visit_type_concept_id | none, required | no | The visit_type_concept_id of every visit |
[mappings]
The mapping sets this deployment runs. The facade, the two FHIRconnect
operations and the mapping subcommands read the FHIRconnect set in
directory; etl run reads the OMOCL set in omocl. The FHIRconnect set
and its templates are read once at boot, and a mapping that does not compile
refuses the start rather than the first request that touches it. The OMOCL
set is read once when etl run starts and compiled against each template
the first time a composition of it arrives, because an OMOCL file names an
archetype and no template.
| Key | Default | Meaning |
|---|---|---|
directory | none | The directory the mapping files are read from, recursively (.yml, .yaml) |
templates | none | The directory holding the operational templates the two operations compile against, as OPT 1.4 XML (.opt) |
omocl | none | The directory the OMOCL files ferrobridge etl run maps with are read from, recursively (.yml, .yaml); read and validated once at start, and every file an Include names must be in it |
The three directories are walked the same way. Every real subdirectory is
read, a symlink to a file is followed, and a symlink to a directory is not.
An entry whose name starts with .. is skipped, so a Kubernetes ConfigMap
mounted as a whole directory is read once: its files sit in a hidden
..<timestamp> directory behind a ..data symlink, and each key appears at
the top level as a file symlink.
The facade always takes its templates from the CDR and needs directory
alone. The two FHIRconnect operations take theirs from one of two sources:
- With
templatesset, every.optfile in that directory is read. This source wins when[cdr]is configured too, because the directory is the explicit choice, and the operations then run with no CDR at all. - With
templatesunset and[cdr]configured, the server reads the context files first and fetches each template they name from the CDR (GET /definition/template/adl1.4/{template_id}, openEHR ITS-REST 1.1.0 §Definition). A template the CDR does not hold, or any other refused fetch, stops the start with an error naming the template and the status the CDR answered, so the lane never comes up with part of its mappings.
A directory with neither templates nor [cdr] is refused while the
operations are enabled. The server checks this when it reads the
configuration, before it calls any upstream, and the refusal names both
sources. With [operations] enabled = false, directory alone
is enough for the facade.
# The operations compile against local templates and reach no CDR.
[mappings]
directory = "/etc/ferrobridge/mappings"
templates = "/etc/ferrobridge/templates"
# The operations compile against the templates the CDR serves.
[cdr]
base_url = "https://cdr.example.org/openehr/v1"
[mappings]
directory = "/etc/ferrobridge/mappings"
[facade]
The FHIR R4 facade. It is off until enabled is true, and a disabled facade
mounts no route, so a request to /fhir answers 404 rather than 403. An
enabled facade needs [cdr] and [mappings] directory: it compiles the
mapping set at boot against the templates the CDR holds, so a mapping that does
not compile refuses the start rather than the first request that touches it.
This lane reaches identifiable data, and the server says so once at start-up.
| Key | Default | Meaning |
|---|---|---|
enabled | false | Whether the facade routes are mounted |
base_url | http://127.0.0.1:8080/fhir | The absolute FHIR service base a client reaches, which Location is written under |
ehr_policy | existing | existing writes only into an EHR the CDR already holds; create_on_first_write creates one for an unknown subject |
identity_store | identity.redb | The file the identity map is kept in, opened at boot and refused when it cannot be written |
subject_namespace | ferrobridge | The namespace a subject identifier is looked up in on the CDR |
system_id | FerroBRIDGE | The AUDIT_DETAILS.system_id every commit records |
composition_language | none, required | The COMPOSITION.language every commit carries, an ISO 639-1 code |
composition_territory | none, required | The COMPOSITION.territory every commit carries, an ISO 3166-1 code |
The two composition fields have no default. FHIRconnect puts them on the project performing the mapping, and there is no correct language for a clinical record you did not write, so an enabled facade with either key unset is a boot error naming the key.
The identity store holds identifiers: a subject to its ehr_id, a resource id
to the composition it came from, and the source identity of every resource the
facade consumed. No clinical content is written into it. Back it up with the
CDR, because a lost map means the next create writes a second composition for a
resource the CDR already holds.
[hl7v2]
The HL7 v2 face: an MLLP listener whose messages are written into the CDR
through the facade’s ingest service. It is off until enabled is true, and an
enabled face with the facade off is a boot error naming hl7v2.enabled and
facade.enabled. This lane reaches identifiable data. The
HL7 v2 face page states what it does with a message and how
it answers.
| Key | Default | Meaning |
|---|---|---|
enabled | false | Whether the MLLP listener runs |
listen | 127.0.0.1:2575 | The socket address the listener binds |
default_charset | ASCII | The HL7 table 0211 code a message with an empty MSH-18 is read in |
concept_maps | none, required | The package directory of hl7.fhir.uv.v2mappings, loaded at boot |
supplements | [] | ConceptMap directories loaded over the guide, in order |
unmapped_entries | skip_and_count | skip_and_count or refuse, for an entry no program maps |
ehr_policy | the facade’s | existing or create_on_first_write, for the face alone |
profiles | [] | { resource_type, profile } pairs: the profile a resource of that R4 type claims when the guide wrote none |
idle_timeout_ms | 300000 | How long a connection may sit between messages; 0 keeps it open |
frame_timeout_ms | 30000 | How long one frame may take to arrive whole; 0 waits |
frame_limit_bytes | 1048576 | The largest message one frame may carry |
log_outcomes | false | Whether an AE or AR also logs the counted outcomes by kind at debug level |
senders | [] | The MSH-4 sending facilities accepted: { namespace_id } or { universal_id, universal_id_type }; empty accepts any |
A resource type the R4 element table does not name, a profile that is not a URL, a resource type listed twice, and a sender that names neither a namespace id alone nor a universal id with its type are each a boot error naming the key.
[operations]
The $tofhir and $toopenehr lane. Both operations are pure transformations
and reach no CDR on a request, so they are served whenever [mappings] directory is set and its templates load, from [mappings] templates or from
the CDR at boot. Without a mapping directory the two routes answer 503.
| Key | Default | Meaning |
|---|---|---|
enabled | true | Whether the two operations and their direct forms are served |
device_reference | Device/ferrobridge-<version> | The Provenance agent.who a call that supplies no context.who gets |
composer | FHIRconnect | The composition composer an inbound run fills in when no mapping does |
composition_language | none | The composition language, an ISO 639-1 code |
composition_territory | none | The composition territory, an ISO 3166-1 alpha-2 code |
The FHIRconnect engine chapter puts the composer and the context start time on
the engine and the composition language and territory on the project performing
the mapping, so set the last two: a template whose reference model requires
them refuses to build a composition without them, and $toopenehr then answers
an OperationOutcome naming the missing field.
The secrets, in one place
| Variable | File sibling |
|---|---|
FERROBRIDGE__CDR__CREDENTIALS__BEARER_TOKEN | FERROBRIDGE__CDR__CREDENTIALS__BEARER_TOKEN_FILE |
FERROBRIDGE__CDR__CREDENTIALS__PASSWORD | FERROBRIDGE__CDR__CREDENTIALS__PASSWORD_FILE |
FERROBRIDGE__TERMINOLOGY__CREDENTIALS__BEARER_TOKEN | FERROBRIDGE__TERMINOLOGY__CREDENTIALS__BEARER_TOKEN_FILE |
FERROBRIDGE__TERMINOLOGY__CREDENTIALS__PASSWORD | FERROBRIDGE__TERMINOLOGY__CREDENTIALS__PASSWORD_FILE |
FERROBRIDGE__CDM__URL | FERROBRIDGE__CDM__URL_FILE |
Prefer the _file route: a Docker or Kubernetes secret arrives as a file, and
a file never shows up in docker inspect or a process listing.
What a bad configuration does
The server refuses to start and prints one line on stderr naming the key, then
exits 78, EX_CONFIG. Five things are refused:
- an unknown key or an unknown section;
- a value of the wrong type, or one that is not a socket address, a URL, or a release name;
- a
[cdm] urlthat names nosslmode, or one that falls back to plaintext or is not a libpq mode, a[cdm]CA that holds no PEM certificate, and aPGSSLROOTCERT,PGSSLCERTorPGSSLKEYin the environment; - a secret set both inline and through its
_filesibling; - a section that is present and names no value for a key it needs.
An example
[server]
listen = "0.0.0.0:8080"
[telemetry]
format = "json"
[cdr]
base_url = "https://cdr.example.org/ehrbase/rest/openehr/v1"
[cdr.credentials]
bearer_token_file = "/run/secrets/cdr-token"
[terminology]
base_url = "https://tx.example.org/r4"
wire_version = "r4"
[mappings]
directory = "/etc/ferrobridge/mappings"
[facade]
enabled = true
base_url = "https://bridge.example.org/fhir"
ehr_policy = "create_on_first_write"
identity_store = "/var/lib/ferrobridge/identity.redb"
subject_namespace = "https://example.org/fhir/sid/patient"
composition_language = "en"
composition_territory = "GB"
The HL7 v2 face
The HL7 v2 face is a second listener in the ferrobridge serve process. It
accepts HL7 v2 messages over MLLP, turns each one into an R4 message Bundle
with the ConceptMaps of the HL7 v2-to-FHIR implementation guide, and writes the
Bundle’s resources into the CDR through the same ingest service the FHIR
facade’s transaction uses. It answers each message with an original-mode
acknowledgment once the CDR has committed or refused it.
No specification governs the hand-off from a message Bundle to the CDR. It is FerroBRIDGE’s own design, and this page states it.
- Turning it on
- The keys
- What happens to one message
- Supplements to the guide
- Sibling resources of one value
- The acknowledgment
- What is logged
- Limits
- Until a mapping context covers the guide’s resources
Turning it on
The face is off until [hl7v2] enabled is true, and it needs the FHIR facade:
the programs, the identity map and the ingest service it writes through are the
facade’s. [hl7v2] enabled with the facade off is a boot error. So is a
concept_maps directory that does not load and a port that cannot be bound.
The guide’s table maps are translated on the [terminology] server, so load
the guide’s table ConceptMaps into it. Without a [terminology] section every
table value is a counted no-terminology outcome.
[facade]
enabled = true
ehr_policy = "existing"
composition_language = "en"
composition_territory = "GB"
[hl7v2]
enabled = true
listen = "0.0.0.0:2575"
concept_maps = "/srv/hl7.fhir.uv.v2mappings/package"
ehr_policy = "create_on_first_write"
profiles = [
{ resource_type = "Observation", profile = "https://example.org/fhir/StructureDefinition/lab-result" },
]
The keys
Every key is settable as FERROBRIDGE__HL7V2__<KEY>, and a list takes TOML
syntax in the variable. The section holds no secret.
| Key | Default | Meaning |
|---|---|---|
enabled | false | Whether the MLLP listener runs |
listen | 127.0.0.1:2575 | The socket address the listener binds |
default_charset | ASCII | The HL7 table 0211 code a message with an empty MSH-18 is read in: ASCII, UNICODE UTF-8, 8859/1 to 8859/9 or 8859/15 |
concept_maps | none, required | The directory of the guide’s ConceptMaps: the package directory of hl7.fhir.uv.v2mappings |
supplements | [] | Directories of your own ConceptMaps, loaded in order after the guide and the supplements the face ships; a map with the id or the url of a loaded one replaces it |
unmapped_entries | skip_and_count | What an entry no program maps does: skip_and_count commits the others, refuse refuses the message |
ehr_policy | the facade’s | existing or create_on_first_write, for the face alone |
profiles | [] | A list of { resource_type, profile }: the profile a resource of that type claims in meta.profile when the guide’s maps wrote none |
idle_timeout_ms | 300000 | How long a connection may sit between messages before it is closed; 0 keeps it open |
frame_timeout_ms | 30000 | How long one message may take from its first byte to its trailer before it is answered AR; 0 waits |
frame_limit_bytes | 1048576 | The largest message one frame may carry |
log_outcomes | false | Whether an AE or AR also logs the counted outcomes of the run, by kind, at debug level |
senders | [] | The sending facilities (MSH-4) the face accepts, each { namespace_id = "..." } (HD.1) or { universal_id = "...", universal_id_type = "..." } (HD.2 and HD.3); empty accepts any |
What happens to one message
- The listener reads one MLLP Release 1 frame. A frame that is malformed,
larger than
frame_limit_bytes, or not complete withinframe_timeout_msis answeredARwhen a header can be read from it, and the connection closes. - The message is decoded in the character set its MSH-18 names, split by
position and grouped by its message structure. With
sendersset, a message whose MSH-4 names none of them is answeredARhere and is never mapped. - The guide’s message, segment and data type maps, with the supplements over
them, write an R4 message Bundle. Every row the run cannot carry is a
counted outcome, and every supplement map the run used is counted once as
supplemented. - Every entry whose resource type is listed under
profilesand that claims no profile gets that profile inmeta.profile, and every entry with asubjectorpatientreference and none set references the message’s onePatient. The guide leaves the relationships between the resources of one message to the implementer, and the facade selects a program bymeta.profile, so these two steps are what let a message reach a mapping. - The Bundle’s entries go through the ingest service as one transaction. The
programs are selected as the facade selects them, and the subject is read
from the
Patiententry’s first identifier. Each composition’sFEEDER_AUDITnames the message: its MSH-10 as the item id and its message type (ORU^R01) as the item type. - The acknowledgment is sent once the ingest settled, so
AAmeans the CDR holds the message’s compositions.
Supplements to the guide
The guide invites an implementation to add a mapping it needs locally
(mapping_guidelines.md, General Format/Approach). FerroBRIDGE does that with
supplements: ConceptMaps in the guide’s own shape that override a map of the
guide or add one it lacks. No specification governs how they load or how they
are marked; that is FerroBRIDGE’s own design, and it works as follows.
- The face ships its supplements inside
ferrobridge-hl7v2(the crate’ssupplements/directory, compiled in) and loads them over the guide’s package at boot. Thesupplementsdirectories of[hl7v2]load after them, so a map of your own can replace one of FerroBRIDGE’s. - A supplement with the id or the canonical url of a loaded map replaces that map whole. Any other supplement is added.
- Each shipped supplement names FerroBRIDGE in its
title. Itsdescriptionnames the guide map it overrides, or the structure it adds, the rows it changes and why, and the tracker issue that records the defect. An override keeps the guide map’s id and url. An added map takes an id in the guide’s naming form and a url underhttps://ferrobridge.eu/fhir/v2mappings/, so it never claims a canonical url of the guide. - The run counts each supplement map it uses once per message, as
supplementednaming the map, so an outcome list shows which values a supplement wrote.
The shipped supplements:
| Map | Kind | What it changes, and why |
|---|---|---|
datatype-cwe-to-codeableconcept | override | Runs the CWE.1 row into coding[1].code with no condition. The guide gates it on a Narrative-Condition no machine evaluates, so no Coding it writes carries a code (#332) |
datatype-ce-to-codeableconcept | override | The same row for CE, the type HL7 v2.3 and earlier give most coded fields (#332) |
datatype-cf-to-codeableconcept | override | Names the sources CF.1 to CF.13, where the guide names them CWE.1 to CWE.13 so no CF row runs, and runs CF.1 with no condition (#332) |
datatype-cwe-to-quantity | override | Names the targets code, unit and system, where the guide writes Quantity.code and so on, which the element table cannot find under the Quantity the row fills; OBX-6 units are kept (#332) |
datatype-hd-name-to-messageheader-source | override | Drops the row that writes HD.2, a universal ID, into MessageHeader.source.software, which R4 defines as the software’s name (#324) |
datatype-hd-name-to-messageheader-destination | override | Writes HD.1 into destination.name, as the endpoint map does, where the guide writes HD.2 and the two maps disagree (#332) |
datatype-pl-to-location | override | Links the six Locations of a PL (bed, room, point of care, floor, building, facility) into one partOf chain in the order the guide’s PL.10 rows give the finest level, where the guide’s partOf rows disagree with it and the building’s names itself; writes the point of care’s mode and physicalType (wa, the code the guide repository’s MDM^T02 sample gives PL.1) at elements Location has; names the point of care and the building in the PL.10 rows as their second set does; and writes PL.9 into the finest valued level (#342, #332) |
segment-msh-to-messageheader | override | With both MSH-3 and MSH-24 valued, MSH-3 names the source and MSH-24 gives its endpoint; with one of them empty, the other runs as the guide writes it (#311) |
segment-orc-to-diagnosticreport | override | Writes ORC-2 into basedOn.identifier (R4 Reference.identifier), where the guide writes basedOn(ServiceRequest) with no map to fill it (#336) |
segment-sch-to-appointment | override | Writes SCH-26 and SCH-27 into basedOn[1].identifier and basedOn[2].identifier for the same reason (#336) |
segment-pid-to-appointment | override | Writes PID-2, PID-3 and PID-4 into the identifier of the Appointment’s patient references, so the message keeps one Patient where the guide’s (Patient) rows would create one per row (#336) |
message-adt-a05-to-bundle, message-adt-a09-to-bundle | override | Names the PD1 row ADT_A05.PD1 and ADT_A09.PD1, where the guide names it ADT_A01.PD1, which leaves ADT_A05 and ADT_A09 with no message map (#332) |
message-adt-a03-to-bundle | added | ADT_A03 (A03 discharge), from the rows of the guide’s ADT_A01 map at the same paths (#256) |
message-bar-p01-to-bundle | added | BAR_P01 (P01 add patient accounts), from the ADT_A01 rows for the segments the two share, the visit segments under VISIT; GT1, UB1, UB2, ACC and DRG have no segment map in the guide and are counted unmapped (#256) |
message-orl-o22-to-bundle | added | ORL_O22 (the laboratory order response), from the guide’s OML_O21 rows under RESPONSE, with the MSA row into MessageHeader.response (#256) |
message-oul-r22-to-bundle | added | OUL_R22 (the specimen-oriented observation), from the guide’s ORU_R01 rows at the OUL_R22 paths; the OBR row into a Specimen is left out, since SPM carries the specimen (#256) |
What the supplements leave to the guide, and what stays open:
- CWE.3 is written into
Coding.systemas the sender sends it (LN). The guide’s comment on that row says a vocabulary table gives the URI, and the guide ships no table map for HL7 table 0396, so the value stays the v2 mnemonic until one exists. - DFT_P03 has no message map. Its defining segment, FT1, has no segment map in the guide, so a map for the rest would acknowledge a charge message without its charges.
- The
PLtoLocationmap’s PL.11 rows write[1-6].identifier[n].assigner, one value spread over six Locations, which the interpreter refuses as it refuses every spreading label, so a location’s assigning authority is counted and not written.
Sibling resources of one value
The PL to Location map writes one Location per level of a patient
location through rows prefixed [1]. to [6]., and links them with
partOf.reference(Location[k]) rows. The guide’s notation says the prefix
applies where the data type is used (mapping_guidelines.md, [n] Notation),
which for a map into a resource is the resource, and leaves the rest to the
implementer. FerroBRIDGE’s own design, for any data type map whose rows
reference a labelled instance of the resource type they fill:
- A
[k].row writes into thek-th sibling of the resource the(Type)row created, and the sibling exists once a value reaches it.[1].is that resource itself. A sibling no value reaches never enters the Bundle. - A
(Location[k])reference between siblings points at siblingkwhen it holds a value. When it holds none, the reference climbs to the sibling thatk’s own row names, and so on up the chain, so a PL without a floor has its point of care in its building, or in its facility. A reference that climbs past the last valued level, loops, or names its own sibling is counted assibling-unresolvedand not written. - Every reference to the family (
Encounter.location.location, and any other reference to the Location the row created) takes the finest valued level: the sibling no other valued sibling is part of. For a PL that is the bed, else the room, else the point of care. It is counted once asfinest-siblingnaming the level. When the chain leaves more than one such sibling, the references stay at the Location the guide’s(Type)row names, or are dropped when that Location holds no value, counted assibling-ambiguous.
The acknowledgment
The codes are HL7 table 0008 in original mode (HL7 v2.5.1 chapter 2
§2.9.2.2). MSA-2 echoes MSH-10, and each ERR carries its table 0357 code in
ERR-3 and the text in ERR-8.
| Outcome | Code | ERR |
|---|---|---|
| The ingest committed the message | AA | none |
| The same message was committed before (same MSH-10 and message type) | AA | none, and nothing is committed again |
| No header can be read at all | none | the connection closes unanswered |
| Delimiters that do not declare themselves, a byte outside the declared character set, a version outside 2.x | AR | 207 or 203 |
| A message structure the definitions or the guide do not carry | AR | 200 |
| A required field or segment missing, an undecoded escape | AE | 101, 100 or 102, located |
MSH-4 names a sending facility senders does not list | AR | 207 at MSH^1^4 |
| MSH-10 empty | AE | 101 at MSH-10 |
A condition of the guide that stops the mapper, a Bundle with no MessageHeader | AE | 101 or 207 |
| No program maps any entry, an entry that does not map, a second subject, a conflict with the identity map | AE | 207, one per refused entry, naming its fullUrl |
| The terminology server refused or did not answer a translation | AR | 207 |
The CDR failed, was unreachable, timed out, or refused the bridge’s credentials (5xx, 401, 403, 407, 408, 429) | AR | 207 |
An AR asks the sender to resend later; an AE says the message itself cannot
be taken as it is. A message that arrives while another delivery of the same
message is still in flight is AE naming the entries, and commits nothing.
Resend it after the first delivery answered. The ERR-8 text is printable ASCII,
so it encodes in every character set a message can declare.
What is logged
Each connection runs in an mllp_connection span naming the peer, with the
count of messages it answered (messages) and of messages refused for their
sender (refused), and each
message in an hl7v2_message span naming its MSH-10 and message type. One
event per message names the acknowledgment code and how many entries were
committed and skipped. With log_outcomes an AE or AR also logs, at debug
level, each outcome kind of the run with its count. No byte of a message, no
FHIR resource and no CDR error body reaches a log line; the ERR segments
travel only to the sender.
The boot banner has an hl7v2 line naming the address the listener binds and
the CDR and terminology hosts it reaches, and /health/readiness carries an
hl7v2-listener indicator that is down from the stop signal on.
/health/info lists every lane under lanes; the hl7v2 lane carries its
listen address and up, whether the listener accepts connections now. The
facade’s CapabilityStatement adds one sentence to implementation.description
naming the HL7 v2 face as another inbound path under the same identity and
replay rules, only when the face is configured. On SIGTERM
the listener stops accepting, every message in flight is mapped, committed and
acknowledged, and the connection closes, within the same
[server] shutdown_timeout_ms as the HTTP drain.
Limits
- The face runs in one process. The claims that hold a message in flight and the identity map that recognises a resent one live in that process, so run one process per identity store and point every sender of a feed at it.
- A batch envelope (FHS, BHS) is read only as far as the parser accepts it. The face answers one acknowledgment per frame and does not split a batch.
- MLLP Release 2’s commit acknowledgment is not spoken, and enhanced-mode acknowledgments are not sent.
- The allow list matches MSH-4 alone; the sending application (MSH-3) is not checked.
Until a mapping context covers the guide’s resources
No published FHIRconnect context in the tree maps the resources the guide and
its supplements write for the message families (ADT, BAR, ORU, OUL, ORM, OML
and ORL, MDM, SIU, VXU):
the published contexts do not compile in-tree for lack of their templates
(issue #258). Until one does, a message’s Bundle reaches the ingest service and
is refused for lack of a program: the sender gets AE with one ERR per
entry, and nothing is committed. To take messages before then, write a context
for the resources you need, claim its profile under profiles, and load it
with the facade’s mapping set.
The container image
FerroBRIDGE ships as one image carrying one binary. This page describes the
image, the quickstart compose.yaml beside it, the variables the server reads,
and the health probe you point your orchestrator at. No release has been cut
yet, so no tag exists on the registry; the recipe and the quickstart are in the
repository and the first release publishes the image they describe.
No specification governs packaging, the process model, or health probes: this page is FerroBRIDGE’s own design.
- What the image is
- Running it with compose
- The environment variables
- The health probe
- The OMOP CDM profiles
- Sources
What the image is
| Fact | Value |
|---|---|
| Registry and repository | ghcr.io/ferrohealth/ferrobridge |
| Tag | the product version, for example 0.0.1; each release publishes its own |
| Base | gcr.io/distroless/static-debian13:nonroot, pinned by index digest |
| Platforms | linux/amd64 and linux/arm64 |
| User | uid 65532, gid 65532, written numerically |
| Entrypoint | /usr/local/bin/ferrobridge, exec form, with serve as the default command |
| Port | 8080/tcp |
The base is distroless static: CA certificates, time zone data, an
/etc/passwd carrying the non-root user, and a /tmp. There is no shell, no
package manager, and no HTTP client, so the image cannot run a script and
nothing can be installed into it at runtime. The binary is a static musl build
that the release lane produces, attests, and stages before the image is built;
the image compiles nothing.
The user is written as 65532:65532 rather than as a name because the kubelet
cannot resolve a name against an image it does not read, so a named USER
makes runAsNonRoot: true refuse the pod.
Every build-independent OCI label is set in the recipe
(org.opencontainers.image.title, .description, .url, .documentation,
.source, .vendor, .licenses, .base.name). The release lane adds the
version, revision and creation labels at build time, so the same file produces
a correctly labelled image at every cut.
The tag scheme follows the product version, and a version is never rebuilt: a bad cut ships forward as the next patch version.
Running it with compose
compose.yaml at the repository root is the quickstart, and it ships as a
release asset. Download it, create the secret files beside it, and start:
mkdir -p secrets
printf '%s' "$CDM_PASSWORD" > secrets/cdm_password
printf 'postgresql://ferrobridge:%s@cdm:5432/omop?sslmode=disable' "$CDM_PASSWORD" > secrets/cdm_url
: > secrets/cdr_bearer_token
docker compose up
curl http://127.0.0.1:8080/health/liveness
Compose refuses to start a service whose secret source file is missing, so create all three even when a lane is off; a file nothing reads may be empty. Never commit them.
The published port binds 127.0.0.1 by default. A published port is DNAT’d
ahead of the host firewall’s own chains, so a port published on 0.0.0.0 is
reachable from the network even when the firewall says otherwise
(Docker: packet filtering and
firewalls).
To serve another machine, name that interface explicitly or put a reverse proxy
in front:
FERROBRIDGE_BIND_HOST=10.0.0.7 docker compose up
The service runs with a read-only root filesystem, every Linux capability
dropped, and no-new-privileges:true. There is no tmpfs beside it because
the server writes nothing: no cache, no scratch file, no upload directory, and
every secret is read once at boot.
The environment variables
Every key is settable as FERROBRIDGE__<SECTION>__<KEY>, and
Configuring the server is the complete reference with every
default and every refusal. This section covers the variables the quickstart
names.
The image sets one of them itself: FERROBRIDGE__SERVER__LISTEN=0.0.0.0:8080,
because the binary’s own default is loopback and no container can publish a
loopback port.
The quickstart passes the upstream variables through from your shell or your
.env file rather than defaulting them, because an empty base URL is a boot
error while an absent one is a lane the server never starts. Set the ones you
use:
FERROBRIDGE__CDR__BASE_URL=https://cdr.example.org/openehr/v1
FERROBRIDGE__CDR__CREDENTIALS__BEARER_TOKEN_FILE=/run/secrets/cdr_bearer_token
FERROBRIDGE__TERMINOLOGY__BASE_URL=https://tx.example.org/r4
FERROBRIDGE__TERMINOLOGY__WIRE_VERSION=r4
FERROBRIDGE__CDM__URL_FILE=/run/secrets/cdm_url
Every credential is reachable through a _file sibling read once at boot, and
that is what the compose secrets are for: a secret mounts at
/run/secrets/<name>, so the _file variable names that path and the value
never enters the environment, where docker inspect would show it. Setting a
value together with its _file sibling is a boot error, and a refused
configuration exits 78.
FERROBRIDGE_CONFIG names a TOML file instead, for a deployment that mounts
its configuration rather than setting variables.
A container’s stdout is no terminal, so the default auto format writes one
JSON object per line with no colour and no banner, and you configure nothing
for a log collector to read it. Set FERROBRIDGE_LOG_FORMAT=pretty to read
the banner and human-readable lines in docker compose logs
(The console).
The health probe
The image declares no HEALTHCHECK. A distroless image has no shell and no
HTTP client, and the binary carries no probe subcommand, so the probe is
external. Two endpoints answer it:
GET /health/livenessanswers200while the process is up. Restart the container when it stops answering.GET /health/readinessruns one bounded check per configured upstream and answers200when every one of them answered. Take the instance out of the load balancer when it does not.
Readiness answers 503 when any indicator is down, with a JSON body naming the
aggregate and every indicator by name:
{
"state": "down",
"indicators": {
"cdr": { "state": "up" },
"terminology": { "state": "down", "detail": "the upstream answered 503 Service Unavailable" }
}
}
The detail names the upstream status or the reason the call never reached it,
and never a body, a credential, or clinical content. A probe that reaches the
upstream counts it up, 401 and 404 included; a 5xx and a failure to
connect count it down. Each check is bounded, so a wedged upstream cannot hang
the probe. In Kubernetes, point livenessProbe at /health/liveness and
readinessProbe at /health/readiness.
The OMOP CDM profiles
Two compose profiles cover the OMOP target, and neither runs on a plain
docker compose up.
The cdm profile stands up the CDM database on PostgreSQL 18.6, pinned by
digest, with its password read from secrets/cdm_password rather than from the
environment, and a pg_isready health check that gates the jobs below:
docker compose --profile cdm up -d cdm
docker compose --profile cdm run --rm cdm-init
cdm-init runs the bridge image with cdm init, which applies the OMOP CDM
v5.4 tables, primary keys and indices to [cdm] schema in one transaction,
then creates the bridge schema ([cdm] bridge_schema) with its side table,
watermarks and one id sequence per CDM table. Running it again is safe: on a
schema that already holds every CDM table it applies nothing, says the schema
is initialised and exits 0, and on one that holds only some of them it exits
1 naming the missing tables. The OHDSI foreign keys are applied only with
cdm init --with-constraints, which PostgreSQL refuses at the pinned OHDSI
tag; the configuration page’s cdm init section has the three states and the
refused statement.
The vocab profile loads an OHDSI Athena vocabulary export. The export is a
licensed download you obtain yourself; it is bind-mounted read only and no
vocabulary byte enters the image, the build context, or a release asset:
FERROBRIDGE_VOCAB_DIR=/srv/athena/2026-09 \
docker compose --profile vocab run --rm vocab-load
vocab load DIR --schema NAME exits 2 today with a line naming #233, the
loader that waits on the observed export format, so that service is the shape
the job runs in rather than a working load. etl run exits 2 the same way,
naming #90, the OMOCL engine the run maps compositions with.
Sources
- distroless: https://github.com/GoogleContainerTools/distroless
- The Dockerfile reference: https://docs.docker.com/reference/dockerfile/
- The Compose file reference: https://docs.docker.com/reference/compose-file/
- Docker packet filtering and firewalls: https://docs.docker.com/engine/network/packet-filtering-firewalls/
Failure and identity behaviour
A bridge carries clinical data between systems, so a silently dropped element or a swallowed upstream error becomes a wrong record in the receiving system. FerroBRIDGE fails loudly instead. The FHIRconnect engine chapter states its guidance as recommendations, so each rule below is pinned as FerroBRIDGE’s own decision, and this page is the operator-facing statement of it.
- All or nothing per unit
- Type coercion is strict
- An upstream failure stays a failure
- Unmapped OMOP codes are recorded, never dropped
- Identity
- A re-sent transaction commits once
- A re-sent single create commits once
- An HL7 v2 sender and receiver become MessageHeader endpoints
- Version mismatch is refused at load time
- Programmed mappings are compiled in
All or nothing per unit
A transaction Bundle that cannot be mapped in full is refused with an
OperationOutcome naming every failing entry, and nothing is committed. A
batch Bundle answers per entry and counts its failures, because FHIR R4 defines
batch that way. There is no best-effort mode. On the OMOP side the unit is one
composition’s whole record graph: a composition becomes many linked rows, and
they commit together or not at all, so no FACT_RELATIONSHIP row ever points
at a record that was never written.
Type coercion is strict
A value that does not parse as the RM leaf type the Web Template resolved is a refusal. FerroBRIDGE does not round a number, truncate a string, or guess a date format to make a value fit.
An upstream failure stays a failure
A refused CDR call, a failed terminology lookup, or a timeout is reported with
the upstream status. It never becomes an empty value, a default, or a silently
missing element. When a display cannot be resolved through $lookup, you get
an error that says so, not a resource with a coding missing its display.
Unmapped OMOP codes are recorded, never dropped
A source code with no standard concept lands as concept_id = 0 with the
source value kept in its source column. That is the CDM’s own answer for “no
matching concept”, and it keeps the row auditable. A row is never discarded
because its code did not resolve, and every run reports how many codes landed
there, per mapping, because the reference implementation’s own authors measured
8.65% of primary concepts doing so and called that an underestimate.
Identity
On the FHIR side an exported resource id is derived deterministically, as the
specification recommends, from the composition’s stable versioned_object_uid,
the entry path and the split occurrence, so it stays the same across
composition versions, which FHIR requires of a logical id; meta.versionId
carries the openEHR version. FerroBRIDGE also keeps a persistent id-map: the
patient identifier to ehr_id relation, the external to internal resource id
relation, and the resource id to composition relation, so a PUT resolves and
a re-sent Bundle is recognised. It also records each Resource.identifier a
committed resource carries against its resource id, which is what a
conditional create’s identifier search reads.
A re-sent transaction commits once
Every entry the facade commits records the source it consumed. A resource is
keyed by its resourceType, id and meta.versionId, the same key a single
create uses. A message is keyed by its type, its control id and the entry’s
position in the Bundle, so a redelivered message is recognised even when it is
rebuilt with fresh resource ids. When every entry of a Bundle was consumed
before, the Bundle commits nothing and answers the resources the first delivery
created. When only some were, the Bundle is refused with 409 and nothing is
committed. An entry without an id has no key, so its Bundle commits again
each time it is sent. These rules are FerroBRIDGE’s own design.
The CDR does not say in which order it lists the versions of a contribution.
After the commit, FerroBRIDGE reads each version back and matches it to the
entry it came from by the FEEDER_AUDIT the engine wrote: the item’s type,
its id and its version. Where several entries name the same item, as the
entries of one message do, the composition content decides. That comparison
masks what two mappings of one entry can differ in without the source
changing: the composition uid the CDR assigns, and every time the engine
filled from its clock because no mapping wrote it. The engine reads the clock
once per resource and hands that instant to the builder as the ctx/time
default, which fills EVENT_CONTEXT.start_time, HISTORY.origin,
EVENT.time and ACTION.time where the mapping left them empty (ITS-REST
Simplified Formats, master06 §time). Each DV_DATE_TIME whose value is that
instant is left out of the comparison, so a Bundle mapped again at a later
instant still matches the compositions its first delivery stored. A version that
matches no entry or more than one refuses the answer with a 500 that names
the contribution, and no binding is recorded.
The commit is two steps. First the facade commits the contribution with
Prefer: return=representation and records the contribution uid the CDR
answers against every keyed entry of the Bundle, before it binds any of them.
Then it binds each entry from the versions the answer lists. A CDR that
ignores the header answers an empty 201 that still names the contribution,
and the facade then reads the contribution back with
GET /ehr/{ehr_id}/contribution/{contribution_uid} and binds from its
versions, with the same FEEDER_AUDIT check. A contribution that cannot be
read back or bound is a 500 naming the contribution, and the recorded uid
stays. When you send the same Bundle again, the facade finds that uid,
commits nothing, reads the contribution back and binds from it, and answers
200 OK for each entry as for any re-sent Bundle.
A single create shares that rule. When you POST a resource whose id and
meta.versionId a transaction committed and did not finish binding, the
facade reads the recorded contribution back, finds the version whose
FEEDER_AUDIT names the resource, binds it, commits nothing, and answers
200 OK with the composition the first delivery produced. The other versions
of that contribution belong to the transaction’s other entries and are passed
over.
A re-sent single create commits once
A single create (POST [base]/{type}) of a resource whose id the identity
map already consumed follows the same rule as a re-sent transaction, keyed by
the same resourceType, id and meta.versionId. FHIR R4 says nothing about
a repeated create with a client-assigned id, so these rules are FerroBRIDGE’s
own design.
- The same
idand the samemeta.versionId: the create is a replay. The facade commits nothing and answers200 OKwith theLocation, theETagand the body of the composition the first delivery produced, at the version it stands at now. A re-sent Bundle gets the same answer per entry. - The same
idand anothermeta.versionId: a changedmeta.versionIdmeans the sender changed the resource, so the create commits a later version of the composition the first delivery produced and answers200 OK. That is the update aPUTperforms, following the version the CDR holds now. - The same
idand nometa.versionId: the key has no version to compare, so the facade maps the resource and compares the result with the composition as the CDR holds it now. The comparison masks the same fields as the transaction path: the compositionuidand every time the engine filled from its clock. The same content is a replay, answered as above. Other content is the later version. - An
idthe map binds to more than one composition: the facade cannot tell which one the create revises, so it answers409 Conflictwith aconflictissue and commits nothing.
A transaction entry follows the same lookup. An entry whose exact key the map
consumed, or a transaction committed, is recognised as above. An entry whose
id the map consumed at another meta.versionId commits a later version of
that composition inside the Bundle’s contribution: its UpdateVersion names
the composition’s latest version as preceding_version_uid and its audit
states a modification, beside the creations of the other entries. The entry
answers 200 OK in the transaction-response, keeps its resource id, and its
Location names the new version. The commit is recorded before the binding
and matched by FEEDER_AUDIT as for any entry, so a retry after a failed
binding reads the contribution back and commits nothing. An entry the map
binds to more than one composition, or to a composition in another EHR,
refuses the Bundle with 409, and two entries that revise one composition
refuse it with 422. PUT is unchanged.
Two deliveries of one source that overlap commit once. Before a delivery reads
the identity map, it claims the key of every entry it carries, and it holds
those claims until it answers, whether it commits, is refused, or fails. A
second delivery that finds any of its keys claimed commits nothing and answers
409 Conflict with an OperationOutcome whose duplicate issues name each
entry another delivery holds. The rule is the same for a transaction and for a
single create of a resource with an id, and a single create also claims the
resourceType and id alone, so two creates of one id at different
versions do not run at once. Retry after the first delivery has
answered: the retry is then recognised as a re-sent Bundle or resource, as
described above. The claims live in the server process, one set per identity
store, so they order deliveries that reach the same FerroBRIDGE instance.
On the OMOP side every CDM v5.4 primary key is a 32-bit integer, so ids come
from database sequences and a bridge-owned side table maps each source record
to its row. That table is what makes a re-run replace rather than duplicate,
and it keeps the openEHR identity in the *_source_value columns.
The FHIR scheme has a failure mode worth knowing before you run it: a re-sent Bundle that omits one resource reads as a different mapping. It is documented rather than hidden.
An HL7 v2 sender and receiver become MessageHeader endpoints
A FHIR R4 message Bundle opens with a MessageHeader
(https://hl7.org/fhir/R4/bundle.html#invs, bdl-12), whose
source.endpoint and destination.endpoint are required url values
(https://hl7.org/fhir/R4/messageheader.html). The v2-to-FHIR guide writes
MSH-3 (Sending Application) and MSH-5 (Receiving Application) there, and both
are HD values. FerroBRIDGE builds the endpoint from the HD in this order:
- When HD.3 (the universal ID type) is
ISO,UUID,DNSorURIand HD.2 (the universal ID) is valued, the endpoint is the form the guide’sHDendpoint map assigns:urn:oid:,urn:uuid:,urn:dns:orurn:uri:followed by HD.2. - Otherwise, or when that form is no valid
url, the endpoint is derived:urn:ferrobridge:hl7v2-hd:followed by HD.1 (the namespace ID), and, when HD.2 or HD.3 is valued,:HD.2:HD.3. Every byte outside the RFC 3986pcharset is percent-encoded, and so is:inside a component, soNorth Lab Appbecomesurn:ferrobridge:hl7v2-hd:North%20Lab%20App.
HD.1, when valued, is also written to source.name or destination.name, so
the application name stays readable. No specification governs the derived
form: it is FerroBRIDGE’s own design. The guide leaves an HD without a
typed universal ID to the implementer, a v2 application name often holds
spaces a url cannot carry, and the ferrobridge prefix marks the value as
derived so nobody reads it as an identifier the sender assigned. The same
HD always yields the same endpoint.
When MSH-3 and MSH-24 (Sending Network Address) are both empty and MSH-4
(Sending Facility) is valued, FerroBRIDGE builds source.endpoint and
source.name from the MSH-4 HD by the same two rules. The guide still
writes MSH-4 into MessageHeader.sender as an Organization, and that stays.
The destination side works the same way: with MSH-5 and MSH-25 (Receiving
Network Address) empty, MSH-6 (Receiving Facility) gives
destination.endpoint and destination.name. Each fallback is counted as a
facility-endpoint outcome naming the facility field and the element it
filled. The guide’s MSH-3 and MSH-24 rows leave a message valuing neither to
the implementer, so the fallback is FerroBRIDGE’s own design.
A message that names its sender in none of MSH-3, MSH-24 and MSH-4 is
mapped. The guide’s MSH-24 row writes the data-absent-reason extension with
the code unknown into source.endpoint for it, and FHIR R4 JSON carries a
primitive with an extension and no value as the _endpoint property alone
(https://hl7.org/fhir/R4/json.html#primitive), which satisfies the required
endpoint. When MSH-4 is valued, its endpoint replaces the extension as above.
A run that still completes no MessageHeader is refused with an error naming
the element it lacks, so no header-less message Bundle is ever produced. A
message valuing MSH-24 is one: the guide’s row into source has two
qualified HD data type maps and no single one to run.
Version mismatch is refused at load time
The mapping version, the grammar version, the archetype revision, the template
sem_ver, and the profile version all have to agree. A mismatch is refused when
the mapping loads, not when a request arrives, so a bad configuration cannot sit
waiting for the first patient record to expose it.
Programmed mappings are compiled in
mappingCode in FHIRconnect and CustomMapping in OMOCL name a function.
FerroBRIDGE registers those as named Rust functions at build time. There is no
runtime plugin loading, so what a deployment can execute is fixed by the binary
you audited.
Version lines and releases
FerroBRIDGE publishes two things, and they carry two version numbers that move independently. Reading a release note or a crate version means knowing which line you are looking at.
The product line
The product is the ferrobridge binary, its container image, and the
repository as a whole. Its version is the workspace version in the root
Cargo.toml, which every member inherits, and it is what a vX.Y.Z git tag
names. A release is cut from that tag: the lane takes its notes from the
matching CHANGELOG.md section, publishes the release, and the same number
appears in CITATION.cff and in the product row of
docs/VERSIONS.md.
The milestone line is 0.0.x today, so the first product version is 0.0.1.
Where to read it:
| You want | Read |
|---|---|
| the version a binary reports | ferrobridge --version |
| the version a release ships | the vX.Y.Z release on GitHub, and its changelog section |
| the version the source tree declares | the root Cargo.toml [workspace.package] version |
The crate line
The library crates are published on crates.io under plain names so other Rust
projects can depend on them: fhir-types, hl7v2-types,
openehr-mapping-core, fhirconnect, omocl, omop-cdm, ferrobridge-term
and ferrobridge-hl7v2. Each carries the
version in its own
crates/*/Cargo.toml, and that number never adopts the product version or a
specification version.
The set is deliberately not lockstep. fhir-types carries a real 0.1.x line,
because the crate moved here from the sibling terminology server and continues
the version sequence it already had on crates.io. Every other member still
sits at the 0.0.0 placeholder that holds its name on the registry until its
first real release. A 0.0.0 on crates.io is a name reservation and nothing
else: it compiles, it carries the licence and the metadata, and it is not the
crate you want to depend on yet.
Where to read it:
| You want | Read |
|---|---|
| the version a crate publishes | the crate’s page on crates.io, or cargo add <crate> |
| the version the source tree declares | that member’s crates/*/Cargo.toml version |
the line fhir-types is on | the crate-line row of docs/VERSIONS.md |
Why they are separate
A published crates.io version is immutable: the bytes under fhir-types 0.1.2
are the bytes under fhir-types 0.1.2 forever. So a crate has to move its
version whenever its packaged content changes, which happens far more often
than a release is cut, and far more often for one member than for another.
Tying either number to the other would force a product release for a crate fix,
or a crate republish for a release with no library change.
Not every bumped crate version is published, so gaps in the published sequence are normal. Publishing different content under an existing version is the one thing that is forbidden, and crates.io refuses it.
The rule that keeps a crate version honest, and the guard that enforces it, are on the crate versions page.
Licences
Every published member is BUSL-1.1 except fhir-types, which is Apache-2.0:
it is generated from the HL7 FHIR packages and exists to be usable by any Rust
project. The licensing page carries the terms.
The FHIR facade
The FHIR side of FerroBRIDGE is a REST facade in front of an openEHR CDR. A FHIR client sees one FHIR server; behind it, every request becomes CDR operations driven by FHIRconnect mappings. The facade stores no clinical data of its own.
The facade runs. It is off until [facade] enabled turns it on, and a disabled
facade mounts no route, so a request answers 404 rather than 403. What this
page describes is what the server answers today: conformance, create, read,
vread, update, transaction and $validate. Search and batch are later work, and the
CapabilityStatement leaves them out rather than claiming them.
- Why a facade
- The surface
- Media types and bodies
- Program selection
- Identity
- A read is a valid instance
- Provenance
- Writes
$validateis a dry run of this server- Status mapping
- Paths are written as well as read
- Terminology
- Search is FerroBRIDGE’s own design
- What the facade does not do
Why a facade
FHIRconnect defines the mapping language and leaves the server surface to the implementer. Two shapes exist in the prior art: publish a facade, or leave both the facade and the CDR to the integrator. No standard prefers either. FerroBRIDGE publishes the facade so that a FHIR client needs to know about one server and one base URL, and so that the mapping direction, the terminology calls, and the CDR commit happen in one place that can refuse a request as a whole.
The surface
FHIR R4 (4.0.1), because that is the only version the FHIRconnect schemas admit
for spec.version. The service base is /fhir.
| Request | What it does |
|---|---|
GET /fhir/metadata | The CapabilityStatement of the loaded mapping set |
POST /fhir/{type} | Create, conditional through If-None-Exist |
POST /fhir/{type}/$validate | The dry run that commits nothing |
GET /fhir/{type}/{id} | Read one resource back out of its composition |
GET /fhir/{type}/{id}/_history/{vid} | Vread: read the version a write’s Location named |
PUT /fhir/{type}/{id} | Update, with If-Match |
POST /fhir | A transaction Bundle, all or nothing |
A type no loaded program maps is absent from the CapabilityStatement and
answers 404 with a not-supported OperationOutcome on the wire. A batch
Bundle answers 422 with not-supported. Search has no route
(issue #98 lands it),
and a _count that is not a non-negative integer is a 400 with invalid,
so a client never reads a page it did not ask for.
The CapabilityStatement names exactly the resource types the loaded programs
map, with create, read, vread and update per type, transaction at system
level, the $validate operation, updateCreate: false, conditionalCreate
and conditionalUpdate true, fhirVersion: 4.0.1, and no search parameter.
When the FHIRconnect operations lane is served under the same base,
rest.operation also names $tofhir and $toopenehr; their direct forms are
never declared (The FHIRconnect operations says why).
Media types and bodies
The facade reads application/fhir+json and application/json, which R4 names
as the JSON media type and its alias, and answers 415 for anything else. Every
response carries application/fhir+json. An Accept header that admits neither
is a 406.
A body is parsed through the strict codec of the generated fhir-types crate,
so an unknown property is a 400 with structure rather than a value that is
silently dropped. Prefer: return=minimal, return=representation and
return=OperationOutcome are honoured on a write, and the default is the
resource itself.
Everything the facade authors is an OperationOutcome with an issue.code
from the R4 value set. An openEHR error body never reaches the wire as its own
document: the CDR’s message and every validationErrors entry travel
verbatim inside issue.diagnostics, which keeps one error vocabulary on the
wire while losing nothing the CDR said.
Program selection
A request is mapped by the program whose context claims the profiles the
resource declares in meta.profile. Every declared profile is considered, as
set membership, and the templateId pin of the FHIRconnect operations surface
selects among candidates when a deployment sends one.
Two refusals are distinct on the wire, and both are 422. When no program
claims the resource, the outcome names the profiles it saw and answers
not-supported. When several programs claim it and nothing pins the template,
the outcome names the candidates, so the fix is a pin rather than a guess.
Identity
A FHIR resource id has to be stable across composition versions, because R4
fixes that “once assigned, this value never changes”. The facade derives it
from the entry’s LOCATABLE.uid when the entry carries one that the R4 id
grammar admits, and otherwise from a SHA-256 over the version container, the
entry path and the split occurrence, rendered as 52 lowercase base32
characters. meta.versionId carries the openEHR version_tree_id and never
enters the derivation, so a new version of a composition reads back under the
same id with a new meta.versionId. meta.source names the openEHR version
uid the answer was read from.
The identity map is a redb file with seven tables: a patient identifier to its
ehr_id and each ehr_id back to the first person recorded for it, an
external resource id to the internal one, an internal id to the
composition version container with the entry path and split occurrence, and the
source resource id with its meta.versionId to the mapping that consumed
them, beside the contribution each source was committed in and each
Resource.identifier of a committed resource. The map wins once written, so a derivation change never renames a
resource a client already holds. The store keeps identifiers and nothing else:
no clinical content is written into it, and a test greps the file for a mapped
value to prove it.
That last table is what makes create idempotent. A resource re-sent with the
same id and meta.versionId commits nothing and answers 200 OK with the
composition it already produced. A resource with a known id and a new
meta.versionId commits a later version of that composition. A resource with a
known id and no meta.versionId is a replay when it maps to the content the
composition holds now, and a later version otherwise. A transaction entry with
a known id and a new meta.versionId commits that later version inside the
Bundle’s contribution. The Operate page on failure and identity states the
full rule.
A read is a valid instance
Every resource the facade answers, on a read, a vread or a write under
Prefer: return=representation, is a valid R4 instance: each element the R4
element table marks min 1 is present, at the top of the resource and inside
every element and contained resource it carries. A mapping often has no
outbound row for the subject, because the ingest reads the subject to find the
EHR and the composition does not hold it. When the rendered resource has no
subject (or patient, on a type that names its subject so), the facade
writes it from the identity map: the person the map recorded for the
composition’s EHR, as a literal reference when the person was keyed by one in
subject_namespace, and otherwise as a logical reference with type: Patient
and the person’s identifier. That is the form a create reads back into the
same EHR, so you can send a read body back as an update unchanged. A subject
the mapping wrote is kept. The log line of the request counts the elements the
facade filled and names their paths, never a value. When a required element
stays absent, the facade answers 500 exception with the element path in
issue.location rather than an invalid resource. No specification governs the
fill: it is FerroBRIDGE’s own design.
Provenance
Every composition the facade commits carries a FEEDER_AUDIT, which is the
reference model’s own element for data transformed into openEHR form: the
source resource’s id and type as an originating_system_item_ids entry, its
meta.versionId as originating_system_audit.version_id, and the configured
system_id naming the bridge. A source resource that carries no id is
recorded as unknown, never given one. So a reader of the CDR can tell which
FHIR resource a composition came from without asking the bridge.
Writes
A create resolves the subject to an EHR: the identity map first, then the CDR
by subject id and namespace, and only then the configured policy. With
ehr_policy = "existing" an unknown subject is refused; with
create_on_first_write the facade creates the EHR and records it.
If-None-Exist behaves as R4 defines it: no match creates, one match answers
200 with the resource that already exists, and several matches answer 412.
The search it runs is over the identity map, so the parameters it answers are
_id and identifier, each in the comma-separated OR form. identifier
matches the Resource.identifier of every resource the facade committed, in
the token forms [code], |[code] and [system]|[code]; the [system]|
form is refused. Any other parameter is refused, because a silently narrowed
search would turn a duplicate into a second composition.
A create answers 201 with Location naming the FHIR resource under the
configured base URL and ETag carrying the version.
That Location is [base]/{type}/{id}/_history/{vid}, and a GET of it is
the R4 vread. The {vid} is the version tree id of the composition version
the write committed, so the facade reads that exact version from the CDR and
answers it with its ETag. A {vid} the CDR does not hold answers 404, and
a version the CDR reports deleted answers 410.
An update needs If-Match when the map knows the id. Without one, the facade
reads the CDR’s current ETag and answers 412 with it on a mismatch rather
than overwriting a version the client never saw. Send back the ETag a read
or write answered, If-Match: W/"1": the facade completes that versionId to
the current composition version and answers 412 with the current ETag
when it is stale. A value that names no version is 400. A PUT to an id the map does
not know is a 404: this milestone does not upsert, and the
CapabilityStatement says updateCreate: false.
A transaction Bundle maps every entry first and commits the lot through one
ITS-REST contribution so the CDR makes it atomic. The facade uses fullUrl
in two places. It names each entry by its fullUrl in every refusal, or by
its position when it has none. And a subject or
patient reference whose value is another entry’s fullUrl is read through
that entry: the person is that entry’s first identifier with a value. The
facade resolves no other reference. A reference is mapped as the resource
carries it, and the facade never fetches a referenced resource from anywhere.
Any failure answers one OperationOutcome naming every failing entry, and
nothing is committed.
A committed transaction answers 200 with a transaction-response Bundle,
one entry per request entry in the same order. Each entry’s response carries
201 Created, the location [base]/[type]/[id]/_history/[vid] and the
etag W/"[vid]", the values a single create answers. Send the same Bundle
again and nothing is committed: each entry answers 200 OK with the location
of the resource the first delivery created. A Bundle only some of whose
entries an earlier delivery consumed is refused with 409, naming those
entries, and a Bundle that carries one resource twice is refused with 422.
A delivery that arrives while another delivery of the same Bundle or resource
is still in flight is refused with 409 and commits nothing; retry it after
the first one answers. The redelivery rule is on the
failure and identity page.
An HL7 v2 message that the HL7 v2 face receives
goes through this same path as a transaction of its Bundle’s entries, so the
same identity and replay rules apply to it. Its entries are keyed by the
message’s MSH-10 and message type and their position in the Bundle, so a
message sent again commits nothing and is answered AA, as its first delivery
was. A message whose delivery overlaps another of the same message is refused
and commits nothing. Each composition’s FEEDER_AUDIT names the message where
a resource would be named here. A subject reference to another entry of the
Bundle is read through that entry: the person is the referenced resource’s
first identifier, the same pair a subject.identifier names.
$validate is a dry run of this server
POST /fhir/{type}/$validate runs the whole inbound path up to the commit and
commits nothing, which a test proves by counting EHRs and versions before and
after. It answers 200 for both verdicts: information issues when the
resource would commit, and an error issue carrying the validator’s message
verbatim when it would not. The operation-level statuses mirror the create
path, so a 415, a 422 for an unmapped profile or a 404 for an unknown
type reads the same on both routes.
The validator is this server, not the CDR. openEHR ITS-REST 1.1.0 documents no
composition-validation route, and a commit the bridge never finalises would
still write a version, so what runs is the mapping engine, the Web Template
validation the composition build performs, and the strict RM reader. Every
outcome says so in issue.details, so you can read what a verdict covers. A
CDR that refuses a composition this validator accepts still answers 422 on
the create.
Status mapping
No specification governs what a CDR answer becomes on the FHIR wire, so the mapping is FerroBRIDGE’s own design. It lives in one table, each row citing both sides, and one wire test asserts each row.
| The CDR answers | The facade answers |
|---|---|
422, template validation | 422 with validationErrors verbatim in issue.diagnostics |
412 | 412 carrying the current ETag |
204 on a read, the composition is deleted | 410 |
404 | 404 |
401 | 401, propagating WWW-Authenticate, never 403 |
400, 405 or 415 | 500: the bridge chose a call the CDR does not offer |
any 5xx | 502 carrying the upstream status |
| no answer: a connect failure or a timeout | 502 |
Paths are written as well as read
The engine is a bidirectional path model over FHIR JSON, because a mapping has
to construct a resource as well as read one. The model is guided by the element
table of the generated fhir-types crate, so it knows which elements repeat,
which are choice types, and which alternatives a choice admits. The three
read-side FHIRPath forms the published mappings use (ofType() and as(),
extension(url), resolve()) are path-model operations; no FHIRPath evaluator
is embedded. FHIRconnect’s ^ parent operator and $fhirRoot are not FHIRPath,
and the path model resolves them when the mapping is compiled.
Terminology
The facade calls a configured FHIR terminology server for three operations:
$lookup to resolve a code’s display, $translate for a conceptmap
reference, and $validate-code. The $lookup case is the common one, because
openEHR requires DV_CODED_TEXT.value while FHIR leaves display optional. A
terminology failure is an error, never a coding with a missing display.
The facade does not call the terminology server yet. The call site is the inbound engine seam, and it runs the calls before anything is built, so a terminology refusal will cost no CDR write. It arrives with the registry of mapping functions in issue #95; until then a mapping that needs a display resolved refuses the unit rather than writing a coding without one.
Search is FerroBRIDGE’s own design
FHIRconnect states that it “does not focus specifically on AQL and FHIRsearch”, so no specification governs FHIR search here. The design is FerroBRIDGE’s own, and it is labelled as such wherever it appears:
- An AQL projection per resource type, declared beside the context mapping.
- Execution over
POST /query/aqlon the CDR. - A CapabilityStatement that names exactly which search parameters each resource supports.
- An
OperationOutcomerefusing every parameter outside that list, rather than ignoring it.
A refusal is deliberate. A search server that silently drops an unsupported parameter returns a result set that looks filtered and is not, which is the kind of quiet wrongness this project treats as a defect.
What the facade does not do
It does not materialise a FHIR store, it does not implement Subscriptions, and it does not send demographics to an external endpoint. Each of those is recorded as outside the current design, with its reasoning, on the tracker.
The FHIRconnect operations
FerroBRIDGE serves the two operations the FHIRconnect specification defines for
its engine: $tofhir turns an openEHR composition into FHIR resources, and
$toopenehr turns FHIR resources back into a composition. Both are pure
transformations. Neither reaches a CDR, and neither changes server state, so
you can call them from a facade, a test harness or a shell.
- The chapter is a draft
- Turning the lane on
POST /fhir/$tofhirPOST /fhir/$toopenehr- Query parameters
- The direct form
- Discovering the operations
- What a failure looks like
- Provenance on every run
- Resolving the patient
The chapter is a draft
The REST API chapter is pull request #93 of the FHIRconnect specification and
is not part of any release. FerroBRIDGE implements it at the pinned commit
recorded in docs/VERSIONS.md, vendors the chapter and its FSH operation
definitions, and labels the wire tests draft. When the chapter merges, the pin
moves and every difference is re-adjudicated.
Turning the lane on
Configure a mapping set. [mappings] directory names the tree of FHIRconnect
mapping files. The operational templates they compile against come from
[mappings] templates when it names a directory, and otherwise from the
[cdr], which serves each template a context names. Everything is read once
at boot, so a mapping or a template that does not load stops the server rather
than the first request that touches it. [operations] carries the lane switch and the values a run needs beside
the payload. See Configuring the server.
Without [mappings], both operations answer 503 with an OperationOutcome.
POST /fhir/$tofhir
The body is a FHIR Parameters resource in application/fhir+json:
{
"resourceType": "Parameters",
"parameter": [
{ "name": "composition", "valueString": "<the composition as a JSON string>" },
{ "name": "templateId", "valueString": "Blood Pressure" },
{
"name": "context",
"part": [
{ "name": "ehr_id", "valueString": "53d89df2-5501-4455-9a65-565a5d1ddb7c" },
{ "name": "patient", "valueReference": { "reference": "Patient/123" } },
{ "name": "who", "valueReference": { "reference": "Practitioner/456" } },
{ "name": "onBehalfOf", "valueReference": { "reference": "Organization/charite" } }
]
}
]
}
The composition travels as a JSON string in either serialization. A canonical
composition carries its template at archetype_details.template_id; a FLAT one
carries none, so templateId is required with it and a FLAT payload without
one answers 400 with the issue code required. When the payload and
templateId both name a template and they disagree, the call is refused.
The answer is a collection Bundle carrying the mapped resources, exactly one
Provenance, and an OperationOutcome entry when the run declared a loss.
POST /fhir/$toopenehr
The body is a FHIR Bundle, or a Parameters carrying it under bundle. The
answer is a Parameters with the composition as a JSON string and an optional
outcome. format selects the serialization: canonical by default, or
flat.
FerroBRIDGE picks the mapping by the profiles the Bundle’s resources claim in
meta.profile, narrowed by templateId when you supply one. A Bundle that
references more than one subject, or that carries more than one resource of the
mapped type, is refused: one Bundle maps to one composition.
Query parameters
templateId, format and ehr_id may travel in the query instead of the
body. The two forms are equivalent, and the body wins when both carry the same
field.
POST /fhir/$tofhir?templateId=KDS_Fall_einfach&ehr_id=53d89df2-5501-4455-9a65-565a5d1ddb7c
A structured value, a full Reference such as who or onBehalfOf, belongs
in the body.
The direct form
When you have a composition and want a Bundle back, the wrapper buys you nothing. The chapter therefore allows an un-enveloped form, and marks it as a deliberate deviation from the FHIR operations framework that stays outside the FHIRconnect FHIR implementation guide:
POST /fhir/tofhir
Content-Type: application/openehr+json
{ "_type": "COMPOSITION", ... }
POST /fhir/toopenehr?format=flat
Content-Type: application/fhir+json
<a FHIR Bundle>
POST /fhir/tofhir answers the Bundle itself and POST /fhir/toopenehr
answers the composition itself, with Content-Type: application/openehr+json. The media type is the one the chapter defines for
openEHR canonical JSON, with the suffix ordering of RFC 6839 §4.
Discovering the operations
With the facade enabled beside this lane, GET /fhir/metadata declares both
operations in CapabilityStatement.rest.operation, as tofhir and
toopenehr with the canonical OperationDefinition URL of each. When the lane
is off, the statement leaves them out. The direct forms are never declared:
the request body is no FHIR resource and the response is no Parameters, so
they fall outside the FHIR operations framework the statement describes, and
the chapter keeps them out of the FHIRconnect implementation guide.
What a failure looks like
FerroBRIDGE is strict, which the chapter leaves open. A mapping that cannot be
performed answers an OperationOutcome and no Bundle and no composition, so
you can never mistake a partial result for a complete one. A run that succeeded
but lost something reports each loss as an issue beside the result:
information for a composition field the engine filled, warning for content
that did not reach the output.
Every error is an OperationOutcome in application/fhir+json, with the R4
issue code that fits: required for a missing parameter, structure for a
parameter the operation does not declare or one given twice, value for the
wrong value type, not-found for a template or profile nothing answers,
multiple-matches when several mappings answer and nothing pins the choice,
business-rule for a Bundle with more than one subject, and processing for a
mapping that did not run to completion. A body in any other media type answers
415.
Provenance on every run
Every $tofhir Bundle carries one Provenance describing the transformation:
target references every mapped resource, recorded is the run time,
agent.who is context.who or the configured Device reference,
agent.onBehalfOf is context.onBehalfOf, and one entity with
role = derivation names the composition the run read, by its uid when it
carried one.
Resolving the patient
An openEHR composition identifies the patient through the EHR it belongs to, so
mapping to FHIR means resolving an EHR identifier to a patient. That belongs to
whatever owns patient identity in a deployment. context.patient is the
caller’s fallback: FerroBRIDGE never requires it, and when you supply it, it
takes precedence over the subject a mapping resolved on its own. Correctness
then rests with you, because a wrong reference attaches clinical data to the
wrong patient and nothing downstream catches it.
The OMOP ETL
The OMOP side of FerroBRIDGE turns openEHR content into rows in an OMOP Common
Data Model v5.4 database, driven by OMOCL mapping files. OMOP is a relational
analytics schema rather than an API, so the deliverable is populated tables.
The sink, the runner, the OMOCL engine and ferrobridge cdm init are built,
and ferrobridge etl run maps each composition with the files in
[mappings] omocl.
- A batch job, not a listener
- The tables in play
- The row types are generated
- Concept resolution is SQL, not terminology
- Keys, re-runs and watermarks
- The derived tables
- Tying a composition to its visit
- The composition query
- Checking a populated database with the Data Quality Dashboard
- Validating OMOCL files
- What you need in place
A batch job, not a listener
The ETL runs as a job over AQL. It queries the CDR with POST /query/aql,
converts each EHR’s compositions through the loaded OMOCL files, and writes
typed CDM rows. It pulls; the CDR does not push.
That follows from the specification surface rather than from taste: change notification is not part of openEHR ITS-REST 1.1.0, so there is nothing conformant to subscribe to. If FerroBRIDGE later consumes a particular CDR’s change events, that is a FerroBRIDGE extension, labelled as one, and the batch path stays the conformant baseline.
The tables in play
PERSON, OBSERVATION_PERIOD, VISIT_OCCURRENCE, VISIT_DETAIL, CONDITION_OCCURRENCE, DRUG_EXPOSURE, PROCEDURE_OCCURRENCE, DEVICE_EXPOSURE, MEASUREMENT, OBSERVATION, DEATH, SPECIMEN, FACT_RELATIONSHIP, NOTE, and NOTE_NLP, beside the vocabulary tables.
Some of those come from mappings and some are derived. PERSON rows come per
EHR. VISIT_OCCURRENCE comes from a configured AQL query, checked when the
configuration loads, grouped by ehr_id and source. OBSERVATION_PERIOD is
derived from the first and last clinical event per person, and the ERA tables
run the SQL the CDM publishes. The CDM leaves each of these to the ETL’s
discretion, so each is labelled as FerroBRIDGE’s own.
An OMOCL record names its target table in type. The CDM says the domain of
the resolved standard concept decides the table, so FerroBRIDGE validates the
two against each other: a literal concept whose domain disagrees with type
refuses the mapping at load, and a resolved concept whose domain disagrees
refuses the record. A row is never moved to a table the mapping did not name.
A source code the vocabulary maps to several standard concepts in the record’s
domain writes one row per concept, each with the same source value and source
concept, as the CDM conventions ask.
The row types are generated
The CDM v5.4 row types are generated from the OHDSI CommonDataModel field
definitions, which publish the model in machine-readable form. The PostgreSQL
DDL is not regenerated: OHDSI renders it through a dialect layer that sits
outside those definitions, so the rendered files are vendored verbatim and
applied by ferrobridge cdm init, and a test asserts the generated columns
equal the DDL’s. Nothing hand-transcribes a column list. The foreign keys of
OHDSI’s constraints file are applied only by cdm init --with-constraints,
which PostgreSQL refuses at the pinned OHDSI tag; see
What cdm init does.
Both clients the ETL opens to the CDM database, the concept resolver and the
writer, connect over the TLS the sslmode of [cdm] url names: disable,
require, verify-ca or verify-full, with the CA from [cdm] tls_ca_file.
The URL must name one; see Configuring the server.
Concept resolution is SQL, not terminology
A source code becomes a concept_id through the locally loaded OHDSI
vocabulary: the CONCEPT table, the standard_concept flag, the domain, and the
CONCEPT_RELATIONSHIP “Maps to” traversal. This is SQL over the vocabulary
tables in the same database as the CDM rows. It never goes to the FHIR
terminology server, which is a different component serving the FHIR side.
The lookup requires a valid concept (invalid_reason empty, the validity
dates containing the record date) and a deterministic order, and an ambiguous
match is an error rather than the first row. An unmapped code lands as
concept_id = 0 with the source value kept in its source column and is counted
in the run report. The CDM defines 0 as “no matching concept”, and keeping
the source value is what makes the gap auditable later.
Each composition’s rows are written together or not at all, through binary
COPY, and a re-run of the same input replaces its rows rather than adding
duplicates.
Keys, re-runs and watermarks
Every CDM v5.4 primary key is a 32-bit integer, so the bridge assigns ids
from one PostgreSQL sequence per table, kept in a schema of its own beside the
CDM ([cdm] bridge_schema, ferrobridge by default). A side table there maps
each row’s natural key (the EHR, the versioned composition, the archetype root,
the occurrence, and the mapping entry that wrote it) to its id, so a later
version of a composition keeps the ids of the rows it still has and deletes
the ones it no longer has. One
ehr_id is one PERSON; the bridge does not reconcile a person across EHRs.
An exhausted sequence refuses the composition with a typed error.
Each committed composition leaves a watermark naming the version written.
ferrobridge etl run --resume skips every composition whose watermark names
the version the query answers and runs the rest, so a run that stopped part
way through continues exactly. A composition the mapper or the database
refuses is rolled back, counted in the run report with the reason, and the
run goes on.
The run report counts, per mapping and in total, the rows written per table,
the concept_id = 0 assignments, the FACT_RELATIONSHIP rows written with
relationship_concept_id = 0, the source fields no mapping wrote, and every
refused record with its table, column and openEHR element. etl run prints it
as text and as JSON.
The derived tables
OBSERVATION_PERIOD spans the first to the last clinical event of each
person, one period per person. The CDM’s EHR guidance suggests more (an end
60 days after the last event, gaps from a persistence window); FerroBRIDGE
applies none of it, and says so.
CONDITION_ERA and DRUG_ERA run the two scripts on the CDM’s SQL scripts
page. The page publishes them in OHDSI SQL for SqlRender, which PostgreSQL does
not run, so the bridge carries their PostgreSQL form, changed only in dialect,
and a test fails when the published scripts change. The drug era script reads
ingredients of the RxNorm vocabulary only, as published.
VISIT_OCCURRENCE comes from the [etl.visits] query, grouped by EHR and
source, from the earliest start to the latest end. A time with an offset keeps
its wall-clock time, because the CDM datetime has no zone. The same holds for
every date and time a mapping reads from a composition: the offset is not
normalised, and the row keeps the wall-clock time the source wrote. The visit concept
and the visit type concept are configuration with no default.
Tying a composition to its visit
The CDM asks every clinical event to carry its visit where one exists, and leaves the tie to the ETL. No specification governs the rule below: it is FerroBRIDGE’s own design.
- A composition belongs to the visit of its own EHR whose window, from the
visit’s start to its end, contains the composition’s
context/start_time. Both bounds are inclusive. - A composition without a context, a persistent one, is tied by the first date its mapping resolved on its first row, with that row’s time when it has one.
- When several visits contain the moment, the visit whose source (the
visit_sourcethe[etl.visits]query selects) equals the name of the composition’scontext/health_care_facilitywins. When no source matches, or no facility is recorded, the bridge does not guess: the composition’s rows carry no visit. - A composition inside no visit window carries no visit either.
- Times are compared as wall-clock times to the second, with the offset dropped, the same reading the visits get. Where one side carries a date and no time, the two dates are compared.
The run report counts the committed compositions tied to a visit, those
inside no visit, and those inside several that the facility did not decide.
A run without [etl.visits] ties nothing and counts nothing.
The composition query
The [etl] aql query selects each composition whole beside its ehr_id and
version_uid, aliased by those names, with an ORDER BY and no LIMIT. It
may also select the versioned_object_uid; when it does, the bridge checks it
against the version, and when it does not, the bridge reads it from the
version_uid, whose first part names the versioned object (openEHR RM
Common 1.1.0, OBJECT_VERSION_ID). A query that reads only each composition’s
latest version, such as one over VERSION v[LATEST_VERSION], keeps a changed
composition from being written twice.
Checking a populated database with the Data Quality Dashboard
The OHDSI Data Quality Dashboard runs its checks against a populated CDM outside CI; the round trip test in CI does not run it. It is an R package (https://ohdsi.github.io/DataQualityDashboard/), with no container image of its own, so you run it from any R session that can reach the database:
-
Populate the database:
ferrobridge cdm init, load the vocabulary, thenferrobridge etl run. The CDM tables are in[cdm] schema(cdmby default); the bridge’s own tables are in[cdm] bridge_schemaand are not part of the CDM, so the dashboard is never pointed at them. -
Create a schema for the results, for example
CREATE SCHEMA dqd_results;. -
In R, install the package and the PostgreSQL driver:
install.packages("DataQualityDashboard") DatabaseConnector::downloadJdbcDrivers("postgresql", pathToDriver = "~/jdbc") -
Connect and run the checks against CDM v5.4:
connectionDetails <- DatabaseConnector::createConnectionDetails( dbms = "postgresql", server = "localhost/ferrobridge", port = 5432, user = "ferrobridge", password = Sys.getenv("CDM_PASSWORD"), pathToDriver = "~/jdbc" ) DataQualityDashboard::executeDqChecks( connectionDetails = connectionDetails, cdmDatabaseSchema = "cdm", resultsDatabaseSchema = "dqd_results", cdmSourceName = "FerroBRIDGE", cdmVersion = "5.4", outputFolder = "dqd-output", writeToTable = TRUE )The
serverargument ishost/database, asDatabaseConnectordocuments for PostgreSQL. -
Open the result with
DataQualityDashboard::viewDqDashboard()on the JSON file the run writes intodqd-output, and record every failing check with its adjudication.
Validating OMOCL files
OMOCL publishes no JSON schema, so FerroBRIDGE authors one from the grammar
tables and the published mapping library, validates every file against it
before running anything, and offers that schema upstream. The grammar also
leaves the reading of alternatives open; FerroBRIDGE tries them in order and
the first present value wins, which is the only reading the published library
is consistent with. A CustomMapping names a converter compiled into the
binary, and an unknown name refuses the file. A mapping that fails validation
is refused at load time.
What you need in place
A PostgreSQL CDM v5.4 database built from the OHDSI DDLs, an Athena vocabulary export loaded into it, a CDR to read from, and the OMOCL files for the archetypes you care about. The deployment shape page lists the neighbours in full.
Writing and loading mappings
Mappings are the product. FerroBRIDGE reads specification-conformant YAML and never carries a hand-coded converter for one FHIR resource or one OMOP table. A per-resource converter is refused in review, because it defeats the reason the project exists. The loader described here is designed and not yet built, so read this page as the contract it is being built to.
- What a mapping set looks like
- Validation happens at load, not at runtime
- Paths need a template
- Composition fields FHIR does not carry
- Code translation
- Escaping to code
- Where the mapping libraries come from
What a mapping set looks like
On the FHIR side you supply three kinds of file, and they compose in this order:
- A
modelfile per archetype, mapping it to an unprofiled R4 resource. - An
extensionfile where a profile or a template needs an adaptation. - A
contextfile as the entry point, naming the profile, the template, the archetypes, the extensions, and thestartmapping.
On the OMOP side you supply OMOCL files: a flat list of records keyed by CDM
target, each record’s keys drawn from OMOCL’s own column vocabulary (value,
unit, concept_id, measurement_date) with ordered alternatives.
FerroBRIDGE carries the table that projects each key onto CDM columns, because
OMOCL does not publish one. YAML anchors and aliases are supported, because the
published library uses them.
Validation happens at load, not at runtime
Every FHIRconnect file is checked three ways. The two published draft-07 JSON
schemas are vendored and run, and the files they reject are recorded, because
those schemas refuse three mapping types the specification defines. Then
FerroBRIDGE’s own strict schema runs, which admits exactly what the prose
defines. Then the constraints no schema can express are checked: targetRoot
alignment, appendTo and slotArchetype resolution, unique mapping names,
the presence of criteria where the grammar requires it, and every path
resolving against the template and the FHIR element table. Every OMOCL file is
validated against the schema FerroBRIDGE authors from the grammar tables.
Version agreement is part of loading. The mapping version, the grammar version,
the archetype revision, the template sem_ver, and the profile version have to
agree, and a mismatch is refused before any request is served.
Paths need a template
Paths in both languages are RM and archetype paths, such as
$archetype/data[at0001]/items[at0077]. A path alone is not executable,
because the leaf RM type and the template-specific identifiers live in the Web
Template built from the template’s operational template. FerroBRIDGE resolves
in that order: the operational template, then the Web Template, then the path,
then the composition. Supply the template to the CDR, and FerroBRIDGE reads it
from there.
Composition fields FHIR does not carry
A composition needs a composer, a context/start_time, a setting, a
language, a territory, and a category. FHIR carries no counterpart for
most of those, so FHIRconnect defaults them and requires the start mapping to
slot in a reusable COMPOSITION.<archetype>.<Resource> mapping. FerroBRIDGE
follows those defaults and records every defaulted value in the composition’s
FEEDER_AUDIT, so a later reader can tell which values came from the source
and which came from a default.
Code translation
FHIRconnect’s conceptmap and OMOCL’s conceptMap both translate codes, and
they resolve differently. A FHIRconnect conceptmap reference goes to the
configured FHIR terminology server through $translate. An OMOCL conceptMap
is an inline table in the mapping file, and everything else on the OMOP side
resolves through the loaded OHDSI vocabulary in SQL.
Escaping to code
mappingCode in FHIRconnect and CustomMapping in OMOCL name a function that
the mapping cannot express. FerroBRIDGE resolves those names against Rust
functions registered at build time. There is no runtime plugin loading, so a
mapping file cannot introduce new executable behaviour into a deployment.
Where the mapping libraries come from
No mapping library is vendored yet. The plan on record is to vendor the published FHIRconnect and OMOCL libraries verbatim through committed fetch scripts, with provenance recorded beside them, and to exercise them in full: every file loads and validates, or carries a recorded skip with its reason. That corpus is how FerroBRIDGE finds out that a grammar feature it has not implemented exists, which is why it lands with the first engine work rather than after it.
How the work is organised
The tracker is GitHub Issues, and the open issue list is the worklist. There is no separate plan document, because a plan document rots and an issue does not. This page tells you how to find work, how a change travels, and what a contribution needs to carry.
The loop
- Read the open issues. Each one opens with a plain summary, then an acceptance-criteria checklist, and sometimes a task list.
- Pick one that no open issue blocks. Blocked issues carry a dependency edge, so you can see the blocker before you start.
- Read the governing specification section first. FHIR, FHIRconnect, OMOP CDM, OMOCL, and openEHR ITS-REST are the authority, in that order of relevance to the change at hand.
- Work on a branch named
<type>/<slug>, with the type from the conventional commit set:feat,fix,chore,docs,refactor,perf,test,ci,build, orrelease. - Open a pull request whose body declares
Closes #<n>, so the merge closes the issue.
Labels carry the type and the priority. Milestones carry the release, and a release is cut when its milestone has no open issue left.
What makes a change reviewable
- A specification citation for anything spec-facing. Name the document and the section. Memory is not a source, and neither is another implementation’s behaviour.
- An explicit flag where no specification governs the decision. Write it plainly: this is FerroBRIDGE’s own design. FHIR search, identity, failure policy, and extension ordering are all in that category.
- A changelog entry under
[Unreleased]for anything with a user-visible effect. The format is Keep a Changelog 1.1.0. - Tests that measure against the specification. Never adjust an expectation to match a bug, and never weaken or skip a test to get a build green.
What is most useful right now
The project is in its design phase, so evidence beats code. A specification citation that contradicts a decision on record, a measurement, or first-hand experience with FHIRconnect, OMOCL, or an openEHR CDR in production is worth more than a speculative implementation.
Licence of contributions
Your contribution is licensed under the same Business Source License 1.1 that covers the project, and it grants the Licensor the relicensing right in CONTRIBUTING.md § Licensing of contributions. You keep your copyright; the pull request checkbox records your acceptance, and there is no separate agreement to sign.
The rules in full are in
CONTRIBUTING.md
and
CLAUDE.md.
Checks and gates
CI runs in two tiers. The first tier gates today, on a repository with no Rust
code: it covers the workflow files, the shell scripts, any Dockerfile, and the
committed guards. The second tier is the Rust set, written and gated behind a
job that looks for a root Cargo.toml, so it activates by itself when the
workspace lands. One conclusion job reads every result and is the single
required status check on main.
- Tier 1, running now
- Tier 2, gated on the workspace
- The end-to-end lane, and running it locally
- The conformance pass lists
- Vendored inputs and the build-time fetch
- The sqlx query metadata
- Workflow security rules
- Advisory analysers
- Building this book
Tier 1, running now
| Check | Command | What it protects |
|---|---|---|
| Workflow security | zizmor --min-severity=low .github/ | every uses: pinned to a commit SHA, no credential-persisting checkout, no context spliced into a shell, and the Dependabot cooldown window |
| Workflow correctness | actionlint | expressions that cannot evaluate, a needs: naming no job, unknown runner labels |
| Shell | shellcheck --severity=style over every tracked shell program | shellcheck’s lowest floor, so every finding gates |
| Containers | hadolint over every tracked Dockerfile | container-recipe defects; no Dockerfile exists yet, so it reports that and passes |
| Comment style | scripts/checks/comment-style.sh --all | line comments only, // TODO(#NNNN): naming its issue, // NOTE: as a citation plus one sentence |
| Version drift | scripts/checks/versions.sh | every file that repeats a pin agrees with docs/VERSIONS.md, and no first-party file claims a licence other than BUSL-1.1 |
| Favicon sync | scripts/checks/favicon-sync.sh | the book theme favicons stay byte-identical to the brand mark they are copies of |
| The book | mdbook build website/book | a page that does not build fails the pull request |
Run the same commands locally before you push. A finding costs a local run rather than a CI round trip.
Tier 2, gated on the workspace
cargo fmt --all --check, cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo nextest run --workspace --locked with
cargo test --doc --locked (CI runs both one package at a time through
cargo hack, so the generated giants never compile side by side on the
hosted runner), cargo doc under RUSTDOCFLAGS=-D warnings,
cargo deny check, an MSRV check, and dependency review on pull requests.
Every cargo lane runs --locked, so CI fails on lockfile drift rather than on
registry drift.
The end-to-end lane, and running it locally
One more job, e2e, starts real servers in containers: a PostgreSQL for the
OMOP CDM catalogue test and the reference openEHR CDR with its own database for
the ITS-REST client test. It is gated on the environment variable
FERROBRIDGE_E2E, and every container-backed test returns without touching
Docker unless that variable is 1, so your ordinary test run stays offline.
To run the lane yourself, start Docker and set the variable:
FERROBRIDGE_E2E=1 cargo nextest run --locked -p omop-cdm -p ferrobridge-server -p ferrobridge-term -p ferrobridge-testkit
The first run pulls the images, which are pinned by digest in
docs/VERSIONS.md; later runs start in a few seconds. Each test owns its
containers and stops them when it ends.
The conformance pass lists
Neither FHIRconnect nor OMOCL has an external conformance suite, so the corpus
tests are the instrument. Eleven corpora are measured, and each keeps a
committed pass list under conformance/<corpus>/pass-list.txt: one passing
case id per line, then a total line with the corpus size. The HL7 v2
messages count twice: the vendored sets are the hl7v2 corpus and the sets
fetched at build time the hl7v2-smoke corpus. HL7’s examples package of each
FHIR version is fetched at build time too, and five corpora read it: one per
version through the fhir-types model (fhir-r4, fhir-r4b, fhir-r5,
fhir-r6) and the R4 package through the facade (fhir-r4-facade).
| Corpus | A case passes when |
|---|---|
| FHIRconnect mapping library | the file parses; it validates against the published schemas (modules/ROOT/attachments/, schema/schema.adoc) or is in the pinned rejection set; it validates against FerroBRIDGE’s strict schemas; it loads into the library set with no refusal naming it; and a program the suites compile against a template carrying its archetype reaches it (types-of-mapping-files/context-mappings.adoc) |
| OMOCL mapping library | the file parses, validates against the authored schema, passes the rules of one file, and loads into the library set with every Include resolved |
| FHIR round-trip laws | PutGet and GetPut both run on the chain and each declares exactly the set its reviewed snapshot pins |
| FHIRconnect REST API (draft) | the wire contract reads every in parameter and part the FSH operation definition declares, and the operation answers only the out parameters it declares, each min = 1 one present (rest-api.adoc, draft) |
| HL7 v2 message corpora, HL7 v2 smoke corpora | the message is framed, decoded and parsed with no refusal, and the Bundle the map writes decodes as R4 and opens with its MessageHeader (bdl-12); an acknowledgment passes when it parses |
| FHIR R4, FHIR R4B, FHIR R5, FHIR R6 | the example decodes through the strict JSON codec of its version, re-encodes to the same document, and goes through the XML codec and back unchanged, compared as the lexical document model (members by name, numbers in their written text); a refusal or the first difference is the failure reason |
| FHIR R4 facade | for each context that maps the example’s type (the suite’s and the KDS diagnosis project), the create answers 201 and the read 200; the read decodes as an R4 instance; no element the example carries comes back with another value (PutGet); and a second create and read of the answer gives it back unchanged. The identity, meta and the subject are the facade’s and leave the comparison. An example of a type no context maps is counted by type under set_aside in the result file, never a case |
A case the list records that no longer passes fails the corpus test itself, so
a regression fails CI. When your change makes a case pass, or the corpus
changes size, the conformance job fails until you record it:
scripts/checks/conformance.sh --update
Commit the rewritten lists and badges with the change. Without a flag the
script compares and reports without failing on new passes; --check is what
CI runs. It needs cargo-nextest and jq. Never edit a list by hand, and
never remove a case from one to make CI green.
The badges and the README block
The script writes one shields.io endpoint badge per file under
conformance/badges/. A badge’s message is the passing and the total count
(68 / 120), and its colour follows the passing share: red under a quarter,
orange under a half, yellow under three quarters, green from three quarters,
and brightgreen when every case passes.
<corpus>.json: one per corpus, counted from its pass list.hl7v2-family-<family>.json: one per HL7 v2 message family with at least one case in either HL7 v2 corpus, ordered by family code, labelled with the family and counted over both corpora. A family the face maps none of shows red with its passing count at zero. The corpus test records each case’s family, MSH-9.1 or else the code before_in MSH-9.3, in the result file.hl7v2-version-<version>.json: one per MSH-12 version a case of either HL7 v2 corpus declares, labelledv2.5.1and so on.
The script also generates the README block between <!-- badges:begin -->
and <!-- badges:end --> from those files, in a fixed order, so never edit
it by hand: the build badges, a blank line, then one row per standard
(FHIRconnect 1.0.0 with the mapping library and the draft REST API, OMOCL
1.0.0, FHIR with the four model corpora, the round-trip laws and the R4
facade, and HL7 v2 with its two corpora, its
family badges and its version badges). Every badge links to its pass list,
and the family and version badges link to the hl7v2 list. --update
rewrites the block and removes a family or version badge no case counts any
more; --check fails when a badge file or the block disagrees with what
--update would write.
Vendored inputs and the build-time fetch
Every external corpus enters through a committed script under
scripts/vendor/, which reads its pin from docs/VERSIONS.md and writes a
PROVENANCE.md beside what it fetches. The HL7 FHIR packages, the v2-to-FHIR
implementation guide among them, go to tools/fhir-codegen/vendor/<package>/
through scripts/vendor/fhir-packages.sh, and the specification corpora go to
docs/specs/. scripts/checks/versions.sh reads every provenance file back
against its pin.
The FHIR examples packages are CC0, but at about 600 MB unpacked they would
more than double the committed FHIR packages, so
scripts/vendor/fhir-packages.sh --build-time fetches them into
tools/ferrobridge-testkit/vendor/<package>/package/, which .gitignore
refuses except for each committed PROVENANCE.md. The script checks the file
count and tree digest the pin records, keeps a tree already at both, and
--cache-key names the key CI restores them under. Run it before the FHIR
corpus tests; without the packages those tests say they skipped.
The HL7 v2 definitions are the one input whose terms do not permit
redistribution, so they are never committed. scripts/vendor/v2ig.sh fetches
them at build time into tools/fhir-codegen/vendor/hl7-v2ig/, which
.gitignore refuses except for its committed PROVENANCE.md. The script
checks the file count and tree digest the pin records on every run. Run it
before the codegen drift check, as the codegen-drift job does:
scripts/vendor/v2ig.sh
cargo run --locked -p fhir-codegen -- emit --check
To move the pin, change the commit in docs/VERSIONS.md, run
scripts/vendor/v2ig.sh --stamp, and copy the file count and digest it prints
into the same row.
The sqlx query metadata
omop-cdm checks its SQL at compile time from the metadata committed under
crates/omop-cdm/.sqlx/, and the sqlx-offline job fails when that metadata
is stale. After you change a query in omop-cdm, start Docker, install
sqlx-cli at the version docs/VERSIONS.md pins, and regenerate:
scripts/checks/sqlx-offline.sh
Commit the rewritten .sqlx/ files with the query change. With
DATABASE_URL set in your environment the sqlx macros connect to that
database instead of reading the metadata, so leave it unset for an ordinary
build.
Workflow security rules
Every workflow follows the same four rules, and the analysers above check them:
- Every
uses:is pinned to a full commit SHA with a trailing version comment. permissions: {}at workflow level, with the minimum granted per job.persist-credentials: falseon every checkout that does not push with git.- No
${{ }}interpolation inside arun:block; context travels throughenv:.
Advisory analysers
CodeQL scans the workflow files, because a workflow that holds a token is code. SonarQube Cloud runs a multi-language sweep over shell, YAML, and JSON. OpenSSF Scorecard scores the repository’s security posture. All three are advisory: they gate no merge, and a finding never outranks a specification citation or a project rule. A wrong finding is recorded on the tracker rather than suppressed quietly.
Building this book
$ cargo install mdbook mdbook-toc mdbook-mermaid
$ mdbook serve website/book
Use the versions pinned in docs/VERSIONS.md, which are the ones CI installs.
The site as GitHub Pages serves it, the landing page at the root and the book
under /docs/, is assembled by scripts/site/assemble.sh _site. That script
renders the roadmap block from the open milestones when gh is authenticated,
and leaves the block empty without failing when it is not.
Crate versions and the bump rule
The library crates under crates/ are published on crates.io, and a published
version is immutable. That single fact produces one rule you have to follow in
every pull request that touches them. The two version lines it sits inside are
described on the
version lines and releases page.
- The rule
- What is enforced, and where
- The escape, and its one condition
- Two things that are normal
fhir-typesis generated
The rule
A pull request that changes any packaged content of a crates/* member bumps
that member’s version in the same pull request.
Packaged content is what the crate’s include ships: src/**, README.md,
LICENSE, and Cargo.toml. A root [workspace.dependencies] entry the member
consumes counts too, because cargo package renders the concrete requirement
into the packaged manifest. Tests, benches, and CLAUDE.md are not packaged
and need no bump.
Three things move together:
- the
versionin that member’scrates/*/Cargo.toml, - any internal requirement naming it in the root
Cargo.toml[workspace.dependencies]table, Cargo.lock, refreshed withcargo update -wand committed.
A half-done bump fails the same way a missing one does.
What is enforced, and where
| Where | What it does |
|---|---|
scripts/checks/crate-version-guard.sh | compares a base ref with a head ref, and fails naming the member whose packaged content moved without its version |
the crate-version-guard CI job | runs that script over the pull request’s base and head; it is one of the jobs the conclusion check reads |
.claude/hooks/crate_version_bump_guard.sh | runs the same script before a git commit or a git push, against the merge base with origin/main, and refuses the command with the findings |
The hook checks a commit against the working tree, because the change it is
about to record is not in HEAD yet, and a push against HEAD. It is the
early copy of the CI job, not a second rule.
Run the guard yourself the way CI does:
scripts/checks/crate-version-guard.sh origin/main HEAD
The escape, and its one condition
A pull request carrying the no-crate-bump label skips the CI job. Use it only
when the diff provably alters no packaged bytes. The script itself reads no
labels, so the hook still reports; that is the intended asymmetry, since the
label is a reviewed decision rather than a local one.
Two things that are normal
A 0.0.0 version is a name reservation. Every member except fhir-types
still holds its crates.io name at 0.0.0, and the guard says so rather than
demanding a bump: content under a reservation changes until the first real
version.
A gap in the published sequence is fine. Not every bumped version is published. Publishing different content under a version that already exists is the forbidden case, and crates.io refuses it anyway.
fhir-types is generated
crates/fhir-types/src/** is emitted by tools/fhir-codegen and carries a
@generated banner. A change there is a generator change plus a regeneration,
never an edit of the output, and the bump follows the regeneration. The
codegen-drift CI job re-runs the emitter and fails on any difference.
Cutting a release
A milestone is a delivery promise, and a release is cut when its milestone
reaches zero open issues. This page is what happens around that cut: the
checklist before the tag, what the tag triggers, and what a consumer can check
afterwards. The repository-side copy is
docs/release.md,
and the two say the same thing.
Before the tag
- The milestone is empty.
gh issue list --milestone vX.Y.Z --state openanswers nothing, or the cut is called and the stragglers move to the next milestone. - The product version moves in every file that declares it. Today that is
CITATION.cff, the product row ofdocs/VERSIONS.md, the rootCargo.toml[workspace.package]version, and the image tag default incompose.yaml.scripts/checks/versions.shfails on any file left behind. This is the product line only; the library crates keep their own versions (crate versions). - The changelog names the release.
[Unreleased]becomes the version and the date, with a fresh empty[Unreleased]above it and a new link reference. What sits under the version heading is what the release notes say, so read it as the release notes before you tag. - The gates pass on the release commit. The same set CI runs (checks and gates).
The tag
The tag is signed and pushed from the working session that carried the milestone, right after the version-bump pull request merges:
git tag -s vX.Y.Z -m "vX.Y.Z" <the merged release commit>
git push origin vX.Y.Z
The release-tags ruleset requires a signature on refs/tags/v*, so an
unsigned tag is refused at push time.
What the tag triggers
.github/workflows/release.yml is dormant until a v* tag arrives. It does
not run on a push to main, on a pull request, or in a merge group.
plan ── github-release (draft) ── build-binaries ── build-image ── finalize-release (publish) ── crates
- plan validates the tag shape, checks it against every file that declares
the product version, and extracts the
## [X.Y.Z]section ofCHANGELOG.mdas the release notes. A missing or empty section fails the release, so a cut can never ship with notes generated from the commit range. It also emits the target matrix the build jobs and the asset check both read, so the two cannot drift apart. - github-release creates the release as a draft carrying those notes and
attaches
compose.yaml. A draft is mutable and invisible to anyone browsing releases, which is the window the asset uploads need. - build-binaries calls the reusable
release-build.ymlonce per target, on a runner of that target’s own architecture, with no cache and withcargo auditable. Each call produces the tarball, its checksum, two SBOMs, three Sigstore bundles and the provenance envelope. - build-image calls the reusable
release-image.ymlonce the musl binaries exist, verifies them against the build lane’s signer identity, and pushesghcr.io/ferrohealth/ferrobridgeforlinux/amd64andlinux/arm64. - finalize-release checks that the draft carries every asset this version
promises, then publishes. Publishing last means a half-assembled release is
never visible. A pre-release publishes with
--latest=false. - crates uploads the
crates/*members to crates.io once the release is public, so a refused upload never leaves a release half-cut. It runs in thecrates-ioenvironment, whose required reviewer pauses it until the owner approves, and authenticates with crates.io Trusted Publishing, so no long-lived registry token exists in the repository. Re-running the leg is safe: a version already on the index counts as done.
A second tag push queues behind the first. A release cancelled part-way through publishing is worse than a slow one.
What a consumer can check
The release is frozen. GitHub’s repository-level immutable-releases setting is on, so once a release is published its notes and its assets cannot be edited.
The tag is protected. The release-tags ruleset blocks deletion and
non-fast-forward updates on refs/tags/v* and requires signatures, so a
published vX.Y.Z cannot be moved to another commit and cannot be deleted.
The commit a release names stays the commit it was cut from.
Every artefact carries verifiable provenance. Each tarball ships a Sigstore
bundle and a SLSA provenance envelope, and the image carries its attestations
as OCI referrers. Because the build runs in a reusable workflow, the signing
certificate names that workflow, so gh attestation verify --signer-workflow
pins the artefact to the release lane rather than to any workflow in the
repository. The commands are in SECURITY.md.
The crates are verified after upload. The publish script reads the registry back rather than trusting the upload’s exit status.
A bad cut ships forward
The no-retag rule is ours, not the platform’s. A bad cut becomes a new patch version. Never move a tag, never delete a release and recreate it, and never edit a published release’s notes to fix what the changelog got wrong: fix the changelog and cut the next version.
The lane enforces the half it can. github-release refuses to reopen an
already-published release for the same tag and fails with that message. The
rest is the ruleset and the immutable-releases setting above, which is why this
page states both rather than claiming the rule is enforced end to end.