Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

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

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: slotArchetype in FHIRconnect, Include in OMOCL.
  • An escape hatch to code outside the mapping: mappingCode in FHIRconnect, CustomMapping in OMOCL.
  • A code-translation concept: conceptmap in FHIRconnect, conceptMap in 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 typeWhat it carries
modelan archetype mapped to an unprofiled resource, the shared layer
extensionprofile and template adaptation, executed after the model
contextthe 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

ComponentPinWhy
FHIRconnectv1.0.0the 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
FHIRR4 (4.0.1)the only value the FHIRconnect schemas admit for spec.version, and what the published mapping library targets
OMOCLv1.0.0the 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 CDMv5.4the only version the published OMOCL files declare
openEHR ITS-REST1.1.0the 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):

CrateUsed for
openehr-basethe RM foundation types, including partial dates
openehr-rmthe RM 1.1.0 model, its canonical JSON codec, the path parser
openehr-itsthe OPT 1.4 codec, the canonical JSON codec, the ITS-REST data types
openehr-sdtthe Web Template builder, the FLAT and STRUCTURED codecs, the composition builder, the RM-instance validation
openehr-querythe AQL 1.1.0 parser and printer
openehr-amthe AOM2 types an ADL 2 template decodes into
openehr-adltest 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.Z tag 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:

  1. 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, and omop-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.
  2. v0.0.3, the FHIR round trip. The published EVALUATION.problem_diagnosis.v1 FHIRconnect mapping and its published extensions, with an R4 Condition committed 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.
  3. 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_id values are asserted, an unmapped code lands as 0 with 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

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.

NeighbourProtocolNeeded by
An openEHR CDRopenEHR ITS-REST 1.1.0both sides
A FHIR terminology serverFHIR terminology operations, R4 or R4Bthe FHIR side
An OMOP CDM v5.4 databaseSQL, PostgreSQLthe OMOP side
The OHDSI vocabulary, loaded into that databaseSQLthe 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

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]

KeyDefaultMeaning
listen127.0.0.1:8080The socket address to bind
request_timeout_ms30000How long one request may take before the server answers 408
shutdown_timeout_ms10000How long the drain may take after SIGTERM or SIGINT
body_limit_bytes1048576The largest request body read before the server answers 413

[telemetry]

KeyDefaultMeaning
formatautoauto, json or pretty; auto is pretty on a terminal and json otherwise
filterinfo,hyper=warn,tower=warn,h2=warnA 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:

VariableSetsValues
FERROBRIDGE_LOG_FORMAT[telemetry] formatauto, json or pretty; any other value refuses the start with exit 78
RUST_LOG[telemetry] filterA 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:

EventFields
consoleformat, rendering, filter (the one in effect, after a fallback), colour
buildversion, commit, built_at, rustc, openehr_crates, fhir_types
lanelane (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.

KeyDefaultSecretMeaning
base_urlnone, requiredThe openEHR REST API root, usually ending in /v1
timeout_ms30000How long one call may take, connection included

[cdr.retry] and [terminology.retry]

KeyDefaultMeaning
max_attempts3How many times a call is sent at most, the first try included; 1 disables retry
initial_backoff_ms200The delay before the second attempt
max_backoff_ms5000The 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.

KeyDefaultSecretMeaning
bearer_tokennoneyesAn RFC 6750 bearer token
usernoneThe user name of RFC 7617 basic authentication; a user_file sibling names a file holding it, read at boot like a secret
passwordnoneyesThe 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.

KeyDefaultMeaning
base_urlnone, requiredThe FHIR service base URL
wire_versionr4r4 or r4b, the release the server answers in
timeout_ms30000How 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.

KeyDefaultSecretMeaning
urlnone, required when the section is presentyesThe PostgreSQL connection URL; it must name its sslmode
tls_canonenoThe PEM CA the database’s certificate is checked against
tls_ca_filenonenoA file holding that CA, read at boot
schemacdmnoThe schema the CDM tables live in, an unquoted lower-case identifier
bridge_schemaferrobridgenoThe schema the bridge keeps its natural-key side table, id sequences and watermarks in
person_policycreate_on_first_sightnocreate_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):

sslmodeWhat the clients do
disableConnect without TLS. A CA with disable is refused
requireConnect 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-caConnect over TLS; the certificate must chain to the CA, or to the webpki root set when no CA is set
verify-fullAs 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 holdscdm init
none of the 39 CDM v5.4 tables, or does not existCreates the schema when absent, then applies OHDSI’s tables, primary keys and indices in one transaction, and exits 0
every CDM v5.4 tableApplies nothing, prints that the schema is already initialised with the cdm_version its CDM_SOURCE rows record, and exits 0
some of the tablesApplies 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.

KeyDefaultSecretMeaning
aqlnone, requirednoThe 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_size100noThe fetch of each page of either query
type_concept_idnone, requirednoThe *_type_concept_id the mappings write, the provenance of the records
observation_period_type_concept_idnone, requirednoThe 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.

KeyDefaultSecretMeaning
aqlnone, requirednoThe visit query, aliasing ehr_id, visit_source, visit_start and visit_end, with an ORDER BY
visit_concept_idnone, requirednoThe visit_concept_id of every visit
visit_type_concept_idnone, requirednoThe 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.

KeyDefaultMeaning
directorynoneThe directory the mapping files are read from, recursively (.yml, .yaml)
templatesnoneThe directory holding the operational templates the two operations compile against, as OPT 1.4 XML (.opt)
omoclnoneThe 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 templates set, every .opt file 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 templates unset 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.

KeyDefaultMeaning
enabledfalseWhether the facade routes are mounted
base_urlhttp://127.0.0.1:8080/fhirThe absolute FHIR service base a client reaches, which Location is written under
ehr_policyexistingexisting writes only into an EHR the CDR already holds; create_on_first_write creates one for an unknown subject
identity_storeidentity.redbThe file the identity map is kept in, opened at boot and refused when it cannot be written
subject_namespaceferrobridgeThe namespace a subject identifier is looked up in on the CDR
system_idFerroBRIDGEThe AUDIT_DETAILS.system_id every commit records
composition_languagenone, requiredThe COMPOSITION.language every commit carries, an ISO 639-1 code
composition_territorynone, requiredThe 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.

KeyDefaultMeaning
enabledfalseWhether the MLLP listener runs
listen127.0.0.1:2575The socket address the listener binds
default_charsetASCIIThe HL7 table 0211 code a message with an empty MSH-18 is read in
concept_mapsnone, requiredThe package directory of hl7.fhir.uv.v2mappings, loaded at boot
supplements[]ConceptMap directories loaded over the guide, in order
unmapped_entriesskip_and_countskip_and_count or refuse, for an entry no program maps
ehr_policythe facade’sexisting 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_ms300000How long a connection may sit between messages; 0 keeps it open
frame_timeout_ms30000How long one frame may take to arrive whole; 0 waits
frame_limit_bytes1048576The largest message one frame may carry
log_outcomesfalseWhether 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.

KeyDefaultMeaning
enabledtrueWhether the two operations and their direct forms are served
device_referenceDevice/ferrobridge-<version>The Provenance agent.who a call that supplies no context.who gets
composerFHIRconnectThe composition composer an inbound run fills in when no mapping does
composition_languagenoneThe composition language, an ISO 639-1 code
composition_territorynoneThe 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

VariableFile sibling
FERROBRIDGE__CDR__CREDENTIALS__BEARER_TOKENFERROBRIDGE__CDR__CREDENTIALS__BEARER_TOKEN_FILE
FERROBRIDGE__CDR__CREDENTIALS__PASSWORDFERROBRIDGE__CDR__CREDENTIALS__PASSWORD_FILE
FERROBRIDGE__TERMINOLOGY__CREDENTIALS__BEARER_TOKENFERROBRIDGE__TERMINOLOGY__CREDENTIALS__BEARER_TOKEN_FILE
FERROBRIDGE__TERMINOLOGY__CREDENTIALS__PASSWORDFERROBRIDGE__TERMINOLOGY__CREDENTIALS__PASSWORD_FILE
FERROBRIDGE__CDM__URLFERROBRIDGE__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] url that names no sslmode, or one that falls back to plaintext or is not a libpq mode, a [cdm] CA that holds no PEM certificate, and a PGSSLROOTCERT, PGSSLCERT or PGSSLKEY in the environment;
  • a secret set both inline and through its _file sibling;
  • 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 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.

KeyDefaultMeaning
enabledfalseWhether the MLLP listener runs
listen127.0.0.1:2575The socket address the listener binds
default_charsetASCIIThe 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_mapsnone, requiredThe 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_entriesskip_and_countWhat an entry no program maps does: skip_and_count commits the others, refuse refuses the message
ehr_policythe facade’sexisting 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_ms300000How long a connection may sit between messages before it is closed; 0 keeps it open
frame_timeout_ms30000How long one message may take from its first byte to its trailer before it is answered AR; 0 waits
frame_limit_bytes1048576The largest message one frame may carry
log_outcomesfalseWhether 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

  1. The listener reads one MLLP Release 1 frame. A frame that is malformed, larger than frame_limit_bytes, or not complete within frame_timeout_ms is answered AR when a header can be read from it, and the connection closes.
  2. The message is decoded in the character set its MSH-18 names, split by position and grouped by its message structure. With senders set, a message whose MSH-4 names none of them is answered AR here and is never mapped.
  3. 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.
  4. Every entry whose resource type is listed under profiles and that claims no profile gets that profile in meta.profile, and every entry with a subject or patient reference and none set references the message’s one Patient. The guide leaves the relationships between the resources of one message to the implementer, and the facade selects a program by meta.profile, so these two steps are what let a message reach a mapping.
  5. 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 Patient entry’s first identifier. Each composition’s FEEDER_AUDIT names the message: its MSH-10 as the item id and its message type (ORU^R01) as the item type.
  6. The acknowledgment is sent once the ingest settled, so AA means 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’s supplements/ directory, compiled in) and loads them over the guide’s package at boot. The supplements directories 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. Its description names 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 under https://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 supplemented naming the map, so an outcome list shows which values a supplement wrote.

The shipped supplements:

MapKindWhat it changes, and why
datatype-cwe-to-codeableconceptoverrideRuns 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-codeableconceptoverrideThe same row for CE, the type HL7 v2.3 and earlier give most coded fields (#332)
datatype-cf-to-codeableconceptoverrideNames 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-quantityoverrideNames 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-sourceoverrideDrops 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-destinationoverrideWrites 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-locationoverrideLinks 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-messageheaderoverrideWith 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-diagnosticreportoverrideWrites ORC-2 into basedOn.identifier (R4 Reference.identifier), where the guide writes basedOn(ServiceRequest) with no map to fill it (#336)
segment-sch-to-appointmentoverrideWrites SCH-26 and SCH-27 into basedOn[1].identifier and basedOn[2].identifier for the same reason (#336)
segment-pid-to-appointmentoverrideWrites 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-bundleoverrideNames 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-bundleaddedADT_A03 (A03 discharge), from the rows of the guide’s ADT_A01 map at the same paths (#256)
message-bar-p01-to-bundleaddedBAR_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-bundleaddedORL_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-bundleaddedOUL_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.system as 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 PL to Location map’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 the k-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 sibling k when it holds a value. When it holds none, the reference climbs to the sibling that k’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 as sibling-unresolved and 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 as finest-sibling naming 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 as sibling-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.

OutcomeCodeERR
The ingest committed the messageAAnone
The same message was committed before (same MSH-10 and message type)AAnone, and nothing is committed again
No header can be read at allnonethe connection closes unanswered
Delimiters that do not declare themselves, a byte outside the declared character set, a version outside 2.xAR207 or 203
A message structure the definitions or the guide do not carryAR200
A required field or segment missing, an undecoded escapeAE101, 100 or 102, located
MSH-4 names a sending facility senders does not listAR207 at MSH^1^4
MSH-10 emptyAE101 at MSH-10
A condition of the guide that stops the mapper, a Bundle with no MessageHeaderAE101 or 207
No program maps any entry, an entry that does not map, a second subject, a conflict with the identity mapAE207, one per refused entry, naming its fullUrl
The terminology server refused or did not answer a translationAR207
The CDR failed, was unreachable, timed out, or refused the bridge’s credentials (5xx, 401, 403, 407, 408, 429)AR207

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

FactValue
Registry and repositoryghcr.io/ferrohealth/ferrobridge
Tagthe product version, for example 0.0.1; each release publishes its own
Basegcr.io/distroless/static-debian13:nonroot, pinned by index digest
Platformslinux/amd64 and linux/arm64
Useruid 65532, gid 65532, written numerically
Entrypoint/usr/local/bin/ferrobridge, exec form, with serve as the default command
Port8080/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/liveness answers 200 while the process is up. Restart the container when it stops answering.
  • GET /health/readiness runs one bounded check per configured upstream and answers 200 when 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

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

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 id and the same meta.versionId: the create is a replay. The facade commits nothing and answers 200 OK with the Location, the ETag and 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 id and another meta.versionId: a changed meta.versionId means the sender changed the resource, so the create commits a later version of the composition the first delivery produced and answers 200 OK. That is the update a PUT performs, following the version the CDR holds now.
  • The same id and no meta.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 composition uid and every time the engine filled from its clock. The same content is a replay, answered as above. Other content is the later version.
  • An id the map binds to more than one composition: the facade cannot tell which one the create revises, so it answers 409 Conflict with a conflict issue 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:

  1. When HD.3 (the universal ID type) is ISO, UUID, DNS or URI and HD.2 (the universal ID) is valued, the endpoint is the form the guide’s HD endpoint map assigns: urn:oid:, urn:uuid:, urn:dns: or urn:uri: followed by HD.2.
  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 3986 pchar set is percent-encoded, and so is : inside a component, so North Lab App becomes urn: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 wantRead
the version a binary reportsferrobridge --version
the version a release shipsthe vX.Y.Z release on GitHub, and its changelog section
the version the source tree declaresthe 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 wantRead
the version a crate publishesthe crate’s page on crates.io, or cargo add <crate>
the version the source tree declaresthat member’s crates/*/Cargo.toml version
the line fhir-types is onthe 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

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.

RequestWhat it does
GET /fhir/metadataThe CapabilityStatement of the loaded mapping set
POST /fhir/{type}Create, conditional through If-None-Exist
POST /fhir/{type}/$validateThe 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 /fhirA 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 answersThe facade answers
422, template validation422 with validationErrors verbatim in issue.diagnostics
412412 carrying the current ETag
204 on a read, the composition is deleted410
404404
401401, propagating WWW-Authenticate, never 403
400, 405 or 415500: the bridge chose a call the CDR does not offer
any 5xx502 carrying the upstream status
no answer: a connect failure or a timeout502

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/aql on the CDR.
  • A CapabilityStatement that names exactly which search parameters each resource supports.
  • An OperationOutcome refusing 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

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 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_source the [etl.visits] query selects) equals the name of the composition’s context/health_care_facility wins. 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:

  1. Populate the database: ferrobridge cdm init, load the vocabulary, then ferrobridge etl run. The CDM tables are in [cdm] schema (cdm by default); the bridge’s own tables are in [cdm] bridge_schema and are not part of the CDM, so the dashboard is never pointed at them.

  2. Create a schema for the results, for example CREATE SCHEMA dqd_results;.

  3. In R, install the package and the PostgreSQL driver:

    install.packages("DataQualityDashboard")
    DatabaseConnector::downloadJdbcDrivers("postgresql", pathToDriver = "~/jdbc")
    
  4. 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 server argument is host/database, as DatabaseConnector documents for PostgreSQL.

  5. Open the result with DataQualityDashboard::viewDqDashboard() on the JSON file the run writes into dqd-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

On the FHIR side you supply three kinds of file, and they compose in this order:

  1. A model file per archetype, mapping it to an unprofiled R4 resource.
  2. An extension file where a profile or a template needs an adaptation.
  3. A context file as the entry point, naming the profile, the template, the archetypes, the extensions, and the start mapping.

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

  1. Read the open issues. Each one opens with a plain summary, then an acceptance-criteria checklist, and sometimes a task list.
  2. Pick one that no open issue blocks. Blocked issues carry a dependency edge, so you can see the blocker before you start.
  3. 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.
  4. Work on a branch named <type>/<slug>, with the type from the conventional commit set: feat, fix, chore, docs, refactor, perf, test, ci, build, or release.
  5. 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

CheckCommandWhat it protects
Workflow securityzizmor --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 correctnessactionlintexpressions that cannot evaluate, a needs: naming no job, unknown runner labels
Shellshellcheck --severity=style over every tracked shell programshellcheck’s lowest floor, so every finding gates
Containershadolint over every tracked Dockerfilecontainer-recipe defects; no Dockerfile exists yet, so it reports that and passes
Comment stylescripts/checks/comment-style.sh --allline comments only, // TODO(#NNNN): naming its issue, // NOTE: as a citation plus one sentence
Version driftscripts/checks/versions.shevery file that repeats a pin agrees with docs/VERSIONS.md, and no first-party file claims a licence other than BUSL-1.1
Favicon syncscripts/checks/favicon-sync.shthe book theme favicons stay byte-identical to the brand mark they are copies of
The bookmdbook build website/booka 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).

CorpusA case passes when
FHIRconnect mapping librarythe 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 librarythe 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 lawsPutGet 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 corporathe 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 R6the 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 facadefor 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, labelled v2.5.1 and 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: false on every checkout that does not push with git.
  • No ${{ }} interpolation inside a run: block; context travels through env:.

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

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:

  1. the version in that member’s crates/*/Cargo.toml,
  2. any internal requirement naming it in the root Cargo.toml [workspace.dependencies] table,
  3. Cargo.lock, refreshed with cargo update -w and committed.

A half-done bump fails the same way a missing one does.

What is enforced, and where

WhereWhat it does
scripts/checks/crate-version-guard.shcompares a base ref with a head ref, and fails naming the member whose packaged content moved without its version
the crate-version-guard CI jobruns 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.shruns 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

  1. The milestone is empty. gh issue list --milestone vX.Y.Z --state open answers nothing, or the cut is called and the stragglers move to the next milestone.
  2. The product version moves in every file that declares it. Today that is CITATION.cff, the product row of docs/VERSIONS.md, the root Cargo.toml [workspace.package] version, and the image tag default in compose.yaml. scripts/checks/versions.sh fails on any file left behind. This is the product line only; the library crates keep their own versions (crate versions).
  3. 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.
  4. 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 of CHANGELOG.md as 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.yml once per target, on a runner of that target’s own architecture, with no cache and with cargo auditable. Each call produces the tarball, its checksum, two SBOMs, three Sigstore bundles and the provenance envelope.
  • build-image calls the reusable release-image.yml once the musl binaries exist, verifies them against the build lane’s signer identity, and pushes ghcr.io/ferrohealth/ferrobridge for linux/amd64 and linux/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 the crates-io environment, 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.