Introduction
FerroTERM is a pure-Rust FHIR terminology server. It serves SNOMED CT, LOINC,
ICD-10, ICD-11, RxNorm, UCUM, and any FHIR CodeSystem through the HL7 FHIR
terminology API from one binary, over an index built once per code system
release and read at startup. There is no JVM, no Elasticsearch, and no database
to run.
Note
The name: Ferro for the Rust family FerroTERM shares with FerroEHR, TERM for terminology. The site is https://ferroterm.eu.
Where the project is
v0.1.8 is the current release. It ships as Linux binaries for x86_64 and
aarch64 (glibc and musl), as a container image for linux/amd64 and
linux/arm64, and as a Compose file, all built with signed provenance. The
server answers FHIR R4 under /r4, R4B under /r4b, R5 under /r5, and the
R6 ballot under /r6; the CI lane runs the HL7 terminology ecosystem suite
against the published versions on every change and holds a committed pass list
per version.
Hierarchical expansion, persisted client resources, Bundle batch, $closure,
and the SNOMED implicit concept maps are served. The persisted resources and the
closure tables need a deployment that names a database in FERROTERM_RESOURCES,
and $closure answers on /r4, /r4b, and /r5, since the R6 ballot defines
no such operation. Where a mature terminology server does more, such as
authoring SNOMED content and MRCM validation of post-coordinated expressions,
this book says so. Each open item is an issue with acceptance criteria on the
tracker, and the milestones are the roadmap.
Who each part of this book is for
- Evaluate is for anyone deciding whether FerroTERM fits: what it serves, why it exists, the architecture at a glance, and how it compares to Snowstorm, Ontoserver, and Hermes.
- Operate is for operators: installing and running the server, configuration, building an index from each code system’s release, hardware sizing, and verifying a release before you run it.
- Integrate is for API consumers: the FHIR terminology operations, the implicit value sets, the served FHIR version, and worked request and response examples captured from a running server.
- Contribute is for contributors: building and testing, the FHIR code generation model, and where the deeper design lives.
Two things that are always true
You bring your own code system content, and the software is source-available under the Business Source License 1.1: free to use outside production and in non-commercial production, a commercial licence for other production use, and Apache License 2.0 four years after each version. SNOMED CT is licensed by SNOMED International, LOINC by the Regenstrief Institute, ICD by the WHO, and RxNorm by the NLM; this repository and every build of FerroTERM ship none of their content. You load a release you are licensed for, and the server serves it. See Loading code systems.
What FerroTERM is
FerroTERM is a read-oriented FHIR terminology server for clinical code systems. It answers the HL7 FHIR terminology operations over the code systems you load, and every code system reaches the operations through the same provider seam, so LOINC, ICD-10, RxNorm, and the rest are loaders and providers, never special cases in an operation.
What it does
The server exposes the FHIR terminology API on R4B:
CodeSystem/$lookup: a concept’s display, designations by language, and properties.CodeSystem/$subsumes: whether one concept subsumes another, from the transitive closure.CodeSystem/$validate-codeandValueSet/$validate-code: whether a code is valid, whether its display is right (the correct one comes back when it is not), and, for a value set, whether the code is a member.ValueSet/$expand: the members of a value set, paged, filtered by text, with version pins, over loaded, inline, and request-scoped value sets. A compose of one include over one system comes back as that system’s own tree, nested undercontains.ConceptMap/$translate: the targets a code maps to, from loaded or inline maps and from the SNOMED implicit concept maps (?fhir_cm=[sctid]).$closure: a named transitive closure table the server keeps in step with the codes a client registers, answered as aConceptMapdelta.POST [base]with abatchBundle: every entry answered in the order it was sent, each one independent of the rest.POST,PUT,GET,DELETE,_historyreads, and?url=search ofCodeSystem,ValueSet, andConceptMap, withETagandIf-Match, where the deployment names a database inFERROTERM_RESOURCES. The closure tables live in the same database.metadataandmetadata?mode=terminology: what this deployment serves.
What it serves
| Code system | What you provide | What the provider answers |
|---|---|---|
| SNOMED CT, any edition | the RF2 release zip | the inferred hierarchy, preferred terms per language reference set, inactive concepts marked, the edition and version URI as version, the is-a and in filters |
| LOINC | Loinc_x.yy.zip | terms, parts, answer lists, and answers as codes; the FHIR LOINC properties and filters; /vs, /vs/[LL…], /vs/[LP…] |
| ICD-10 (WHO and national translations), ICPC-2 | a ClaML document | the classified-with tree, modifiers expanded onto their leaves, inclusion and exclusion notes as properties |
| ICD-10-CM | the CMS tabular XML and order file | codes with the period, seventh-character codes under their stem, the valid flag |
| ICD-11 (MMS, ICF, the Foundation) | a local deployment of the WHO ICD-API | short codes, entity URIs, postcoordination expressions validated against the axes, the scale value sets |
| RxNorm | the RRF release or the prescribable subset | the FHIR STY, SAB, TTY, REL, and RELA filters over typed edges, /vs |
| ATC/DDD | the WHO index as CSV, or the G-Standaard BST801T file | the five levels as a classified-with tree, DDDs as properties |
| DHD thesauri | the Uitleverformaat 5.0 delivery zip | a flat table with the SNOMED CT, ICD-10, DBC, and ZA links as properties and as concept maps |
| G-Standaard | the monthly release directory (BSTnnnT files) | the GPK, PRK, HPK, and article systems, the ladder as properties |
FHIR CodeSystem, ValueSet, ConceptMap | a FHIR package or a directory of JSON | hierarchy, filters, properties, and supplements as the resources declare them |
| UCUM, BCP 47, BCP 13, ISO 3166-1 | nothing | grammar and registry validation, vendored into the binary |
docs/terminologies.md
records each system’s FHIR page, its licence, and exactly what the provider
implements.
Why it exists
Most SNOMED CT terminology servers run on the JVM, and the reference server, Snowstorm, runs on Elasticsearch. That stack works, and it asks for a lot of memory: a machine serving an edition wants 16 to 32 GB of RAM, most of it Elasticsearch heap, plus a search cluster to operate.
The data is smaller than that: a few hundred thousand concepts, a million descriptions, a million relationships. FerroTERM builds an edition into one index once, reads it at startup, and serves it from one process; the benchmarks page has what that costs in build time, disk, and resident memory for each code system, measured and recorded. The goal is plain: run a clinical terminology server on hardware you already have.
Who uses it
FerroTERM speaks the FHIR terminology API, so any FHIR client can use it: a validator, an IG publisher, a clinical application resolving value set bindings. One such client is FerroEHR, a pure-Rust openEHR clinical data repository by the same author. FerroTERM is independent of it.
What is not built yet
The build order is the tracker’s milestones.
- Post-coordinated SNOMED expressions are not served: the SNOMED URI’s
expressionsparameter answersfalse. ECL, every implicit value set, and the implicit concept maps (?fhir_cm=) are served. - MRCM validation of postcoordinated SNOMED expressions is not scheduled.
- FerroTERM serves an index built from a finished release. It authors no code system content, and it has no syndication client, so you download the release yourself and run the build tool over it.
What stable means in v0.1.0
v0.1.0 is the first release the project calls stable. Stable here is a promise about specific surfaces, not about all of them, so this page says which ones carry it and which do not. A surface that carries the promise changes only in ways your deployment survives; a surface that does not may change in any release, and this page is where you find out which is which.
The version itself is still 0.x, and it stays there until the surfaces below
have been through real deployments. Read the promise, not the number.
What carries the promise
The HTTP terminology API for R4, R4B, and R5. The operations
($lookup, $validate-code, $subsumes, $expand, $translate), the
parameters each version’s OperationDefinition declares, the shape of the
Parameters answer, and the OperationOutcome a refusal carries. A request
that works against v0.1.0 works against every later 0.1.x. Where the
specification and this server disagree, the specification wins and the change
is a fix, which the conformance page records.
The URL layout. The version prefix (/r4, /r4b, /r5), the resource
paths under it, /metadata and /metadata?mode=terminology, and /health.
The implicit value sets and concept maps of SNOMED CT. The ?fhir_vs
forms and ?fhir_cm, as the
SNOMED CT URI standard
defines them.
Configuration. The FERROTERM_* environment variables keep their names
and meanings. New ones may appear; an existing one does not change what it
does, and one that is retired keeps working for the rest of the 0.1 line.
The licence terms. BUSL 1.1 with the change date and change licence each release states. A released version’s terms never change after the fact.
What does not carry the promise
R6, which is ballot-tracking. /r6 answers in the shapes of
hl7.fhir.r6.core 6.0.0-ballot5. R6 is not published, so its shapes can
change under us, and when the ballot moves this endpoint moves with it inside
the 0.1 line. Build against /r5 if you need the promise;
FHIR versions has the detail.
The built artifact format. The layout the offline build writes carries a version, and a server refuses an artifact of another one rather than misread it. That refusal is deliberate: a wrong display is worse than a failure to start. Expect to rebuild your artifacts when you upgrade, and plan the upgrade as build then switch, not switch then build. The install page has the order.
The crates.io crates. rf2, concept-graph, concept-store,
designation-index, sct-ecl, fhir-terminology, and the rest are
published so other Rust projects can use them, on their own 0.x
line that moves with every change to their contents. They are pre-1.0 and
their APIs change. The server’s HTTP API is the stable surface; the crates are
not it.
Performance figures. The benchmarks are records of what
one machine measured, not a floor the project holds you to. The bars the
project does claim live in bench/bars.json and CI fails when one is crossed.
Anything the conformance page marks as failing. A suite case that does not pass is a known gap with an issue, and fixing it changes behaviour. That is a fix, and the promise does not protect a wrong answer.
How a breaking change would arrive
Inside the 0.1 line, a change to a promised surface happens only to follow the specification or to fix a wrong answer. It arrives with the issue that adjudicated it, the spec citation, and a note in the changelog saying what moved. If a promised surface has to change for any other reason, that is 0.2.
Architecture at a glance
This page is a short tour of the design as built. The full design authority,
with citations to the terminology-server and graph-reachability literature, is
docs/architecture.md
in the repository.
- Offline once, online from precomputed structures
- An index-materialized graph, not a graph database
- The artifact layout
- The provider seam
- FHIR support is machine-generated
- Text search is a separate index
Offline once, online from precomputed structures
A code system release is turned into served structures once, by
ferroterm-build, and the server opens them read-only:
graph TD
R["A release (RF2, LOINC, ClaML, RRF, the ICD-API cache, ...)"] --> BUILD["ferroterm-build, once per release"]
BUILD --> IDX["store.redb + its column files + hierarchy.bin + text.bin + manifest.json"]
IDX --> SRV["ferroterm, read-only, loaded at startup"]
SRV --> API["FHIR terminology API (R4B today)"]
For SNOMED CT the hierarchy a client queries is the inferred one, a reasoner output SNOMED ships in its inferred relationship file; the build computes its transitive closure once and persists it. The server never re-derives inference and never parses a release at request time.
An index-materialized graph, not a graph database
The hierarchy is stored as integer-keyed compressed sparse-row (CSR)
adjacency plus roaring bitmaps holding the transitive closure in both
directions. A subsumption test is a bitmap membership test, a descendant set is
a bitmap returned directly, and set operations over value sets are bitmap AND,
OR, and difference. Typed relationships (RxNorm’s has_ingredient, for one)
are a second adjacency with the type on each edge, both directions
materialized.
A general-purpose graph database is rejected on the evidence: no production terminology server uses one (Ontoserver is Postgres plus Lucene, Snowstorm is Elasticsearch, Hermes a memory-mapped store plus Lucene), and graph databases degrade on exactly the deep multi-hop neighbourhoods a descendant expansion produces.
The artifact layout
Every code system builds into files with the same names and the same meanings, so the server opens each by its manifest and nothing downstream knows which file format it came from. The store with its four columns, the word index, and the manifest are always written; a system with a hierarchy adds one more, and several add files of their own:
| File | Holds |
|---|---|
store.redb | the designations with their language and use, the code index, the vocabularies; a pure-Rust, disk-backed, ACID embedded engine |
store.concepts.col, store.displays.col, store.properties.col, store.acceptability.col | the dense columns the store reads at open, one record per concept, each addressed by ordinal |
hierarchy.bin | the CSR is-a adjacency and the roaring closure bitmaps, for a system that has a hierarchy; RxNorm has none and writes none |
text.bin | the fst word dictionary and roaring postings of every designation, by language |
manifest.json | the system, version, languages, counts, and the files above |
Several systems add files. RxNorm writes its typed edges (relations.bin) and
atom identifiers (atoms.bin); ICD-11 writes entity keys (keys.bin) and
postcoordination scales (scales.json); SNOMED CT writes reference set members
(refsets.bin, members.bin), the concept model attributes ECL evaluates
(attributes.bin), and the alternate identifiers (identifiers.bin). A
SNOMED edition is the largest of them, at seven files. No FHIR or SNOMED
specification governs this layout; it is the project’s own design.
The provider seam
The operations talk to a CodeSystemProvider trait and nothing else: locate a
code, its display and designations by language, its status and properties, the
hierarchy when the system has one, search, enumeration, filters, implicit value
sets, and subsumption. Each code system implements the seam over its artifact
(or, for the registry systems, over a grammar and a vendored table), and
declares its capabilities, filters, and properties for
TerminologyCapabilities. The compose layer (include, exclude, filters,
paging, total) sits once above every provider.
FHIR support is machine-generated
HL7 publishes the whole type system and every operation as machine-readable
StructureDefinition and OperationDefinition resources, in versioned
packages. FerroTERM vendors and pins those packages (R4 4.0.1, R4B 4.3.0, R5
5.0.0, R6 ballot 5, HL7 Terminology) and generates per-version Rust modules
from them, so a parameter R5 adds appears in the R5 module and is absent from
R4B. Every generated file is marked // @generated, and a drift check
regenerates it in CI. The server mounts the R4B module today; the R4, R5, and
R6 modules are served under /r4, /r5, and /r6.
Text search is a separate index
Description search is its own concern. A word index maps each folded word to a
roaring postings list of designations, with an fst dictionary for prefix
lookup, per language. A filter on $expand intersects the matching words’
postings and the value set’s members.
How it compares
FerroTERM serves the same FHIR terminology API as the established servers. The difference is the runtime footprint, the stack it needs to operate, and how far along the feature set is. This page sets FerroTERM beside Snowstorm, Ontoserver, and Hermes.
Note
The numbers for the other servers come from their own documentation and published work, cited in
docs/architecture.md. FerroTERM’s own figures are measurements over the licensed Dutch SNOMED CT edition on a laptop, and each one links the record that produced it, naming the release it was measured on; see Benchmarks and Hardware sizing.
The servers
- Snowstorm (SNOMED International): Java on Elasticsearch. The reference server and FerroTERM’s behavioural oracle. Full-featured, and a full International edition deployment wants 16 to 32 GB of RAM plus a search cluster.
- Snowstorm Lite (SNOMED International): drops Elasticsearch for a single Lucene index and runs the International edition in about 500 MB.
- Ontoserver (CSIRO): Postgres plus Lucene. A production FHIR terminology server, index-materialized rather than a graph database; the Dutch national terminology server runs it.
- Hermes (Mark Wardle): Clojure, a memory-mapped store plus Lucene. It serves subsumption in tens of microseconds from a materialized structure, the design point FerroTERM follows in pure Rust.
Side by side
| Server | Language / runtime | Store | Edition footprint | Deploys as |
|---|---|---|---|---|
| Snowstorm | Java (JVM) | Elasticsearch | 16 to 32 GB RAM | JVM plus a search cluster |
| Snowstorm Lite | Java (JVM) | Lucene | about 500 MB | JVM service |
| Ontoserver | Java (JVM) | Postgres plus Lucene | server plus database | JVM plus Postgres |
| Hermes | Clojure (JVM) | memory-mapped store plus Lucene | modest | JVM service |
| FerroTERM 0.1.8 | Rust | redb plus roaring and fst artifacts, read at startup | 559 to 864 MB on disk, 943 MB to 1.14 GB resident, per SNOMED edition | one binary (statically linked on the musl targets) or a distroless image |
What FerroTERM trades
FerroTERM is younger. Hierarchical expansion, persisted client resources,
Bundle batch, $closure, the SNOMED implicit value sets and implicit concept
maps, ECL, the R4, R4B, R5, and R6 endpoints, and XML are served; the persisted
resources and the closure tables need a deployment that names a database in
FERROTERM_RESOURCES. FerroTERM serves an index built from a finished release,
so it authors no SNOMED content and has no syndication client to download one,
and MRCM validation of postcoordinated expressions is unscheduled. What
it serves, it serves from one binary with signed provenance, over more code
systems than the SNOMED-only servers, and with its conformance measured by the
HL7 terminology ecosystem suite on every change.
Every production server in this list converges on a materialized index rather than live graph traversal. FerroTERM takes the same shape in pure Rust, with a machine-generated FHIR layer across four versions.
Code systems served
One table of every code system FerroTERM serves: the canonical URI each is
served under, the versions and editions handled, how you bring its content,
and its licence position. This page is the single source; the README and the
landing page carry the same names, and a check in CI fails when a row here is
missing from either. The design notes per system (identity rules, filters,
properties, implicit value sets, with their citations) are in
docs/terminologies.md in the repository.
| Code system | Canonical URI | Versions and editions | How you load it | Licence |
|---|---|---|---|---|
| SNOMED CT | http://snomed.info/sct | Any RF2 edition; the version is the edition URI (http://snomed.info/sct/[module]/version/[date]) | ferroterm-build --rf2 <release zip or dir> into FERROTERM_INDEX | Licensed by SNOMED International; free in member countries through the national release centre |
| LOINC | http://loinc.org | Any release (2.82 style), with its linguistic variants | ferroterm-build --loinc <Loinc_x.yy.zip or dir> | Free under the LOINC licence |
| UCUM | http://unitsofmeasure.org | The ucum-essence.xml grammar vendored in the binary | Nothing to load | Free (UCUM licence) |
| BCP 47 | urn:ietf:bcp:47 | The IANA Language Subtag Registry vendored in the binary | Nothing to load | Open (IETF) |
| BCP 13 | urn:ietf:bcp:13 | The RFC 6838 grammar and the IANA media type registry, vendored | Nothing to load | Open (IETF) |
| ISO 3166-1 | urn:iso:std:iso:3166 | The country codes from Unicode CLDR, vendored | Nothing to load | Open (CLDR) |
| ICD-10 (WHO) | http://hl7.org/fhir/sid/icd-10 | Any ClaML release (2019 style) | ferroterm-build --claml <xml or zip> --system http://hl7.org/fhir/sid/icd-10 | Licensed by WHO; national translations by their publishers |
| ICD-10-NL | http://hl7.org/fhir/sid/icd-10-nl | The Dutch ClaML release (WHO-FIC NL) | ferroterm-build --claml <xml or zip> --system http://hl7.org/fhir/sid/icd-10-nl | Licensed by WHO-FIC NL |
| ICD-10-CM | http://hl7.org/fhir/sid/icd-10-cm | Any CMS fiscal-year release (tabular and order files) | ferroterm-build --icd10cm <dir or zip> | Public domain (US) |
| ICD-11 MMS | http://id.who.int/icd/release/11/mms | Any WHO release (2026-01 style), in the languages fetched | ferroterm-build --icd11 <cache> --icd11-api <local ICD-API> | CC BY-ND 3.0 IGO |
| ICD-11 ICF | http://id.who.int/icd/release/11/icf | The same WHO release | The same --icd11 build | CC BY-ND 3.0 IGO |
| ICD-11 Foundation | http://id.who.int/icd/entity | The same WHO release | The same --icd11 build | CC BY-ND 3.0 IGO |
| ATC/DDD | http://www.whocc.no/atc | Any annual index (2026 style) | ferroterm-build --atc <index.csv or BST801T> --atc-version <year> | Purchased from the WHO Collaborating Centre |
| ICPC-2 | http://hl7.org/fhir/sid/icpc-2 | Any ClaML release | ferroterm-build --claml <xml or zip> --system http://hl7.org/fhir/sid/icpc-2 | Licensed by WONCA |
| RxNorm | http://www.nlm.nih.gov/research/umls/rxnorm | Any monthly release, or the licence-free prescribable subset | ferroterm-build --rxnorm <RRF zip or dir> | UMLS licence for the full release; the prescribable subset is free |
| DHD Diagnosethesaurus and Verrichtingenthesaurus | urn:oid:2.16.840.1.213883.2.4.3.120.5.1 | Any Uitleverformaat 5.0 delivery, with its SNOMED CT and ICD-10 concept maps | ferroterm-build --dhd <delivery zip or dir> | Licensed by DHD |
| G-Standaard | urn:oid:2.16.840.1.213883.2.4.4.1 (GPK), .10 (PRK), .7 (HPK), .8 (article) | Any monthly release | ferroterm-build --gstandaard <dir> --gstandaard-version <release> | Z-Index subscription |
| Nederlandse Labcodeset | https://ferroterm.eu/fhir/ValueSet/nl-labcodeset and the LOINC supplement https://ferroterm.eu/fhir/CodeSystem/nl-labcodeset-loinc | Any Nictiz publication, as a value set and supplement over LOINC, SNOMED CT, and UCUM | ferroterm-build --labcodeset <zip, document, or dir> into FERROTERM_CODESYSTEMS | Licensed by Nictiz |
| NHG ICPC-1 to SNOMED CT map | http://terminologie.nictiz.nl/ns/ConceptMap/icpc1nl-snomed (source http://hl7.org/fhir/sid/icpc-1-nl) | Any Nictiz release of the FHIR ConceptMap | A directory with the ConceptMap and a package.json, in FERROTERM_CODESYSTEMS | Licensed by the NHG |
FHIR CodeSystem, ValueSet, and ConceptMap resources | Each resource’s own url | HL7 Terminology and any FHIR package or directory of JSON, supplements included | FERROTERM_CODESYSTEMS | Per resource; HL7 Terminology is CC0 |
Every system reaches the operations through one provider seam, so nothing in an operation is a special case for one system. The repository ships no code system content, licensed or open: you bring the release you are licensed for, and the build tool turns it into an index. The loading page shows each build.
Benchmarks
Every figure FerroTERM quotes about speed or footprint comes from a record the benchmark harness wrote, never from a number typed by hand. A record names the machine, the FerroTERM version, the code system release, and the method beside every figure, so you can rerun it and compare.
- What a record holds
- Reproduce a record
- Run in a container
- Units, and what a record refuses
- The published figures
- The current record set
- Reading the numbers
What a record holds
For one code system and one run, ferroterm-bench writes a JSON record with:
- the machine (operating system, architecture, CPU, memory) and the FerroTERM version;
- the code system, its version, its concept count, and the artifact’s size on disk;
- the ingest: wall time around
ferroterm-buildover the release, and the build’s peak resident memory (from/usr/bin/time), when the release is at hand; - the time from starting the server until
/healthanswers, and the server’s resident memory after start and after the warm requests, the median of five readings of that one process (footprint’sphys_footprinton macOS, which counts the pages the kernel compressed away, andps -o rss=elsewhere, where the resident set already counts them); - per operation (
$lookup,$validate-code,$subsumes, a small and a large$expand, and a designation search throughfilter): the first request cold, and the nearest-rank p50, p95, and p99 over the warm requests that follow, all as HTTP round trips from the same machine; - the comparison field, which states a reference server’s numbers taken on the same machine over the same release with its configuration, or says the comparison was not run.
Reproduce a record
The harness runs over releases and artifacts you are licensed for; the
repository ships none. Build the artifacts as the loading
page shows, put their paths in
bench/systems.json, and run:
$ bench/run.sh
bench/run.sh --skip-ingest measures the artifacts as they are without
rebuilding them; --only LOINC restricts the run to systems whose name
contains the text. Records land under bench/results/, one file per system
and run, named by the system and the timestamp. Close other work on the
machine during a run; the latency of a request that shares a CPU with a
compiler is the compiler’s number, not the server’s.
Run in a container
A record taken in the same container image is comparable across machines and
free of whatever else a workstation has installed. bench/compose.yaml builds
the server, the build tool, and the harness from the checkout on digest-pinned
Debian images, mounts data/ and artifacts/ read-only, and writes the records
to bench/results/:
$ docker compose -f bench/compose.yaml build
$ docker compose -f bench/compose.yaml run --rm bench --skip-ingest --only LOINC
The record marks a container run (machine.container), and on macOS or Windows
the machine it names is the Docker virtual machine, with that machine’s CPU
count and memory, so a container record from a laptop reads slower than a native
one on the same hardware. The README table says which kind it shows.
Units, and what a record refuses
The records store raw numbers: milliseconds for latency, bytes for memory and
disk, seconds for ingest and time to ready. Everything rendered from them (the
console summary, the README table) uses the unit that fits the value: seconds,
milliseconds, or microseconds for time; GB, MB, or KB for bytes. A request that
answers anything other than a 2xx status fails the run for that system and no
record is written, so a record never holds the latency of an error response. A
cell reads n/a when the system defines no such operation, for instance a
code system with no whole-system value set to expand.
The published figures
The README table, the figure tiles on the landing page, and the tables on the
site’s benchmarks page are rendered
from one committed record set, a directory under bench/records/ named by the
date and the machine, by scripts/checks/bench-table.sh render <target>; CI
runs check and fails when any of the three and the records disagree. Every
record in a set comes from the same machine, FerroTERM version, and FHIR
version, or the renderer refuses the set. A new set is a new directory copied
from bench/results/ by hand, so every published number names the run it came
from and links to its record.
The current record set
bench/records/2026-09-23-apple-m2/, taken on 2026-09-22 with the 0.1.4
release binaries on an Apple M2 with 17.18 GB, natively rather than in a
container. Nothing else of the owner’s ran while it was taken: no compiler, no
test run, no editor indexing, and Docker Desktop idle with no container
started. FerroTERM’s own processes are the only ones in the figures, one
server at a time, and the harness measures every system’s serving before it
runs any build, so no build shares a CPU with a latency measurement.
The resident and peak-build figures are the server’s and the build tool’s, not
the machine’s. Resident memory is the median of five readings of the one
server process, read with footprint so the pages macOS compressed are
counted; peak build memory is the ferroterm-build child’s peak over one
system’s release, from /usr/bin/time. The set’s own
README
carries the machine state in full, including what the one-minute load average
in each record is an average of.
| Code system | Release | Concepts | Build | Peak build memory | Index on disk | Resident | $lookup | $validate-code | $subsumes | $expand (small) | $expand (large) | Search | Snowstorm |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ICD-10-CM | 2026 | 98,827 | 1.51 s | 359 MB | 44 MB | 91 MB | 105 µs | 60 µs | 58 µs | n/a | n/a | n/a | not run |
| ICD-10-NL | 2021 | 42,769 | 880.52 ms | 221 MB | 21 MB | 66 MB | 96 µs | 60 µs | 64 µs | n/a | n/a | n/a | not run |
| ICD-11 MMS | 2026-01 | 37,211 | n/a | n/a | 44 MB | 99 MB | 138 µs | 59 µs | n/a | 129 µs | 116 µs | 139 µs | not run |
| LOINC | 2.83 | 257,266 | 8.15 s | 2.54 GB | 310 MB | 440 MB | 136 µs | 56 µs | n/a | 126 µs | 7.23 ms | 283 µs | not run |
| RxNorm (prescribable subset) | 09082026 | 81,468 | 4.58 s | 667 MB | 77 MB | 133 MB | 388 µs | 114 µs | n/a | n/a | n/a | n/a | not run |
| SNOMED CT (International edition) | 20260901 | 535,502 | 14.12 s | 3.06 GB | 559 MB | 943 MB | 217 µs | 66 µs | 59 µs | 215 µs | 1.7 ms | 353 µs | not run |
| SNOMED CT (Netherlands edition) | 20260630 | 548,949 | 17.66 s | 3.88 GB | 864 MB | 1.14 GB | 222 µs | 66 µs | 59 µs | 285 µs | 2.28 ms | 817 µs | not run |
Warm p50 over 200 HTTP round trips on one machine (Apple M2, 17.18 GB, macos/aarch64), FerroTERM 0.1.4 serving FHIR R4B, taken 2026-09-22. The records are under bench/records/; the benchmarks page has the method, the cold and tail latencies, and how to reproduce a record.
Reading the numbers
A cold request is the first of its kind on a freshly started server, before
redb’s page cache and the query caches hold anything for that path; the warm
percentiles are what a busy server answers. The resident memory is what the
server has read in: the index is loaded at startup, which is why the figure
after start and the figure after the warm requests are close. The design target
for point reads is under a millisecond in the release profile on the Dutch
edition; a millisecond-scale figure is a measurement to improve, never a result
to call fine.
$lookup reads slower on some systems than on others because its answer is
larger, not because the read is. Across the seven systems in the set, its warm
p50 fits a fixed 98 µs plus 4.6 ns for every byte of the answer, from a 862
byte ICD-10-NL answer at 96 µs to a 63,134 byte RxNorm answer at 388 µs: the
same rate on every system, and RxNorm is the largest answer rather than the
slowest read. The bar the project holds it to has that shape, 100 µs + 6.0 ns/byte in bench/bars.json, and scripts/checks/served-bars.sh checks
every record of the published set against it.
Conformance
The badges on the repository’s README show how much of the HL7 terminology ecosystem test suite each served FHIR version passes:
What the suite is
The HL7 terminology ecosystem implementation guide
publishes the test cases the FHIR Validator uses against terminology servers.
FerroTERM runs the suite’s general mode with the FHIR Validator’s txTests
command against a release build of the server, once per served FHIR version
(/r4, /r4b, /r5), on every pull request and every push to main. The
suite’s commit, its test case version, and the validator version are pinned in
docs/VERSIONS.md and in scripts/checks/tx-ecosystem.sh.
What the numbers mean
A badge reads passed / total. passed is the number of cases on the
committed pass list for that version (conformance/tx-ecosystem/passing*.txt
in the repository); total is the number of cases the suite ran. Continuous
integration fails when a listed case stops passing, and a case joins the list
in the same change that makes it pass, so the badge never says more than CI
has verified. The site regenerates the badge data from those lists on every
deploy.
The pass lists are the honest state, not a ceiling: the remaining cases and their clusters are tracked on the issue tracker, and the lists grow with the work. The suite’s other modes (ICD-11, LOINC) need licensed content and run by hand; their lists live beside the general ones.
What the numbers are not
The suite is the ecosystem’s test suite, not a certification. Passing a case means the server answers as the suite expects; it does not make the server an HL7-certified product, and HL7 issues no such certification for terminology servers. Registration in the HL7 terminology ecosystem is a separate step and will be linked from this page when it exists.
Licensing
FerroTERM is source-available under the Business Source License 1.1. The
LICENSE file in the repository is the authority; this page states the same
boundary in the order people ask about it, so you can answer “do I need a
commercial licence?” without reading the parameters.
The code systems are a separate question, answered at the bottom of this page.
Do you need a commercial licence?
| What you are doing | What you need | Why |
|---|---|---|
| Reading, building, modifying, or redistributing the source | Free | The licence grants this without a fee and without asking anyone; only production use is restricted. |
| Development, testing, evaluation, prototyping | Free | None of these is production use, which is the only thing the Additional Use Grant limits. |
| Personal use | Free | Named in the grant’s definition of Non-Commercial Purposes. |
| Academic or scientific research, or teaching | Free | Named in the grant’s definition of Non-Commercial Purposes. |
| Production use by a non-profit or public body that is not in the course of a business, does not deliver a service for payment, and is not for commercial advantage | Free | The grant’s definition of Non-Commercial Purposes, in the licence’s own words. |
| Treating patients, or delivering any other service for payment | Commercial licence | The grant ends with “any other production use, including the delivery of health care or any other service for payment, requires a commercial license from the Licensor”. |
| Any production use by a vendor or integrator | Commercial licence | A business’s production use is not a Non-Commercial Purpose, so the same closing sentence of the grant applies. |
| Offering FerroTERM, or a work derived from it, to third parties as a hosted, managed, or embedded terminology service | Commercial licence | Carve-out (a) of the grant, which the licence defines as a service through which anyone other than you and your affiliates stores, manages, or queries terminology, code system content, or health data. |
| Selling, sublicensing, or otherwise distributing it for a fee, on its own or inside another product | Commercial licence | Carve-out (b) of the grant. |
The last two rows hold whatever else you are. Hosting FerroTERM for third parties, and distributing it for a fee, need a commercial licence in every case, including for an organisation the rows above would otherwise leave free.
What the licence says
Reading, building, modifying, and redistributing the source is free, without a fee and without asking anyone, and every non-production use is covered: 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 in the course of a business, does not deliver a service for payment, and is not for commercial advantage.
Any other production use needs a commercial licence from the Licensor, including the delivery of health care or any other service for payment.
There is no open-core tier. The engine, the server, and the tools are in one repository under one licence, and nothing is held back to be sold back to you.
When it becomes Apache 2.0
Each version becomes Apache License 2.0 four years after that version is published. The clock runs per version, so the four years start at the release you are looking at rather than at the project.
The crate that is Apache 2.0 today
rf2 (the SNOMED CT release file reader) is published to crates.io under
Apache License 2.0 rather than the Business Source License, so any Rust project
can depend on it without a licence conversation. Every other crate published
from this repository carries the Business Source License and states so in its
own README. The FHIR model FerroTERM builds on, fhir-types, is published
under Apache 2.0 by the FerroBRIDGE repository.
Starting a commercial licence
A commercial licence is arranged with Cadasto B.V., the Licensor, which handles the business side of FerroTERM: write to info@cadasto.com or use https://www.cadasto.com/contact/. Technical questions go to the maintainer named in MAINTAINERS.md. Companies and care providers building on FerroTERM are wanted here, and the commercial licence is the normal path for them.
The code systems are licensed separately
The licence above covers the software. The content of each code system is licensed by its publisher: SNOMED CT by SNOMED International, LOINC by Regenstrief, ICD by the WHO, RxNorm by the NLM. The repository ships none of it, and a deployment brings the release it is licensed for. UCUM and the IANA and Unicode registries are vendored under their own licences, recorded beside the data.
Install and run
FerroTERM ships as a release tarball and as a container image. Each holds two binaries, the server and the build tool, so there is no JVM to install, no Elasticsearch to run, and no database to provision. The registry systems (UCUM, BCP 47, BCP 13, ISO 3166-1) are inside the binary; every other code system is an index you build from a release you are licensed for (Loading code systems).
- The first call, with nothing to load
- Run with Docker Compose
- Run the container by hand
- Run the binary
- Check that it is serving
The first call, with nothing to load
$ docker run --rm -p 8080:8080 ghcr.io/ferrohealth/ferroterm:0.1.8
$ curl 'http://localhost:8080/r4b/CodeSystem/$lookup?system=http://unitsofmeasure.org&code=mg/dL'
The server starts with no index, serves the four registry systems, and answers
the lookup with the unit’s canonical form. GET /r4b/metadata?mode=terminology
lists what a deployment serves.
Run with Docker Compose
Every release attaches a compose.yaml. With it and the release files of the
code systems you hold a licence for, the path is two steps: build an index for
each code system once, then serve them. Put the release files in ./releases
beside the compose file (or point FERROTERM_RELEASES_DIR at their
directory). For SNOMED CT, a licensed download from SNOMED International or
your national release centre:
$ curl -LO https://github.com/FerroHEALTH/FerroTERM/releases/latest/download/compose.yaml
$ docker compose run --rm build --rf2 /releases/SnomedCT_Release.zip --out /data/index/snomed
$ docker compose up
SNOMED CT is optional. A deployment that serves LOINC alone builds only that:
$ docker compose run --rm build --loinc /releases/Loinc_2.82.zip --out /data/index/loinc
$ docker compose up
The build service runs ferroterm-build from the same image, and the
arguments after build are its own, so every code system in
Loading code systems builds the same way: the release
directory is mounted read-only at /releases, and each --out names a child
of /data/index, which is ./index on the host (or FERROTERM_INDEX_DIR).
docker compose run --rm build with no arguments prints the builder’s help.
The release mount is read-only, so filling an ICD-11 cache with --icd11-api
(which writes into the cache) runs with the binary; a build from a filled
cache in ./releases runs here.
A zip is unpacked to the container’s tmpfs and gone with it. The service sits
under the build profile, so docker compose up never starts it. To serve
indexes you built elsewhere, skip the first command and point the variable at
an index root: a directory whose child directories are the artifacts, one per
release. A root is the layout the sync service writes (one child per release,
renamed into place when complete), so a deployment that starts with a root
needs no re-layout when the sync is added later. A single artifact directory
is accepted too (see Configuration).
$ FERROTERM_INDEX_DIR=/srv/ferroterm/index docker compose up
The file pulls the image of the release it shipped with, mounts the index
read-only at /data/index, publishes port 8080 on the loopback interface, and
runs the container with every capability dropped and a read-only root
filesystem. FERROTERM_BIND_HOST, FERROTERM_PORT, FERROTERM_LOG_FORMAT,
FERROTERM_DEFAULT_LANGUAGE, and RUST_LOG are variables you set on the
command line.
The ferroterm service declares a health check, so docker compose up --wait
returns once the server answers GET /health, and a service of your own waits
for it with depends_on: ferroterm: condition: service_healthy. The shipped
proxy service does exactly that. See
Health, metrics, and request identifiers for what the
probe does.
Run the container by hand
The image is ghcr.io/ferrohealth/ferroterm, published for linux/amd64 and
linux/arm64 with every release. It holds the static ferroterm and
ferroterm-build binaries on a distroless base: no shell, no package manager,
a numeric non-root user (65532), and the listen address preset to
0.0.0.0:8080.
$ docker run --rm -p 8080:8080 \
-v /srv/ferroterm/index:/data/index:ro \
-e FERROTERM_INDEX=/data/index \
ghcr.io/ferrohealth/ferroterm:0.1.8
Mount the index root read-only; each child directory under it is one
artifact. The server writes nothing while serving, so the
container runs with a read-only root filesystem. Tags are <version>,
<major.minor>, and latest; a deployment pins the digest and verifies its
provenance first (see Verifying releases):
$ gh attestation verify oci://ghcr.io/ferrohealth/ferroterm:0.1.8 \
-R FerroHEALTH/FerroTERM \
--signer-workflow FerroHEALTH/FerroTERM/.github/workflows/release-image.yml
The image carries a HEALTHCHECK that runs ferroterm healthcheck: the
binary probes GET /health on its own listen address, since there is no shell
or curl to do it, so docker inspect reports the container’s health and
Compose can wait on it. Kubernetes ignores the image’s HEALTHCHECK: point the
readiness and liveness probes at GET /health, set runAsNonRoot: true,
readOnlyRootFilesystem: true, drop every capability, and mount the index
root (not a single artifact) read-only at the path FERROTERM_INDEX names. The server stops
cleanly on SIGTERM.
Run the binary
Each release carries a tarball per target (x86_64 and aarch64, glibc and
musl) holding ferroterm and ferroterm-build, with a checksum, a CycloneDX
SBOM, and Sigstore attestations beside it. Download, verify, unpack, run:
$ gh release download v0.1.8 -R FerroHEALTH/FerroTERM -p 'ferroterm-v0.1.8-x86_64-unknown-linux-musl.tar.gz*'
$ gh attestation verify ferroterm-v0.1.8-x86_64-unknown-linux-musl.tar.gz -R FerroHEALTH/FerroTERM \
--signer-workflow FerroHEALTH/FerroTERM/.github/workflows/release-build.yml
$ tar xzf ferroterm-v0.1.8-x86_64-unknown-linux-musl.tar.gz
$ FERROTERM_INDEX=/srv/ferroterm/index ./ferroterm
The server opens each index read-only, refuses to start on a missing or
damaged one, and listens on 127.0.0.1:8080. The listen address, the index
directories, the FHIR resource directories, and the default display language
are environment variables described in Configuration.
Check that it is serving
$ curl http://localhost:8080/health
$ curl 'http://localhost:8080/r4b/metadata?mode=terminology'
The TerminologyCapabilities names every code system and version this
deployment serves, with its filters and properties. See
The FHIR terminology API for the operations.
The viewer
A deployment answers the FHIR terminology API, and until you write a request by
hand it tells you nothing about what it loaded. The viewer closes that gap. It
is a browser interface the server hands out at /ui, built from the same
binary and the same image, and it reads the deployment through the public API
like any other client. Editing is a second bundle at /ui/editor, so a reader
who never edits downloads none of it.
- Reaching it
- The boundary
- The packaging
- The screens
- Signing in
- Composing a value set
- History and restoring a version
- About these screenshots
Reaching it
Start the server and open /ui. The root path redirects there, so
http://localhost:8080/ lands on the overview.
$ docker run --rm -p 8080:8080 ghcr.io/ferrohealth/ferroterm:0.1.8
$ open http://localhost:8080/ui
FERROTERM_UI turns it off (Configuration). With
FERROTERM_UI=off the /ui routes are gone, and / and /ui answer the
not-found OperationOutcome that every unknown path answers, which is what
an API-only deployment wants. A build that carries no viewer bundle serves no
/ui route whatever the variable says, and prints that at start.
The viewer talks to the origin it was served from and to no other. There is no parameter that points it at a different server.
The boundary
The browser is the FHIR client. Every screen below is built from responses
to requests the server answers to anyone. The viewer declares no endpoint of
its own, holds no credential, and asks the server for nothing a client cannot
ask for. Each section of each screen carries a disclosure that reveals the
exact request it made, as a URL you can copy and run with curl. Check the
claim on any screen; you do not have to take it from this page.
The viewer only reads. The server exposes create, update, and delete on
CodeSystem, ValueSet, and ConceptMap; no screen calls them.
The packaging
One binary, one image. The bundle is compiled to WebAssembly and embedded in
the ferroterm binary at build time, so a release tarball and the container
image behave the same way and there is no directory to mount or misplace.
Nothing is rendered on the server. The document the server sends is empty until the WebAssembly bundle loads and draws the page, which has three consequences worth knowing before you deploy it: the first paint waits for that bundle, JavaScript has to be enabled, and a search engine sees nothing. The API is the answer for anything that needs those, and it is the whole product.
The screens
Every screen keeps its state in the address. A filter you typed, the page you
are on, and the FHIR version you selected are all query parameters, so a link
you copy reproduces what you were looking at, and the back button walks your
reading. You reach the screens from the sidebar on the left. The version
switcher in the top bar moves the page you are on to another root: /r4,
/r4b, /r5, or /r6, so it changes what every screen reads rather than
taking you to a screen of its own. On a narrow window the sidebar is folded
away behind the “Screens” button in the top bar.
The screenshots below come from a server loaded with one small synthetic code system, so every code and display in them is invented. Your own deployment shows the editions you built.
The command bar
Above every screen. Type a code, a canonical, or a phrase, and the viewer reads
it by its shape alone, before any request. What it offers is gated on the one
request the screen makes: the root’s own CapabilityStatement, so an operation
this root does not declare is never offered. Every offer is an address you
could have typed, so what you found is a link you can send.
Overview
What this deployment serves, rendered from
GET /{version}/metadata?mode=terminology. One table, one row per served
version, with the version an unversioned request resolves to, the content mode,
whether the root subsumes over it, and the artifact it was read from. Order it
by any of the three system-level columns; the order lives in the address.
One code system
Two documents describe a code system, and this screen shows both. The upper
pane is what the capability statement says this server can do with it. The
lower pane is the published CodeSystem resource, which describes the code
system itself. Every affordance on the other screens is gated on the upper
pane: where a version declares no hierarchy filter, no tree is offered.
Concept browser
Search a code system, read one concept, and walk what the server declares of
its hierarchy. The search is ValueSet/$expand with a filter, the concept is
CodeSystem/$lookup, and each level of the tree is one $expand over the
child filter the version declares. The tree follows the ARIA tree view pattern:
one tab stop, arrow keys to open and close a node, Enter to read the concept
under the cursor, and every move is a navigation, so a walk is a link.
Expansion runner
ValueSet/$expand by canonical, with filter, count, offset,
displayLanguage, activeOnly, and includeDesignations. The answer shows
the total, the parameters the server says it applied, and page controls that
walk the rest.
Validate and subsume
Two runners on one screen, because both answer a question about codes rather
than about a set. $validate-code takes a code and either the code system or
the value set to judge it against; the root’s capability statement decides
which levels the form offers, and where a root declares the instance level a
stored resource’s id stands in for the canonical. The answer states the
verdict, the message the server gave, the display it holds, and whether the
concept is inactive. $subsumes takes two codes in one system and states the
relation the server found between them. Running either puts its parameters in
the address, so a case is shareable by link.
Value sets
The ValueSet resources this root publishes, searched by url and version.
Opening one shows what its definition selects and links into the expansion
runner. A value set a code system defines implicitly is not published as a
resource, so it is absent here; the runner takes its canonical directly.
Concept maps
The ConceptMap resources this root publishes, searched by url and
version. Opening one shows the groups it maps and links into the translate
runner with the map already named.
Translate
ConceptMap/$translate, over the maps this root holds. Name a code and the
system it belongs to; leave the map empty and the server picks the maps it
holds for that code. The answer lists each match with the equivalence the
server stated and the map it came from.
About this server
One screen at /ui/about, in three panes, for the questions that are not about
a code. Each pane opens with the line it is there to say and keeps its detail
one press away. The addresses the three used to have still work: /ui/versions,
/ui/evidence and /ui/settings open the pane they named.
The four FHIR versions. The four served roots side by side, each read from
its own GET /{version}/metadata. One table compares what each root declares of
the terminology operations, the other what it declares of the resources. The
pane exists because the versions genuinely differ: R4 and R4B declare $lookup
at the type level and R5 added the instance level, and the viewer offers on each
screen only what the root you are on declares.
The evidence this build ships. The conformance and benchmark figures the repository commits. This is the one pane that asks the server nothing: its figures were read out of committed files when the bundle was built, so they describe the FerroTERM build your deployment is running rather than the deployment itself, and it says so and names the release version. Three summaries, each naming the file every one of its numbers came from:
- The HL7 terminology ecosystem suite, one row per mode and served root, with
the cases passed of the cases run. The counts come from the pass lists under
conformance/tx-ecosystem/, and the build stops when a list and the table in that directory’s README disagree. - The latency bars from
bench/bars.json, each with the run recorded against it and the room that run had. A bar is the claim and never moves to match a slower run. - The newest committed benchmark run under
bench/records/, one record per code system, with the machine it was taken on, the FerroTERM version that answered it, and the cold, median, and percentile timings per operation.
No number here is typed into the viewer’s source. Each one is read from its committed file at build time, so you can open that file in the repository and check it.
Settings. The FHIR base in use, the theme, the density, the default FHIR version, a display language, and the page size. These live in the browser that shows them. The server is neither asked nor told about any of them, so nothing here changes what another reader sees.
Editing a concept map
Sign in, and /ui/editor/conceptmap authors a local ConceptMap: its
canonical, version and status, the value sets that scope it, and one group per
pair of systems with the codes it maps between them. Both sides of every
mapping are picked by searching the system the group names, so a code arrives
with the display your server gave it.
The screen writes the elements the FHIR version you are on defines. On /r4
and /r4b that is source[x], target[x] and equivalence; on /r5 and
/r6 it is sourceScope[x], targetScope[x], relationship and noMap. The
relationship control offers whatever your server expands that version’s own
value set to, so nothing about the codes is compiled into the viewer.
Preview before you save. Each code carries a control that runs
ConceptMap/$translate with the map on screen sent inline, so you see what the
mapping does before anything is written. A server that does not accept a map
sent that way makes the preview fall back to the saved map, and the panel says
so.
Saving sends the whole resource with If-Match. If someone changed the map
since you opened it, the server answers 412, the screen says so in its own
words and offers to reload. A map your deployment loaded from a file opens the
same screen read-only, because the REST API is not managing it.
Signing in
The viewer is read-only until you configure an identity provider. Set
FERROTERM_OIDC_ISSUER and FERROTERM_VIEWER_CLIENT_ID
(Configuration), and the top bar gains a Sign in
control. Without both, the control does not exist and neither does any edit
control.
Signing in happens at /ui/editor, which is where the editing screens are. On
/ui the control is a link there: a token lives in the page that holds it, and
a page load ends it, so signing in on one page and editing on another would
sign you out on the way.
The viewer is a public client: it holds no secret and signs in with the
authorization code flow with PKCE against the issuer your server publishes in
[base]/.well-known/smart-configuration
(https://hl7.org/fhir/smart-app-launch/app-launch.html). Register the
redirect address https://your-server/ui/editor/callback with the identity
provider,
under the client id you set in FERROTERM_VIEWER_CLIENT_ID, and allow the
scopes openid, fhirUser, user/CodeSystem.cud, user/ValueSet.cud, and
user/ConceptMap.cud. A role that gets none of the three user/ scopes reads
the server and changes nothing.
The access token stays in the browser tab’s memory. It is never written to
localStorage and never put in a cookie, so closing the tab signs out. Signing
out drops the token and, where your issuer publishes a revocation_endpoint,
revokes it there (RFC 7009).
Composing a value set
Signed in with user/ValueSet.cud, the editor bundle’s Compose a value set
screen builds a local ValueSet.compose and saves it through the same REST API
any client uses.
A definition is a set of clauses. Every include adds to the selection and the
value set is the union of them all; the criteria inside one include are taken
together, so a code has to satisfy all of them; an exclude takes codes back out
of that union (https://hl7.org/fhir/R4B/valueset.html#compositions). The
screen says which of those each clause is doing, in a sentence under it, and
names the invariant a clause breaks by its own number: vsd-1 for a clause
that selects nothing, vsd-2 for codes or filters with no code system, vsd-3
for codes and filters at once.
A clause draws in a value set your server already publishes, picked from the
list, or any canonical you type, including a form a code system defines for
itself. It selects out of a code system with the filter properties and
operators that system’s served version declares, which the screen reads from
TerminologyCapabilities: a system declaring an expression-constraint filter
offers it here, a system declaring none offers none, and a clause pinned to a
code system version is offered that version’s filters. A filter value the
server refuses is shown with the character its diagnostic points at marked.
Own codes are picked out of the clause’s code system through a search, so a clause cannot name a code the system does not hold, and each one can carry the display this value set gives it.
Run the preview expands the definition on screen, saved or not: it posts it
to ValueSet/$expand in the valueSet parameter and pages the answer
(https://hl7.org/fhir/R4B/valueset-operation-expand.html). The page and the
text filter are in the address, so a preview is a link you can send.
Save sends the resource with If-Match, so a value set someone else
changed since you opened it is refused with 412 and the screen offers to reload
it (https://hl7.org/fhir/R4B/http.html#concurrency). An update replaces the
whole resource, so the save carries back every element the form does not draw,
from the description to a code’s own designations
(https://hl7.org/fhir/R4B/http.html#update). Every refusal is shown in the
server’s own words. Content your server serves from its loaded indexes has no
write path, so it opens here to read and offers no save under any role.
History and restoring a version
Every editing screen carries a History of this resource link once the
server holds the resource, which opens /ui/editor/history on it. The screen
lists the versions the server holds, newest first, with when each was written,
the interaction that wrote it where the answer states one, and meta.source
where your server states one. meta.source is a source system rather than a
person: FHIR keeps who changed a resource in Provenance and AuditEvent, so
the column is headed Source system and is usually empty.
How the list is read depends on what your server declares in its
CapabilityStatement. A server declaring history-instance answers the whole
list at once. FerroTERM declares vread, so the viewer reads each earlier
version back one request at a time, counting down from the version the resource
states, and the screen says so. Only the most recent versions are listed.
Compare two versions picks an earlier and a later one and lists the
elements they disagree about: the path, whether it was added, removed or
changed, and both values. It compares the two documents structurally rather
than as text, so what you read is which elements moved.
meta.versionId and meta.lastUpdated are left out, because your server
assigns both on every write and they differ between any two versions. The two
versions are in the address, so a comparison is a link you can send.
Restore this version writes that version’s whole resource back as a new
version. It states If-Match of the version the server holds now, not the one
being restored, so a change someone else made since you opened the screen is
refused with 412 and the screen offers to reload. The restore control appears
only for an account whose token carries the write scope for the resource type;
everyone else reads the same versions and restores nothing.
Seeing what a release changed
Where the synchronisation service is running and its admin listener
is reachable at the same address the viewer is served from, the screen also
lists what the newest run found about the resource you have open: the codes it
names that the activated release made inactive, removed, or pushed outside the
value set they came through. The listener authenticates nobody, so publish it
nowhere else; a reverse proxy that maps /runs on your server’s origin to the
sync service’s listener is what makes these findings visible, and without one
the screen does not show the section.
About these screenshots
Each image is a capture of a running server, taken by a browser driven through
WebDriver, over the synthetic fixture in e2e/fixtures/codesystems. The
repository distributes no SNOMED CT content and no derived edition data, and a
picture of real concepts would be exactly that, so no licensed release is ever
the subject of one.
They are refreshed by the browser battery, which captures them only when asked:
$ scripts/ui-e2e.sh --docs-shots
That builds the bundle, builds the image the release lane builds, runs it
beside a pinned Chromium, drives the journeys, and then writes one PNG per
screen into website/book/src/operate/img/viewer. Without --docs-shots the
battery changes no file in the checkout, which is why an ordinary run on a pull
request never rewrites an image here.
Configuration
The server reads its configuration from environment variables at start. There
is no configuration file and no command-line flag; a container sets the
variables, a host sets them in the service unit. ValueSet/$expand returns
at most 1,000 members without count and asks for paging beyond that
(too-costly); the limit is not configurable.
- The variables
- What the server logs
- Reloading the served set
- Index roots
- A refused artifact stops the start
- The registry systems
- What a code system version is served as
- What you do not configure
The variables
| Variable | Meaning | Default |
|---|---|---|
FERROTERM_INDEX | The artifact directories to serve, one per code system version (a SNOMED CT edition, a LOINC release, an ICD-10 classification, an RxNorm release, or one of the three ICD-11 code systems; the manifest says which), separated by the platform’s path separator (: on Linux). Each holds the store.redb with its store.*.col column files, hierarchy.bin, text.bin, and manifest.json that ferroterm-build wrote. The server opens them read-only and refuses to start when one is missing, damaged, or duplicates another’s system version. A path that holds no manifest.json of its own is an index root: every child directory that holds one is an artifact, and the server lists the root again on every reload (see Index roots). | none (the server starts with no code systems) |
FERROTERM_CODESYSTEMS | Directories of FHIR CodeSystem resources to serve, separated by the platform’s path separator: a FHIR package’s package/ directory (HL7 Terminology, for example) or a directory of CodeSystem JSON files. Files are read in the FHIR version the directory’s package.json declares, or as R4B when there is none. A resource with content = supplement is applied to the system it supplements and is read and searched as a CodeSystem of its own; the server refuses to start when that system is not loaded. ValueSet resources in the same directories are served by url and version for ValueSet/$expand and ValueSet/$validate-code (without a version, the greatest version answers). | none |
FERROTERM_RESOURCES | The database file holding the CodeSystem, ValueSet, and ConceptMap resources clients write through the REST API, and the closure tables $closure maintains. The server creates the file when it does not exist, serves every resource in it exactly as it serves one from FERROTERM_CODESYSTEMS, and loads them again after a restart. A deployment that names no file refuses every write with a 422 and declares no write interaction in its capability statement. | none (the server persists nothing) |
FERROTERM_LISTEN | The socket address to bind. | 127.0.0.1:8080 (the container image sets 0.0.0.0:8080) |
FERROTERM_ADMIN_LISTEN | The socket address of the admin listener, which serves POST /reload and nothing else (see Reloading the served set). Without FERROTERM_OIDC_ISSUER it authenticates nobody, so bind it to an address only your operators reach. Unset, the server binds no second listener and SIGHUP is the only reload trigger. | none (no admin listener) |
FERROTERM_BASE_URL | The URL clients reach this server at, without a version prefix and without a trailing slash (https://tx.example.org). Each version states it as implementation.url in its capability statements, so a client behind a reverse proxy learns the address it should use rather than the socket the process bound. Setting it also turns on the audience check of the SMART gate, whose accepted set is every served version’s base under this URL (see Behind a reverse proxy). A deployment that names none states no URL. | none |
FERROTERM_SECURITY_SERVICE | The authentication in front of the server, as comma-separated codes of the FHIR restful-security-service value set (OAuth, SMART-on-FHIR, Basic, Certificates, Kerberos, NTLM). The codes declare what a proxy enforces, in CapabilityStatement.rest.security.service; with FERROTERM_OIDC_ISSUER set, SMART-on-FHIR is added to whatever you declare here. | none |
FERROTERM_OIDC_ISSUER | The OpenID Connect issuer whose access tokens gate the write routes and the admin listener (see Behind a reverse proxy). The server reads {issuer}/.well-known/openid-configuration and the JWKS it names before it binds, and refuses to start when either does not answer. Map a terminologist’s role in that issuer to user/CodeSystem.cud, user/ValueSet.cud, and user/ConceptMap.cud, and a reader’s role to none of them, so a reader’s token carries no write scope at all. Unset, the server asks for no token and every route answers as it does today. | none (no token is ever asked for) |
FERROTERM_OIDC_AUDIENCE | One more aud value the server accepts, beside the FHIR base of every served version it derives from FERROTERM_BASE_URL. Set it when your issuer mints an audience of its own, a resource identifier registered in the issuer, for example. A token passes when its aud, a string or an array, holds any accepted value, and is refused with 401 invalid_token otherwise (see Behind a reverse proxy). With neither this nor FERROTERM_BASE_URL set, the server checks no audience and the issuer alone bounds the token. | none |
FERROTERM_OIDC_ADMIN_SCOPE | The scope the admin-listener routes require, compared verbatim against the scopes the token grants. Map an operator’s role to it and leave it off a terminologist’s and a reader’s roles. No specification governs the admin surface, so the name is yours. | ferroterm/admin |
FERROTERM_VIEWER_CLIENT_ID | The OAuth client the viewer signs in as. The viewer is a public client: it holds no secret, signs in with the authorization code flow with PKCE, and learns this value from [base]/.well-known/smart-configuration, where the server publishes it as ferroterm_viewer_client_id (RFC 8414 §2 admits the extra member). Register the client in the same issuer FERROTERM_OIDC_ISSUER names, with the redirect address {base}/ui/editor/callback (the editing bundle is where the viewer signs in), no client secret, S256 as a code challenge method, and the scopes openid, fhirUser, user/CodeSystem.cud, user/ValueSet.cud, user/ConceptMap.cud. Unset, the viewer offers no sign-in and draws no edit control. | none (the viewer stays read-only) |
FERROTERM_UI | Whether the server serves the viewer at /ui, the editing bundle at /ui/editor, and redirects / onto the first. Reads on or off (true/false, 1/0, yes/no). Off, the /ui routes are absent and both / and /ui answer the not-found OperationOutcome every unknown path answers, for a deployment that wants an API-only surface. A build that carries no viewer bundle serves no /ui route whatever this is set to, and says so at start. | on |
FERROTERM_USAGE_REPORT | Whether the server sends the usage report to FerroPULSE. Reads on or off (true/false, 1/0, yes/no). on cannot override CI detection: with GITHUB_ACTIONS or CI set to true or 1, the report is off. | on |
FERROTERM_USAGE_REPORT_ENDPOINT | The collector the usage report is posted to: an https URL without credentials, or plain http to a loopback host. The server refuses to start on anything else. | https://report.ferropulse.eu/v1/report |
FERROTERM_STATE_DIR | A writable directory where the server keeps its own state: the usage report’s instance id and the time of its last start report. Without one, or with one the server cannot write, the usage report is off. | none (the compose file sets /data/state, on a named volume; the container image sets none) |
FERROTERM_DEPLOYMENT | How the server was deployed, as the usage report states it: helm, compose, binary, or unknown. The compose file sets compose. | unknown |
FERROTERM_DEFAULT_LANGUAGE | The BCP 47 language used for display when a request names none. | en |
FERROTERM_LOG_FORMAT | auto, json, or pretty. json writes one object per line (timestamp, level, message, and the fields) for a log pipeline; pretty writes aligned human lines with colour; auto picks pretty when stdout is a terminal and json otherwise. The startup banner prints only with pretty. | auto |
RUST_LOG | The tracing filter. | info,hyper=warn,tower=warn,h2=warn |
What the server logs
At start: the banner (pretty only), one ferroterm starting line with the
version, the served FHIR versions (fhir_versions), and the count of code
systems, one serving code system line per loaded version (id, system,
version, concepts, languages, path), one listening line with the
address and the base path of every served version (bases), and one usage report ON or usage report OFF line (see Usage report).
Per request: one
request line with method, route, status, latency_ms, and the
system, url, version, code, codeA, codeB query parameters the
request named (named); bodies and free-text parameters are never logged. A
client error logs at warn, a server error at error. On SIGTERM or
SIGINT: the signal, then ferroterm stopped. A refused start is one
cannot start line with the reason. On a reload: the served set was reloaded with the count of code systems, or the served set was not reloaded at error level with the reason.
Reloading the served set
The server reads the directories again and swaps the whole served set on two triggers:
SIGHUPto the process (kill -HUP <pid>,docker kill --signal=HUP <name>).POST /reloadon the admin listener, whenFERROTERM_ADMIN_LISTENnames an address.
$ curl -sS -X POST http://127.0.0.1:8081/reload
{"outcome":"ok","systems":[{"id":"snomed.info-sct-…","system":"http://snomed.info/sct","version":"…"}]}
A reload opens every artifact in FERROTERM_INDEX and every resource in
FERROTERM_CODESYSTEMS again, read-only, builds a new registry, and exchanges
a pointer. A request already in flight finishes on the set it started on, and
that set is dropped when the last request holding it ends, so the memory of the
swapped system is resident twice for a few seconds. FERROTERM_RESOURCES is
live already and is carried over untouched: the resources clients wrote stay
written, and the new set serves them over the new artifacts.
An artifact is replaced by building it in a new directory and renaming that
directory over the configured path, which is one atomic step and leaves the
old files in place for the handles still reading them. Writing into the
directory the running server is reading corrupts what it reads. A directory in
FERROTERM_CODESYSTEMS is different: add or remove a resource file in it, and
the reload picks up the change.
Adding or removing an artifact PATH means changing FERROTERM_INDEX, which
the process reads once at start, so that still takes a restart. Adding or
removing a release inside an index root does not, because a reload lists every
root again.
The admin listener carries POST /reload and nothing else and answers 404 to
anything else. It authenticates nobody unless FERROTERM_OIDC_ISSUER is set,
in which case every route on it requires a bearer token carrying
FERROTERM_OIDC_ADMIN_SCOPE. Bind it to a loopback or internal
address (127.0.0.1:8081), keep it off the address your clients reach, and
leave it out of the reverse proxy that publishes the FHIR surface (see
Behind a reverse proxy). The FHIR listener never serves
/reload: that path is the not-found OperationOutcome any unknown path
answers.
Index roots
The server reads each path in FERROTERM_INDEX in one of two ways:
- An artifact path holds a
manifest.jsonof its own. The server opens that directory as one code system version. - An index root holds no
manifest.json. The server lists it and opens every child directory that holds one, in the order their names sort. A child without a manifest is passed over, as is a file beside the children.
/srv/ferroterm/snomed-nl/ FERROTERM_INDEX names this root
20260331/ an artifact: the release in production
20260930/ an artifact: the release that followed it
.incoming-20261031/ no manifest yet, so the server passes it over
Every reload lists the roots again, so a release directory that appeared since the last scan is opened and one that disappeared is dropped. That is what lets a synchronisation job publish a release beside the running one and keep the previous release for a rollback. Both are served, and a request that names no version still resolves to the greatest one, so publishing a release is what promotes it. A retired release keeps answering until you remove its directory and reload.
Write a release into a root in two steps: build or download it into a directory
outside the root, then rename it in. rename is one atomic step
(https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html), so
a reload sees the new directory whole or not at all. The manifest is the marker
that makes the directory an artifact, and ferroterm-build writes it after
everything else, so a build straight into a root is picked up only once it has
finished.
A root that holds no artifact serves nothing and logs the index root holds no artifact. A path that names nothing at all still refuses the start, and a
reload that meets one keeps the old set answering.
A refused artifact stops the start
An artifact the server cannot open stops the whole start. Nine good artifacts
and one damaged one serve nothing, and the cannot start line names the
directory and the reason.
That is deliberate. A server that dropped the damaged one and served the other nine would answer every code of the tenth system with “not found”, and a caller cannot tell that from “this code does not exist”. A terminology answer that is wrong and confident is worse for a clinical system downstream than a server that did not start, so the refusal is loud and total.
The practical consequence for an operator is that a rebuilt artifact is swapped in before a restart, not after one. The practical consequence for a benchmark or a conformance run is that it fails at once, naming the artifact, rather than reporting a figure taken over a system nothing served.
A reload holds the same bar without the downtime. One artifact that does not
open refuses the whole swap: the server keeps serving the set it already had,
logs the served set was not reloaded with the reason, counts
ferroterm_reloads_total{outcome="failed"}, and answers the admin request
with a 500 naming the artifact. A half-applied release never reaches a
client. A child directory under an index root is an artifact like any other
here: once it holds a manifest the server cannot read, it refuses the reload
rather than serving the root without it.
The registry systems
Four systems ship with the server and are served without configuration: BCP
47 language tags (urn:ietf:bcp:47), BCP 13 media types (urn:ietf:bcp:13),
UCUM units (http://unitsofmeasure.org), and ISO 3166-1 country codes
(urn:iso:std:iso:3166). The first three are grammars over their registries
and cannot be expanded; the fourth is a table from Unicode CLDR. Their data
is vendored in the binary.
What a code system version is served as
Each loaded code system version becomes one CodeSystem instance. A resource
that carries an id keeps it: the id is the logical id the resource is read
by, so a client that knows it reads the resource there
(id). The server mints an id only
for a version that arrives without a resource, an index built from an RF2
release or a LOINC delivery, for example: the version URI when that URI carries
the system (a SNOMED CT edition is
snomed.info-sct-<module>-version-<YYYYMMDD>), otherwise the system URL and
the version (terminology.hl7.org-CodeSystem-v2-0001-2.0.0), reduced to the
FHIR id alphabet. The instance reads at [base]/r4b/CodeSystem/<id>, is found
by [base]/r4b/CodeSystem?url=…&version=…, and takes an instance-level
operation at [base]/r4b/CodeSystem/<id>/$validate-code. The id is stable for
a given version and is listed at startup and in
GET /r4b/metadata?mode=terminology.
A ValueSet or ConceptMap in a FERROTERM_CODESYSTEMS directory becomes an
instance the same way: its own id when it has one, else one minted from its
url and version. It reads at [base]/r4b/ValueSet/<id> or
[base]/r4b/ConceptMap/<id>, is found by [base]/r4b/ValueSet?url=…&version=…
or the matching ConceptMap search, and carries that id as its Resource.id in
both answers, so a client can read what a search returned. Every served version
answers both.
Two ids never compete. An id outside the FHIR id alphabet (at most 64 of
A-Z, a-z, 0-9, -, and .,
id) refuses the load, and so does
a second loaded resource of the same type carrying an id the first one holds, or
a loaded id a resource written through the REST API already answers on. The
diagnostic names the canonicals involved; the server never renames a resource to
make room. From the other side, a PUT or POST onto an id a loaded resource
is read at is refused with 409 Conflict, so a client cannot shadow what the
deployment serves.
What you do not configure
You do not configure a database connection, a search cluster, or a JVM heap.
FerroTERM has none of these. The inputs the server needs are the built indexes and any
FHIR resource directories, and you build the indexes offline with
ferroterm-build as described in Loading code systems.
Loading code systems
FerroTERM serves the code systems you load. Each arrives as the release its
owner distributes, turned once into an index by ferroterm-build, and served
read-only. This page covers the licensing rule, the build step, and the
command for every supported source; the sections below name each system.
- You bring your own content
- The offline build
- Loading a SNOMED CT edition
- Loading LOINC
- Loading ICD-10 and ICD-10-CM
- Loading ATC
- Loading the DHD thesauri
- Loading the G-Standaard product ladder
- Loading the Nederlandse Labcodeset
- Loading the NHG ICPC-1 to SNOMED CT map
- Loading RxNorm
- Loading ICD-11
- Test content
- Derivative reference set packages
You bring your own content
Warning
This repository ships no code system content, and no build of FerroTERM contains any. SNOMED CT is licensed by SNOMED International, LOINC by Regenstrief, ICD by the WHO, RxNorm by the NLM. You must hold whatever licence the release you load requires.
The split is firm: the software in this repository is under the Business Source License 1.1, and the code system content is the property of its owner and is not distributed here. A SNOMED CT licence is free within member countries, the Netherlands among them, and available under the affiliate licence elsewhere; LOINC needs a free account; ICD-10-CM and the RxNorm prescribable subset are public downloads; ICD-11 comes through the WHO’s own container under its licence.
The offline build
The running server never parses a release. ferroterm-build turns it into the
index once, and the server opens the index read-only and reads it in at
startup. The tool ships in every release tarball beside ferroterm and in the
container image.
graph LR
REL["A release you are licensed for"] --> BUILD["ferroterm-build (offline)"]
BUILD --> IDX["index: store.redb + its column files + hierarchy.bin + text.bin + manifest.json"]
IDX --> SRV["ferroterm (read-only)"]
Every source builds into the same layout, and the manifest names the system,
so FERROTERM_INDEX takes any mix of them. When a new release arrives, rebuild
its index and restart the server against it.
Loading a SNOMED CT edition
The command takes the release zip as SNOMED International or your national release centre distributes it, or the unpacked release directory, and writes the index:
$ ferroterm-build --rf2 /path/to/SnomedCT_Release.zip --out /path/to/ferroterm-index
From a zip, only the Snapshot/ tree is unpacked, to a temporary directory
that is removed when the build ends; the Full and Delta trees are never read.
With the container image the same build is one compose command (see
Install and run). The build reads the concepts, descriptions,
language reference sets, and inferred relationships, computes the transitive
closure, and writes the store, the hierarchy, and the text index. The
benchmarks page records how long each release
takes and how large its index is.
The edition and version come from the release itself (the module and
effective time), and $lookup returns them as the version URI
(http://snomed.info/sct/11000146104/version/20260630).
Loading LOINC
The same tool builds a LOINC release into the same artifact layout. Download the release from https://loinc.org/downloads/ (a free account; the licence allows use and redistribution with the LOINC copyright notice, and the repository ships no release) and build it:
$ ferroterm-build --loinc /path/to/Loinc_2.82.zip --out /path/to/loinc-index
The zip’s term table, parts, hierarchy, answer lists, and linguistic variants
are unpacked to a temporary directory; the part-link tables are not read. The
version is taken from the release name (Loinc_2.82), else pass
--loinc-version. Point FERROTERM_INDEX at the directory beside your SNOMED
CT index; the server opens each by the system its manifest names.
Loading ICD-10 and ICD-10-CM
The same tool builds the ICD-10 family. A ClaML document (WHO ICD-10 from your WHO licence, the Dutch translation from the WHO-FIC Collaborating Centre at RIVM, or any other ClaML classification) is served under the system URI you name:
$ ferroterm-build --claml /path/to/icd10nl-2021.xml \
--system http://hl7.org/fhir/sid/icd-10-nl --out /path/to/icd10nl-index
The version comes from the document’s Title; pass --claml-version when
the title has none. A zip is accepted; its largest .xml entry is the
document.
ICD-10-CM is a free download from CMS (https://www.cms.gov/medicare/coding-billing/icd-10-codes): the “Code Descriptions in Tabular Order” zip holds the order file and the “Code Tables, Tabular and Index” zip holds the tabular XML. Give both:
$ ferroterm-build --icd10cm 2026-code-descriptions-tabular-order.zip \
--icd10cm 2026-code-tables-tabular-and-index.zip --out /path/to/icd10cm-index
Codes come out with the period (A00.0, S02.0XXA); chapters are named by
their range (A00-B99); the order file’s header flag is the valid
property. Point FERROTERM_INDEX at each directory as for LOINC.
Loading ATC
The WHO Collaborating Centre sells the ATC/DDD index as a spreadsheet; export
it to CSV (any of comma, semicolon, or tab) with its columns ATC code,
ATC level name, DDD, U, Adm.R, and Note, and build it with the index
year as the version:
$ ferroterm-build --atc /path/to/atc-index-2026.csv --atc-version 2026 --out /path/to/atc-index
A G-Standaard subscriber builds the same tree from the BST801T file (the
Dutch and English names, no DDDs):
$ ferroterm-build --atc /path/to/BST801T --atc-version 2026 --out /path/to/atc-index
The system is http://www.whocc.no/atc; the five levels are the kind
property and filter; every DDD is a ddd property.
Loading the DHD thesauri
A DHD licensee receives the Diagnosethesaurus and the Verrichtingenthesaurus as zips of CSV tables (“Uitleverformaat 5.0”). Build a delivery as it comes; the version is read from the zip name:
$ ferroterm-build --dhd 20260901_120000_Diagnosethesaurus_2.40_uitleverformaat_5.0.zip \
--out /path/to/dhd-diagnoses
The system is urn:oid:2.16.840.1.113883.2.4.3.120.5.1, a flat table: the
preferred term is the display, synonyms and patient-friendly terms are
designations, and the SNOMED CT identifier, the ICD-10, DBC, and ZA
derivations, the roles, and the umbrella terms are properties. Concepts ended
before the delivery date are served inactive. The build also writes two
ConceptMap resources under conceptmaps/ in the output directory (the
thesaurus to SNOMED CT and to ICD-10); point FERROTERM_CODESYSTEMS at that
directory to serve them with $translate. Pass --dhd-version when the zip
name does not carry the version.
Loading the G-Standaard product ladder
A Z-Index subscriber builds the GPK, PRK, HPK, and article code systems from the monthly release directory in one run; the release is the version:
$ ferroterm-build --gstandaard /path/to/g-standaard/202609 --gstandaard-version 202609 \
--out /path/to/gstandaard
Four artifact directories appear under the output (gpk, prk, hpk,
artikel), one urn:oid system each; list them all in FERROTERM_INDEX.
Each is a flat table: the full name from the names file is the display, the
short and label names are designations, and the rungs above a concept (gpk,
prk, hpk), the ATC code, form, route, brand, and firm are properties.
Loading the Nederlandse Labcodeset
A Labcodeset licensee receives one XML publication per release. Build it as it comes; the release date is read from the document:
$ ferroterm-build --labcodeset Labcodeset_v2026-08.zip --out /path/to/labcodeset
The build writes a directory of FHIR resources under labcodeset/ in the
output directory: the value set of the active concepts over LOINC
(https://ferroterm.eu/fhir/ValueSet/nl-labcodeset, Dutch displays, English
designations), a LOINC supplement carrying the Dutch names and the
publication’s facts as properties (materials, units, outcome lists, statuses,
replacements), and one value set per ordinal outcome list under its OID
(urn:oid:…). Point FERROTERM_CODESYSTEMS at that directory beside a LOINC
artifact in FERROTERM_INDEX; the SNOMED CT materials and outcome refsets
resolve when a SNOMED CT edition is loaded too. Retired concepts stay in the
supplement, marked labcodeset-status = retired, and leave the value set.
Loading the NHG ICPC-1 to SNOMED CT map
Nictiz and the NHG distribute the ICPC-1 (NHG table 24) to SNOMED CT mapping
as a FHIR R4 ConceptMap. Put the file in a directory with a package.json
naming its FHIR version, and point FERROTERM_CODESYSTEMS at it:
{"name": "nl.nictiz.nhg-icpc-snomed", "version": "2026-03-31", "fhirVersions": ["4.0.1"]}
$translate then answers from http://hl7.org/fhir/sid/icpc-1-nl to SNOMED
CT on every version endpoint. The 2026-03-31 release writes experimental as
the string "false"; FHIR JSON requires a boolean there
(https://hl7.org/fhir/R4/json.html), and the server refuses the file until
it reads "experimental": false.
Loading RxNorm
The “Current Prescribable Content” subset needs no licence (https://www.nlm.nih.gov/research/umls/rxnorm/docs/prescribe.html); the full monthly release needs a UMLS licence. Both have the same shape:
$ ferroterm-build --rxnorm RxNorm_full_prescribe_09082026.zip --out /path/to/rxnorm-index
The version is the release date from the readme inside the zip (09082026);
pass --rxnorm-version when the release has none. The build keeps the names
of the unrestricted sources (RXNORM, MTHSPL). A full release carries
sources the UMLS licence restricts by category; name the ones your licence
covers with --rxnorm-sources MSH,VANDF to serve their names too. Point
FERROTERM_INDEX at the directory as for LOINC.
Loading ICD-11
ICD-11 comes from the WHO ICD-API. Run the local deployment WHO publishes (https://icd.who.int/icdapi; the licence, CC BY-ND 3.0 IGO, allows the local copy but not passing it on), then let the build walk it into a cache and build the three code systems:
$ docker run -d -p 8080:80 -e acceptLicense=true -e saveAnalytics=false \
-e include=2026-01_en whoicd/icd-api
$ ferroterm-build --icd11 /path/to/icd11-cache --icd11-api http://127.0.0.1:8080 \
--icd11-release 2026-01 --icd11-languages en --out /path/to/icd11-index
The cache holds one JSON file per entity and language (about 110,000 files
for the MMS, the ICF, and the Foundation) and is reused by later builds, so
the API is only needed once per release. The build writes
icd11-index/mms, icd11-index/icf, and icd11-index/entity; point
FERROTERM_INDEX at each of the three. Languages beyond English need the
deployment to include them (include=2026-01_fr); the build records every
language the cache holds.
Test content
FerroTERM’s own tests use shaped, synthetic content only. They never contain real content extracted from a release, which keeps the licence line clean in the repository itself.
Derivative reference set packages
SNOMED International publishes packages that carry only a module and its reference sets, over an edition they depend on. The ICNP Nursing Practice package is one: two concepts, a reference set of about two thousand International Edition concepts, and a module dependency naming the edition it was authored against.
Layer one onto the edition it depends on with --rf2-refset, repeatable:
$ ferroterm-build --rf2 /path/to/SnomedCT_InternationalRF2.zip \
--rf2-refset /path/to/SnomedCT_ICNPNursingPractice.zip \
--out /path/to/ferroterm-index
The package’s concepts, descriptions, and language reference set members join
the edition’s, and its reference sets serve as implicit value sets of the
edition, so http://snomed.info/sct?fhir_vs=refset/1157358007 expands to the
ICNP members. The manifest records what was layered.
Each package’s module dependency is checked against the edition before anything is read. An edition at or newer than the date a package was authored against meets the dependency; an older one does not and the build refuses, naming the module and both dates. A package naming a module the edition does not contain is refused the same way.
Syncing from a national terminology service
A national terminology service publishes its releases in an Atom syndication feed: one entry per downloadable item, with the canonical identifier, the version, the date, and a digest. Some services keep their FHIR resources behind their FHIR API instead, and the sync lists that API as if it were a second feed. The FerroTERM sync service reads both on a schedule, takes what you subscribed to, builds or stages it, and asks the server to reload. It runs beside the server and never inside it, and it moves content in one direction only.
What one run does
- It reads each configured feed, and the FHIR API of a source that has one
(a search per
CodeSystem,ValueSet, andConceptMap, paged to the end), and compares what they offer with what you already serve: the releases under your index root, the resources in your managed directory, and the ledger of what the service itself put there. - It takes the entries you subscribed to that you do not hold yet, streams each one to the staging directory, and refuses any whose digest does not match what the feed advertised. A resource read from a FHIR API carries no advertised digest; the run records the digest of what arrived instead.
- An RF2 archive goes through
ferroterm-buildinto a staging directory and is renamed into the index root as a new release beside the previous one. A FHIR resource is written as a file in the managed resource directory, corrected first when its service needs a correction. - It asks the server to reload, then prunes the releases retention no longer keeps and tells the server about that too.
- It writes one JSON record of everything above and posts a summary to your webhook, whether the run worked or not.
A run that fails leaves the served set exactly as it was. A build that fails
never reaches the index root, and a reload the server refuses is rolled back,
including a resource file the run had replaced. A delivered resource is served
at the id it carries, so a delivery whose id another loaded or persisted
resource of the same type already answers on is one of the reloads the server
refuses (see
What a code system version is served as). Nothing is deleted except by
retention, and the server’s own write store (FERROTERM_RESOURCES) is never
touched.
Running it
The service ships as its own image, carrying ferroterm-sync and
ferroterm-build, and the Compose overlay runs it beside the server over one
volume:
FERROTERM_SYNC_DATA=/srv/ferroterm FERROTERM_SYNC_CONFIG=/srv/sync.toml \
docker compose -f compose.yaml -f compose.sync.yaml up
One directory on one filesystem holds index/, codesystems/, staging/,
records/, and state/. That is not a convenience: a run stages a release and
then renames it into the index root, and a rename works only inside one
filesystem. The server mounts index/ and codesystems/ read-only; the sync
mounts the whole tree and writes to it as uid 65532.
The overlay also sets FERROTERM_ADMIN_LISTEN on the server, because
POST /reload on that listener is how the sync asks for the new release to be
served.
ferroterm-sync run-once performs a single run and exits, which is what a
first run and a cron-style deployment use.
Configuration
One TOML file, /etc/ferroterm/sync.toml unless --config says otherwise.
Every key is optional and falls back to the value below; a key the service does
not know is refused at start-up rather than ignored.
| Key | Default | What it names |
|---|---|---|
listen | 127.0.0.1:8181 | the service’s own admin listener |
server_admin_url | http://127.0.0.1:8081 | the server’s admin listener, which serves POST /reload |
fhir_base_url | none | the server’s FHIR base, which the revalidation reads |
index_root | /data/index | the index root the server reads |
resources | /data/codesystems | the managed resource directory the server reads |
staging | /data/staging | where a run downloads and builds |
records | /data/records | one JSON record per run |
state | /data/state | what the service remembers between runs |
retention | 2 | release directories kept per code system |
activation | auto | auto or manual |
webhook_url | none | where one JSON summary per run is posted |
timezone | UTC | the zone a daily schedule is read in |
build_command | ferroterm-build | the offline build, on PATH or by path |
A [[source]] entry names the add-on that reads a service and carries that
add-on’s own block:
server_admin_url = "http://ferroterm:8081"
fhir_base_url = "http://ferroterm:8080/r4b"
index_root = "/data/index"
resources = "/data/codesystems"
staging = "/data/staging"
records = "/data/records"
state = "/data/state"
retention = 2
activation = "auto"
timezone = "Europe/Amsterdam"
[schedule]
at = "03:00"
[[source]]
kind = "nts"
[source.config]
systems = ["http://snomed.info/sct/11000146104", "http://loinc.org"]
An add-on whose service keeps resources behind its FHIR API lists that API by
default; the NTS block takes fhir_api = false to keep a run to the feed and
fhir_api_url to point at another endpoint.
The file carries no credential. A source add-on reads its credentials from a file or from the environment, so this file is safe to commit and safe to show.
The schedule
Two shapes, and no cron syntax:
every = "24h"measures from the end of the last run.at = "03:00"is a time of day intimezone.
With both, the earlier due time wins. With neither, the service runs only when you ask it to. The end of the last run is written to the state directory, so a restart between two runs does not replay the run that already happened: a service that ran at 03:00 and restarts at 04:00 is next due at 03:00 the following day. A window the service was down for is due at once.
A run also starts on POST /run on the admin listener, and as
ferroterm-sync run-once.
Activation
In auto the run renames what it built into the index root and asks the server
to reload, in the same run. In manual the run stops after staging: nothing is
served, the run record says how many items wait, and POST /activate puts them
in front of the server when you are ready. What is staged survives a restart,
and a later run does not build it again.
An activation is not a synchronisation run: it gets its own run record, and it leaves the schedule where it was, so the next scheduled run still starts at its own time.
What a new release did to your own content
A release can retire a code your own value set enumerates, remove it, or leave
it outside the value set it was included through. After a release is activated
the service revalidates every locally authored ValueSet and ConceptMap
against the set now being served and writes what it found into the run record.
It reports and never edits. A finding is a decision for a terminologist, and the service changes nothing in your content, ever.
Set fhir_base_url to the server’s FHIR base, such as
http://ferroterm:8080/r4b, to turn the check on. Without it the run record
says the check did not run. The check reads the server’s public FHIR API,
ValueSet/$validate-code and ValueSet/$expand, both with activeOnly, so it
sees exactly what any client sees:
- a code that is still a member but is no longer active is
inactive; - a code the code system no longer carries is
absent; - a code that is still in the code system and no longer falls inside the value
set is
outside-value-set.
Each finding names the resource, the system, the code, and the release this run
activated for that system. A run that finds nothing says so in one sentence,
and that sentence and the finding count travel in the webhook summary.
/metrics carries ferroterm_sync_revalidation_findings as a gauge per
locally authored resource, so a resource that is clean reads zero rather than
disappearing.
The check reads the first 1000 codes of a resource and expands at most 1000 members, which keeps one run bounded; a larger resource is noted in the log.
Retention
After a successful reload the service keeps the newest retention release
directories per code system and removes the rest, then asks the server to
reload once more so the removed release leaves the served set. A release that
is still there is what you roll back to, so keep at least two. A retention of
0 removes nothing. Each run record says what was removed, how many bytes it
held, and how many bytes the index root uses afterwards.
Retention judges only directories that carry a manifest.json, so a build in
progress is never touched, and it groups them by the code system edition the
manifest names.
The admin listener
The service binds listen and serves six routes:
| Route | What it does |
|---|---|
POST /run | performs one run and answers its summary when the run has finished |
POST /activate | puts what is staged in front of the server |
GET /runs | the runs this service performed, newest first |
GET /runs/{id} | one whole run record |
GET /metrics | the Prometheus exposition |
GET /health | 200 while the process is up |
It authenticates nobody, exactly like the server’s admin listener. Keep it on an internal network, and never publish it. The Compose overlay leaves both listeners unpublished, reachable only inside the compose network.
The viewer’s history screen reads GET /runs and GET /runs/{id} to show what
the last run found about the resource a terminologist has open
(The viewer). It is a browser, so it reaches only the address it
was served from: a deployment that wants those findings on screen maps /runs
on the server’s own origin to this listener in the reverse proxy in front of
it, and keeps the rest of the listener unreachable. A deployment that maps
nothing shows no findings, which is the default.
/metrics carries ferroterm_sync_runs_total{outcome="ok"|"failed"},
ferroterm_sync_last_run_timestamp_seconds,
ferroterm_sync_entries_taken_total, ferroterm_sync_bytes_staged_total,
ferroterm_sync_index_bytes, and ferroterm_sync_revalidation_findings per
locally authored resource.
What a run record contains
One JSON file per run under records, named by the run identifier, which sorts
by time. It carries:
- the trigger, the start and end, the duration, and the outcome;
- per source: the feed address, how many entries it offered, the FHIR API
address and how many resources it listed when the source has one, what was
taken (with its origin, feed or FHIR API, the bytes, the download time, the
build time, and the corrections that were applied), what was left behind
with the reason for each one, such as a system you did not subscribe to, a
release you already serve, a delta rather than a snapshot, Ontoserver’s
binary index, or a
CodeSystemwhose content isnot-present, and the subscribed canonicals no listing carried at all, which means the account could not see them and never that the service lacks them; - the activation: the mode, what reached the server, what is still staged, what the server answered to each reload, and whether the run was rolled back;
- the revalidation: how many locally authored resources were read and how many codes were checked, one sentence saying what it found, and a line per local code the release made inactive, removed, or left outside its value set;
- what retention removed and how many bytes the index root uses;
- every error, in the order it happened.
The same summary is what the webhook receives: one POST per run end, on success and on failure. A delivery that fails is logged and never fails the run.
NTS (Nationale Terminologie Server)
The NTS
is the Dutch national terminology service, run by Nictiz. It serves FHIR R4 and
publishes its content at
https://terminologieserver.nl/synd/syndication.xml. The feed is challenged:
the listing itself answers 401 with a bearer challenge, so the add-on
authenticates before it can even see what is on offer.
The account and the licences
You need a personal account on the NTS. Nictiz issues it after you accept its terms, and each licensed system is unlocked per account. A run with an account on 2026-09-25 (issue #602) showed how that scoping looks from the outside:
- The syndication feed listed 23 entries, all of them Ontoserver binary
indexes of the SNOMED CT Netherlands edition (releases 2023-03-31 through
2026-08-31, 1.4 to 1.6 GB each), each marked
onto:permissionsnomed.read. It carried no RF2 archive and no FHIR resource or package, so the feed alone gives the sync nothing it can build or serve. The FHIR content lives behind the FHIR API of the same service (1,024CodeSystem, 1,593ValueSet, and 45ConceptMapresources for that account), which the add-on lists beside the feed. The SNOMED CTCodeSystemresources there carrycontentnot-presentand are reported, never served. - The feed identifies SNOMED CT as
http://snomed.info/sctand carries the edition in the version URI (http://snomed.info/sct/11000146104/version/20260831). - A system the account holds no licence for is hidden, not absent:
$lookupon LOINC or the NHG tables answers404naming the current version (http://loinc.org|2.83,http://hl7.org/fhir/sid/icpc-1-nl|12), and a search by that URL returns zero resources. SNOMED CT, ICD-10-NL, the Labcodeset LOINC supplement and its three maps, UCUM, and the zib value sets were visible to an account that had accepted the SNOMED CT terms only.
Ask Nictiz for each licence you need and check what your account sees before you subscribe to a canonical. The content stays licensed by its publisher. FerroTERM distributes none of it, and a deployment brings what it is licensed for.
Credentials
The add-on reads its credentials from a file or from the environment, never from the configuration body, so a configuration file is safe to commit and to show in a run record.
| Variable | What it holds |
|---|---|
FERROTERM_NTS_CLIENT_ID | The OAuth 2 client, cli_client by default |
FERROTERM_NTS_CLIENT_SECRET | A client secret, when your deployment has one |
FERROTERM_NTS_USERNAME | Your account name |
FERROTERM_NTS_PASSWORD | Your account password |
The file form takes the same four field names in a JSON object, and the
configuration points at it with {"from": "file", "path": "/run/secrets/nts.json"}.
The add-on reads the token endpoint from
https://terminologieserver.nl/fhir/.well-known/smart-configuration, which
redirects (301) to the Keycloak discovery document at
https://terminologieserver.nl/authorisation/auth/realms/nictiz/.well-known/openid-configuration,
and logs in with the grant Nictiz documents: the password grant with
cli_client. If you configured a client secret, the client-credentials grant
is tried first and falls back to the password grant when the realm refuses it.
The realm issues an access token and a refresh token that both live 24 hours
(expires_in and refresh_expires_in of 86400, measured 2026-09-25). The
access token is refreshed a minute before it expires, and a refused refresh
logs in again from the stored credentials. An unattended run on any day needs
no person.
The first run
Two scripts in scripts/live/ run the first hops on your own machine and
never in CI, because both need your personal licensed account:
scripts/live/nts-feed.sh reads the feed with your credentials and reports,
per system, whether the service publishes it as an RF2 archive, as FHIR
resources, or only as Ontoserver’s binary index; scripts/live/sync-e2e.sh
builds the binaries from the checkout, starts the server on an empty index
root, runs the sync once, and shows the run record and what the server serves
afterwards. Both take the FERROTERM_NTS_* variables and print no secret.
The FerroEHR hop follows: point its quickstart overlay at the server the
second script started and commit a composition whose binding names a code the
run made available.
- Get the account, accept the terms, and confirm which systems your licences
unlock. Run
scripts/live/nts-feed.shto see the feed as your account sees it. - Put the credentials where the add-on reads them, and keep them out of the configuration file.
- Subscribe by canonical identifier. The defaults are the SNOMED CT
Netherlands edition (
http://snomed.info/sct/11000146104), LOINC (http://loinc.org), UCUM (http://unitsofmeasure.org), and ICD-10 (http://hl7.org/fhir/sid/icd-10). The service’s own canonicals for the Dutch content are ICD-10-NL (http://hl7.org/fhir/sid/icd-10-nl), the Labcodeset (http://labterminologie.nl/cs/labconcepts, a LOINC supplement, with its maps underhttp://labterminologie.nl/cm/), NHG-Tabel 24 (http://hl7.org/fhir/sid/icpc-1-nl), the other NHG tables (https://referentiemodel.nhg.org/tabellen/nhg-tabel-NN-slug), and the zib value sets (http://decor.nictiz.nl/fhir/ValueSet/{oid}--{timestamp}). - Run once and read the run record before you activate anything. It lists every entry the feed offered, what was taken, and the reason for each entry left behind: a system you did not subscribe to, a release you already serve, or a system the service offers only as Ontoserver’s binary index, which is an internal package format and is reported as not syndicable.
- Check the corrections. The freely published NHG ICPC-1 to SNOMED CT map
file writes
experimentalas the string"true", and the add-on writes the boolean FHIR declares before the file is served. The service itself servesexperimentalas a boolean on every resource it lists (checked over 2,662 summaries on 2026-09-25), so the correction concerns files taken from elsewhere. Every correction is listed with the element it changed; a file that needs none lands byte-identical.
What is verified so far
The feed hop ran against the live service on 2026-09-25 with a personal account (the record is on issue #602): discovery, the password grant, the token lifetimes, and the listing are confirmed as described above. The run also showed that the feed carries only the SNOMED CT binary index, so the RF2 lane takes nothing from the NTS until Nictiz publishes RF2 there; the FHIR resources are taken through the API lane instead.
Hardware sizing
FerroTERM is built to run an edition on ordinary hardware. This page says where the memory goes and how to size a machine; the measurements themselves (build time and peak build memory per release, the index on disk, the server’s resident memory, the latency of every operation) are on the benchmarks page, rendered from the committed records so no figure on this site is typed by hand. Your numbers vary with the edition and the machine; the shape below holds.
What the records show
The resident figure is what the server has read in and holds. The index is read
at startup, not paged in as requests arrive, so the figure is at its full size
by the time /health answers 200; the records carry the resident memory at
that point and again after the warm requests, and the two are close. Size the
machine for that figure. The build is the expensive step: it holds a whole
release in memory while it computes the transitive closure, so peak build
memory is several times the size of the finished index and is the figure to
size a build machine by. That peak lands in the write phase, where the rows
the read pass produced, the rows the store builder holds for its one write
transaction, and the word dictionary under construction are alive together.
The build also reads and writes on every core, so size a build machine for
cores as well as memory. The
design target for point reads is under 1 ms measured over HTTP on the same
machine; a millisecond-scale figure in a record is tracked as a performance
issue, not called done.
Where the memory goes
Every structure a served edition holds reports what its own allocations hold,
and ferroterm-residency --report prints those counts beside the process
footprint, so the accounting adds up from the structures rather than being
inferred from a total. The two SNOMED editions below were measured on an
Apple M2 with the release binaries. The top rows are the structures’ own
counts; the bottom three are the process that loaded them and nothing else, so
they run under a serving process, which also holds the FHIR core code systems
and the HTTP runtime. The benchmarks page reports the serving process, from
whichever record set is committed there.
| Structure | On disk (NL) | Resident (NL) | Resident (International) |
|---|---|---|---|
| Concept records, a dense column by ordinal | store.concepts.col 20.0 MB | 20.0 MB | 19.4 MB |
| Preferred displays, a dense column by ordinal | store.displays.col 73.5 MB | 73.5 MB | 55.6 MB |
| Properties, a dense column by ordinal | store.properties.col 62.9 MB | 62.9 MB | 61.1 MB |
| Acceptability, a dense column by ordinal | store.acceptability.col 49.8 MB | 49.8 MB | 37.1 MB |
| Transitive closure, both directions, roaring bitmaps | part of hierarchy.bin | 150.6 MB | 143.6 MB |
| CSR is-a adjacency | part of hierarchy.bin | 4.9 MB | 4.7 MB |
| CSR child adjacency, transposed when the edition opens | derived | 4.9 MB | 4.7 MB |
fst word dictionary and roaring postings | text.bin 76.6 MB | 115.7 MB | 69.1 MB |
| Reference set member tables | members.bin 35.2 MB | 60.0 MB | 57.0 MB |
| Attribute rows | attributes.bin 12.5 MB | 12.5 MB | 11.9 MB |
| Attribute inverted index, derived when the edition opens | derived | 4.9 MB | 4.7 MB |
| Reference set membership bitmaps | refsets.bin 0.7 MB | 0.7 MB | 0.6 MB |
| Alternate identifiers | identifiers.bin | 0 MB | 0 MB |
| Every structure | 560.4 MB | 469.5 MB | |
Unattributed: the redb page cache, the allocator, the binary, the runtime | 55.1 MB | 49.6 MB | |
| The process, measured | 615.5 MB | 519.0 MB |
The designation text is the one part of an edition that stays on disk: it is
the largest thing an artifact holds and a point read through redb answers it
in microseconds, so it is read per request rather than held. Everything else
above is resident because a read reaches it: $lookup reads the displays and
the properties, $expand reads the concepts and the closure, $subsumes is a
membership test on the closure, ECL reads the attributes and the membership
bitmaps, and a filter search reads the word dictionary. Nothing in the list
is loaded and never touched.
The unattributed remainder is worth naming. Each column is its own file, read
once into the structure it is served from, so nothing of it arrives twice; the
redb page cache is capped at 64 MiB and fills only with the designation rows
a request reads, against a default of a gibibyte. What is left is the
allocator’s slack, the binary, and the async runtime.
Both directions of the closure are stored on purpose: subsumption needs one direction, and a descendant set is returned directly from the other. Roaring compresses SNOMED-shaped sets heavily, which is why the closure of half a million concepts fits in hundreds of megabytes rather than the gigabytes a plain bitset would need.
Disk and CPU
Size the disk for the indexes you load; the benchmarks page gives each index’s size. The
offline build is the CPU-heavy step, once per release, in ferroterm-build.
Serving is a point read or a bitmap operation, so a modest CPU handles it: a
2 to 4 GB box with two cores serves an edition beside other services. A paged
$expand is cut from the selection before any concept is read, so the
concepts a page reads are the concepts it returns, not the whole selection;
an unpaged expansion beyond 1,000 members is refused with too-costly.
Finding the page still costs something on a large selection: the committed
records put a hundred members of a SNOMED subtree at 253 microseconds and a
thousand at 2.5 milliseconds on the Netherlands edition.
Behind a reverse proxy
FerroTERM speaks plain HTTP and, by default, authenticates nobody. That is deliberate: a deployment already has a proxy, and a terminology server is a poor place to reimplement TLS and rate limits. This page is the shape that gets you from that to a served endpoint, and the one part the server can take over itself: bearer validation on the routes that change content (Validating tokens in the server).
- What the server does and does not do
- What the server connects to
- The quickstart, proxied
- The base URL
- Forwarded headers
- Authentication
- Validating tokens in the server
- The admin listener stays behind the proxy
- Rate limits and timeouts
What the server does and does not do
| The server | The proxy |
|---|---|
| answers FHIR terminology requests | terminates TLS |
reads its indexes, writes only what FERROTERM_RESOURCES names | authenticates the caller, unless FERROTERM_OIDC_ISSUER moves that into the server |
| logs a line per request with no bodies and no free text | rate-limits and sheds load |
| states its endpoint and its declared security in the capability statement | forwards the client’s X-Request-Id |
The read path holds no patient data: a request names a code system, a code, and
sometimes a display, and the log line carries only the system, url,
version, code, codeA, and codeB parameters. Free-text parameters, request
bodies, and response bodies are never logged, so a proxy log is the only place a
filter= term can appear. Keep proxy access logs to the same standard.
What the server connects to
By default, nothing. The server binary carries no general HTTP client, so a
deployment can refuse it every outbound route and it still answers every
request. The indexes are files it opens read-only, and the one socket it opens
itself is the health probe: a GET /health to its own listener over loopback,
because the image has no shell to run a probe with. Fetching a release from a
terminology service happens outside the server, on a machine of your choosing,
and the server reads the artifacts that run writes.
The one exception is the OIDC issuer, and only when you name one. With
FERROTERM_OIDC_ISSUER set the server reads that issuer’s discovery document
and its JWKS at start, and the key set again when a token names a key it has
not read (see Validating tokens in the server).
That is the whole of its outbound traffic; leave the variable unset and it
makes none.
scripts/checks/no-client-in-server.sh is the evidence. The
no-client-in-server job in .github/workflows/ci.yml runs it on every pull
request, reading the binary’s resolved dependency tree and failing when an HTTP
client reaches it.
The quickstart, proxied
compose.yaml carries a proxied profile: Caddy in front, the server on the
compose network only.
FERROTERM_DOMAIN=tx.example.org \
FERROTERM_BASE_URL=https://tx.example.org \
docker compose --profile proxied up
With the default domain (localhost) Caddy issues from its own internal CA, so
the profile works offline; name a real domain and it fetches and renews a
certificate itself. The server publishes no host port in this profile: the proxy
reaches it as ferroterm:8080 inside the network.
The base URL
A server behind a proxy answers on an address it never sees: the proxy
terminates TLS and forwards a plain request, so the socket the process bound is
not the URL a client used. FERROTERM_BASE_URL is that URL, without a version
prefix and without a trailing slash:
FERROTERM_BASE_URL=https://tx.example.org
Each version’s capability statement then states its own endpoint, so a client that reads one learns where to send the next request:
"implementation": {
"description": "FerroTERM terminology server",
"url": "https://tx.example.org/r4b"
}
Both GET /r4b/metadata and GET /r4b/metadata?mode=terminology carry it. A
deployment that sets nothing states no URL rather than a wrong one.
Forwarded headers
Terminate TLS at the proxy and forward the original scheme, host, and client
address. Caddy’s reverse_proxy sets X-Forwarded-For, X-Forwarded-Proto,
and X-Forwarded-Host by itself; nginx needs them spelled out:
location / {
proxy_pass http://ferroterm:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Request-Id $request_id;
proxy_read_timeout 60s;
}
The server does not read these headers: it takes its public URL from
FERROTERM_BASE_URL rather than from a header a client could forge, which is
the same reason a proxy should strip an inbound X-Forwarded-* it did not set.
It does read X-Request-Id, and echoes it, so a proxy that generates one (as
nginx does above) gets the same id in the server’s log line. See
Metrics and request identifiers.
Authentication
The default is that FerroTERM validates no tokens: the proxy authenticates and the capability statement declares what it enforces. The FHIR security page puts it the same way, a server relies on the deployment’s infrastructure and states what that is (https://hl7.org/fhir/R4B/security.html). Authenticate at the proxy, and tell clients what you did:
FERROTERM_SECURITY_SERVICE=SMART-on-FHIR
The value is codes of the FHIR restful-security-service value set
(OAuth, SMART-on-FHIR, Basic, Certificates, Kerberos, NTLM),
comma-separated, and they appear in CapabilityStatement.rest.security.service.
A deployment that declares none says so in words: “The server requires no
authentication of its own; a deployment puts its own in front of it.”
If your gateway speaks SMART on FHIR, the scopes worth granting are read-only:
the terminology surface reads, and the only routes that change content are the
resource endpoints and $closure, which a deployment enables by naming
FERROTERM_RESOURCES and can leave unset.
Validating tokens in the server
Take the gateway route when one gateway already fronts every service you run
and no client writes to FerroTERM. Take the server route when writes matter:
FERROTERM_RESOURCES is set, clients or an editor persist CodeSystem,
ValueSet, and ConceptMap resources, and you want the server itself to know
which caller may change what. A gateway can protect a path; it cannot tell a
terminologist’s token from a reporting client’s without repeating the scope
rules FerroTERM already applies.
Name the issuer, and the server takes the SMART App Launch resource-server role (https://hl7.org/fhir/smart-app-launch/conformance.html). It issues no tokens of its own: your authorization server does that, and FerroTERM only reads its public metadata.
FERROTERM_BASE_URL=https://tx.example.org
FERROTERM_OIDC_ISSUER=https://auth.example.org/realms/tx
FERROTERM_OIDC_ADMIN_SCOPE=ferroterm/admin
At start the server reads {issuer}/.well-known/openid-configuration and the
JWKS it names. It refuses to start when either does not answer, so a server
that cannot check a token never serves a surface it has declared protected.
The issuer URL is https, and a plain-HTTP one is refused unless its host is
the loopback, because those bytes decide which signatures the server trusts.
Install your certificate authority in the image when the issuer runs under a
private one: the server uses the platform trust store.
What changes once it is set:
POST,PUT, andDELETEonCodeSystem,ValueSet, andConceptMap, on every served FHIR version, require a bearer token. So doesPOST [base]/$closure, which keeps a stored closure table, and every route on the admin listener.- The read surface stays open.
$lookup,$expand,$validate-code,$subsumes,$translate,/metadata,/health, and/metricsanswer without a token, whichever HTTP method they use. [base]/.well-known/smart-configurationis served per version, built from the issuer’s document, and each capability statement declaresSMART-on-FHIRwith theoauth-urisextension.
The scope for a route is the SMART version 2 one (https://hl7.org/fhir/smart-app-launch/scopes-and-launch-context.html):
| Route | Scope that grants it |
|---|---|
POST /r4b/CodeSystem | system/CodeSystem.c or user/CodeSystem.c |
PUT /r4b/ValueSet/{id} | system/ValueSet.u or user/ValueSet.u |
DELETE /r4b/ConceptMap/{id} | system/ConceptMap.d or user/ConceptMap.d |
POST /r4b/$closure | system/ConceptMap.u or user/ConceptMap.u |
| Any admin-listener route | the FERROTERM_OIDC_ADMIN_SCOPE value |
SMART names no scope for $closure, so that last row is our own reading: the
operation maintains a stored ConceptMap
(https://hl7.org/fhir/R4B/conceptmap-operation-closure.html), and no
specification governs the admin listener at all.
Both compartments open the same routes. system/ is a client authorized in
its own right, which is what a sync service or a reporting client presents;
user/ is a person acting through an interactive client, which is what the
editor presents. patient/ is refused: a terminology server holds no patient
record, so a patient-compartment scope selects nothing here. The served
scopes_supported drops what the gate refuses, so it never advertises a scope
the server would answer 403 to.
The combined forms work as the specification defines them, so
user/CodeSystem.cud and user/*.cruds both grant a create, and the
version 1 user/CodeSystem.write and user/CodeSystem.* are accepted for
compatibility. A scope narrowed by search parameters
(system/CodeSystem.cud?url=…) grants nothing: the server does not evaluate
the restriction, so it refuses rather than widening it. That is why the
discovery document claims permission-v1 and not permission-v2, which names
the granular syntax. Grant an unnarrowed scope.
The token itself is an ordinary JWT signed by the issuer. The server checks the
signature against the JWKS, iss, exp, nbf, the audience as described
below, and the typ header when the issuer sets one: a token typed as
something other than an access token (an ID token, for example) is refused
rather than spent here.
The audience a token carries
The aud parameter of a SMART authorization request names the resource server
the app wants FHIR data from
(https://hl7.org/fhir/smart-app-launch/app-launch.html), and this server
publishes one FHIR base per version. A reader who signs in on /r5 and one who
signs in on /r4b therefore get tokens with different audiences from an issuer
that mints the claim from that request. Both are this server.
The write routes accept every served version’s base derived from
FERROTERM_BASE_URL, with and without a trailing slash, plus
FERROTERM_OIDC_AUDIENCE when you set it. With
FERROTERM_BASE_URL=https://tx.example.org the server accepts
https://tx.example.org/r4, /r4b, /r5, and /r6 under that host. A token
passes when its aud holds any of them, which RFC 7519 §4.1.3 asks of a
recipient identifying itself, and the claim may be a string or an array. A
token for any other audience is refused with 401 and error="invalid_token",
and so is a token with no aud at all (RFC 8725 §3.9).
A token minted for one version’s base is spent on every other. One process serves the four bases and a reader moves between them in one session, so the audience says which server, not which version.
Set FERROTERM_BASE_URL to the exact address readers’ browsers use, down to
the scheme, the host, and the port. An audience is compared as a string, so
https://tx.example.org and https://tx.example.org:443 are two audiences,
and a value that differs from the one the viewer sends refuses every write
from a reader who signed in successfully.
The root URL is not in the set: the server serves no FHIR base there. Name it
in FERROTERM_OIDC_AUDIENCE when your issuer mints it anyway.
The admin listener is not a FHIR endpoint, so no version base is an audience
for it: it accepts FERROTERM_OIDC_AUDIENCE when you set one, and checks no
audience when you do not.
With neither variable set the server checks no audience anywhere, and the
issuer check alone bounds the token. Set FERROTERM_BASE_URL in any deployment
behind a proxy: it is what the capability statements publish, and it is what
makes the audience check real.
A server-to-server client obtains its token through SMART Backend Services,
which is the flow a sync service uses
(https://hl7.org/fhir/smart-app-launch/backend-services.html):
client_credentials with private_key_jwt, the system/ scopes above, and
the token presented as Authorization: Bearer.
A browser client holds no secret, so it runs the standalone launch as a public
client: the authorization code flow with PKCE
(https://hl7.org/fhir/smart-app-launch/app-launch.html), user/ scopes, and
the token kept in memory. It reads where to send the person from
[base]/.well-known/smart-configuration, which publishes
authorization_endpoint, token_endpoint, and
code_challenge_methods_supported. That last member is always ["S256"]:
“SMART servers SHALL support the S256 code_challenge_method and SHALL NOT
support the plain method”, and S256 is the SHA-256 challenge of RFC 7636
§4.2. A plain your issuer advertises is dropped.
The capabilities array states what the deployment offers: permission-user
and permission-v1 are this server’s own, and launch-standalone,
client-public, client-confidential-symmetric,
client-confidential-asymmetric, and sso-openid-connect are claimed only
where your issuer’s document backs them. Register the browser client with your
issuer for token_endpoint_auth_method: none, and grant it openid too when
you want the person’s identity in an id_token.
What comes back on a refusal follows RFC 6750 §3:
| Situation | Answer |
|---|---|
| No token | 401, WWW-Authenticate: Bearer realm="…", an OperationOutcome with login |
| Expired, malformed, wrong issuer, wrong audience, wrong signature | 401 with error="invalid_token" |
| Valid token, no scope for the route | 403 with error="insufficient_scope", and the outcome naming every scope that opens the route |
Key rotation needs nothing from you. A token naming a kid the server has not
read makes it fetch the JWKS again, at most once a minute, so publishing the
new key before you sign with it is enough.
The admin listener stays behind the proxy
FERROTERM_ADMIN_LISTEN binds a second listener that serves POST /reload
and nothing else. Without FERROTERM_OIDC_ISSUER it authenticates nobody
(Configuration). Bind it to
127.0.0.1 or an address on your internal network, publish only the FHIR
listener through the proxy, and reach the reload from the host or from the
orchestration that writes the new artifacts. A deployment that puts the admin
address behind the same public proxy hands anyone a way to make the server
re-read its disks.
Rate limits and timeouts
The proxy is the place for both. An expansion of a large implicit value set is
the expensive request to bound: ValueSet/$expand answers at most 1,000 members
without count and asks for paging beyond that, so a limit on requests per
second per client, plus a read timeout in the tens of seconds, is enough. Cap
the request body too (a Parameters POST with tx-resource resources is the
largest thing a client sends).
Health and metrics stay off the public route:
handle /health /metrics {
respond 404
}
Probe them from inside the network instead, where the container’s own port is reachable.
Health, metrics, and request identifiers
FerroTERM answers a health probe at GET /health, a Prometheus scrape at
GET /metrics, and names every request with an X-Request-Id. None of these
is a FHIR interaction: the routes live off the FHIR base path, so a probe or a
scrape is never mistaken for a terminology request.
Health
GET /health answers 200 OK with an empty body. The server binds its
listener only after every index and CodeSystem directory it was configured
with is open, and refuses to start on a missing or damaged one, so an answer
at all means the deployment serves what it was told to serve. There is no
degraded state to report: a probe that gets a 200 may send terminology
requests.
The container image has no shell and no HTTP client, so the probe is a subcommand of the binary:
$ ferroterm healthcheck
$ ferroterm healthcheck --url http://127.0.0.1:8080/health
It sends one GET /health, waits at most three seconds, and exits 0 with no
output on 200 OK. Otherwise it exits 1 with the reason on stderr: cannot reach, answered 404 Not Found, or did not answer within 3s. Without
--url it follows FERROTERM_LISTEN, on the loopback when the server listens
on every interface, which is what the image sets. The image declares it as its
HEALTHCHECK (every ten seconds, a five-second limit, a sixty-second start
period for reading an edition), and the shipped compose.yaml states the same
block, so docker compose up --wait and condition: service_healthy work.
Kubernetes ignores the image’s instruction and takes GET /health in its own
probes.
Scraping
curl -s http://127.0.0.1:8080/metrics
The exposition is the text format Prometheus reads, with every metric under the
ferroterm prefix:
| Metric | Type | Labels | What it says |
|---|---|---|---|
ferroterm_http_requests_total | counter | method, route, status | the requests answered |
ferroterm_http_request_duration_seconds | histogram | method, route, status | how long each took |
ferroterm_code_system_loaded | gauge | system, version | one per code system version loaded |
ferroterm_reloads_total | counter | outcome (ok, failed) | the reloads of the served set |
route is the matched route, /r4b/CodeSystem/$lookup, never the URI, so the
series count stays bounded no matter how many codes clients ask about. The
duration buckets start at half a millisecond and double, which brackets the
bars the engine is held to: a point read under a millisecond, a page of an
expansion under ten.
ferroterm_code_system_loaded describes the set the server answers from now:
a reload that drops a version
drops its series, and one that adds a version adds one. Both outcome series
of ferroterm_reloads_total start at zero, so
increase(ferroterm_reloads_total{outcome="failed"}[1h]) > 0 is an alert you
can write before the first reload happens. A failed reload means the server is still
answering from the old set and the content on disk did not open; the
the served set was not reloaded log line carries the reason.
A scrape configuration is one job:
scrape_configs:
- job_name: ferroterm
static_configs:
- targets: ["ferroterm:8080"]
The endpoint carries no code system content and no request bodies, so it is safe to expose to a monitoring network. It is not authenticated: keep it on an internal listener or behind the same reverse proxy rules as the rest of the server (see Configuration).
Request identifiers
Every response carries X-Request-Id. A client that sends the header gets its
own value back, so a trace started at the caller stays one trace; a client that
sends none gets a UUID the server makes. An id that is empty, longer than 128
characters, or not printable ASCII is replaced rather than echoed, so a header
cannot smuggle a line break into a log.
The same id is a field of the request’s log line:
{"timestamp":"2026-09-05T09:21:04.113Z","level":"INFO","message":"request",
"method":"GET","route":"/r4b/CodeSystem/$lookup","status":200,
"latency_ms":0.42,"named":"system=http://snomed.info/sct code=73211009",
"request_id":"0f0f6c1e-9d0e-4a5f-8c1a-1f6b0f1f6b0f"}
So a client that reports a slow or refused call gives you the id, and the log
line, the status, and the latency follow from it. Bodies are never logged, and
the named field carries only the system, url, version, and code parameters a
request stated.
Usage report
Every FerroTERM server sends a small JSON report to FerroPULSE, a collector that Cadasto B.V. runs. It goes out at each process start and once a day. It says that the server is alive, which version it runs, which families of code systems it serves, and how fast it answers, in coarse aggregates. The report is on by default. Every boot logs one INFO line that says whether it is on and how to switch it off.
This page lists every field the report carries, says when it is sent, gives the switch, and carries the privacy notice for the data the collector stores. No FHIR or SNOMED CT specification governs any of it; the report is FerroTERM’s own design.
- When a report is sent
- What a report contains
- What a report never contains
- Switch it off
- Network
- Privacy notice
When a report is sent
There are two kinds of report, and both are one HTTPS POST to
https://report.ferropulse.eu/v1/report with a JSON body of at most 64 KiB.
start: at each process start, once the server has loaded its code systems. A server sends at most one start report per ten minutes: the time of the last one is kept in the state directory. A container in a restart loop sends one start report per ten minutes, not one per restart.daily: the first attempt 10 minutes after boot, then about every 24 hours (23.5 to 24.5 hours after the previous one, spread at random). It covers the requests since the previous daily report of the same process, or since boot.
The report needs a state directory, FERROTERM_STATE_DIR, where the server
keeps the instance id. Without one, or with one it cannot write, the server
sends nothing: without a stable id each restart would count as a new
installation. The compose file is the one deployment that turns it on: it
mounts the named volume ferroterm-state at /data/state and sets
FERROTERM_STATE_DIR=/data/state. The container image sets no state
directory, so a plain docker run or a Kubernetes pod sends nothing until
you mount a writable volume and set FERROTERM_STATE_DIR to it:
docker run -v ferroterm-state:/data/state -e FERROTERM_STATE_DIR=/data/state \
ghcr.io/ferrohealth/ferroterm:<version>
The image owns /data/state by its runtime user (uid 65532), so a named
volume mounted there is writable. A bind mount needs a host directory that uid
65532 can write.
The boot line, with the report on, reads:
usage report ON: instance id, version, licence type, deployment, uptime, host size, loaded code system families and coarse operation latency, at each start and once a day; no codes, URLs or request paths. Off: FERROTERM_USAGE_REPORT=off. Details: https://ferroterm.eu/docs/operate/usage-report.html
It carries the configured endpoint as the structured field endpoint. With the
report off, the line says why, for example:
usage report OFF: switched off (FERROTERM_USAGE_REPORT=off); nothing is sent. Details: https://ferroterm.eu/docs/operate/usage-report.html
usage report OFF: CI detected (GITHUB_ACTIONS); nothing is sent. Details: https://ferroterm.eu/docs/operate/usage-report.html
usage report OFF: no state directory (FERROTERM_STATE_DIR is unset), and without a stable instance id each restart would count as a new installation; nothing is sent. On: mount a writable volume and set FERROTERM_STATE_DIR to it. Details: https://ferroterm.eu/docs/operate/usage-report.html
What a report contains
Every report carries these fields:
| Field | Sent in | What it is |
|---|---|---|
schema | both | Always 2, the version of the report format. |
event | both | start or daily. |
first_start | start | true when this start created the instance id, which marks a new installation; false on every later start. |
product | both | Always ferroterm. |
instance_id | both | A random UUID (version 4), created at the first start and stored in <FERROTERM_STATE_DIR>/instance-id. It is not derived from the host or anything else about the deployment. |
version | both | The server version, for example 0.1.8. |
git_sha | both | The commit the binary was built from, 7 to 40 hex digits. A build that recorded no commit, such as a local cargo build, sends 0000000. |
licence | both | The licence grant type, non-commercial (what the Business Source License grants everyone). |
deployment | both | How the server was deployed: helm, compose, binary or unknown. It comes from FERROTERM_DEPLOYMENT, which the compose file sets to compose. |
uptime_s | both | Seconds since this process started. |
cpu_bucket | both | The CPUs available to the process, as a range: 1-2, 3-4, 5-8, 9-16 or 17+. |
memory_bucket | both | The memory available to the process in GiB, the smaller of the host’s memory and the container limit, as a range: <4, 4-8, 8-16, 16-32 or 32+. Off Linux the server cannot read it and sends <4. |
status | both | The state facts below. |
metrics | daily | The performance aggregates below. |
The status object:
| Member | What it is |
|---|---|
index_ok | Whether at least one code system the deployment loaded is serving. |
code_systems | The families of the loaded code systems, sorted: dhd, fhir, gstandaard, icd10, icd10cm, icd11, labcodeset, loinc, other, rxnorm, snomed. fhir is a CodeSystem resource from FERROTERM_CODESYSTEMS that no other family names, and other is an artifact none names. Never an edition, a module id, a version or a URL. |
viewer | Whether the viewer is mounted at /ui. |
persistence | Whether a resources database (FERROTERM_RESOURCES) is configured. |
A start report, formatted here for reading (the server sends it on one line):
{
"schema": 2,
"event": "start",
"first_start": true,
"product": "ferroterm",
"instance_id": "5f0c6b2e-3d4a-4c8b-9e1f-2a3b4c5d6e7f",
"version": "0.1.8",
"git_sha": "1a2b3c4d",
"licence": "non-commercial",
"deployment": "compose",
"uptime_s": 0,
"cpu_bucket": "3-4",
"memory_bucket": "8-16",
"status": {
"index_ok": true,
"code_systems": ["loinc", "snomed"],
"viewer": true,
"persistence": false
}
}
The daily metrics
The daily report adds one object that covers the window since the previous daily report:
"metrics": {
"window_24h": {
"requests_bucket": "1k-10k",
"groups": {
"lookup": {
"p50_ms": 1.2, "p95_ms": 4.0, "p99_ms": 9.5,
"errors_5xx": 0,
"histogram": [1200, 800, 300, 90, 20, 6, 2, 1, 0, 0, 0]
}
},
"counters": { "fhir_r4": 120, "fhir_r4b": 2300, "fhir_r5": 0, "fhir_r6": 3 }
}
}
requests_bucket: the number of requests under a FHIR base (/r4,/r4b,/r5,/r6) in the window, as a range:0,1-100,100-1k,1k-10k,10k-100kor100k+. Each range includes its lower bound and excludes its upper one, so1-100means 1 to 99./health,/metrics,/uiand the admin listener are not counted.groups: one entry per group of requests that saw at least one in the window. A request is placed in a group by the route template it matched, never by the path or the parameters the client sent:lookup,validate_code,expand,subsumes,translateandclosurefor the operations,metadata,read(a resource, its history, or a version),search,write(create, update, delete), andotherfor everything else.p50_ms,p95_ms,p99_ms: the median, 95th and 99th percentile latency of the group in milliseconds, estimated from its histogram.errors_5xx: the responses with a 5xx status.histogram: 11 request counts, one per latency bucket. The bucket upper bounds in milliseconds are 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, and no bound for the last.counters: the requests per served FHIR version.
What a report never contains
A report carries no code, no display or other text from a request, no system, value set or concept map URL, no edition, module id or version of a code system, no request path or parameter, no error message, no user name, no client IP address, no host name and no customer or staff name. The report types in the server have no field that could hold any of these, and the collector refuses a report with a value that is not a boolean, a number or a short lower-case token.
The collector does record the IP address the report arrives from. See What the collector stores.
Switch it off
FERROTERM_USAGE_REPORT=off
The compose file forwards this variable from your shell, so
FERROTERM_USAGE_REPORT=off docker compose up switches the report off there
too. In a CI job the report is off by itself: when GITHUB_ACTIONS or CI is
true or 1, the server sends nothing, and FERROTERM_USAGE_REPORT=on does
not change that. A container does not inherit the runner’s environment, so a
test lane that starts the image passes --env FERROTERM_USAGE_REPORT=off.
With the report off, the server sends nothing to FerroPULSE and writes nothing
to the state directory for the report, and the boot line says usage report OFF. Nothing else in the server changes. Switching it off stops new reports
only. Reports already stored at the collector stay until the retention period
ends, unless you ask for them to be erased.
Network
- Destination: outbound HTTPS to
report.ferropulse.eu, port 443.FERROTERM_USAGE_REPORT_ENDPOINTmoves it; the server refuses to start on an endpoint that is plain HTTP (except to a loopback host) or that carries credentials. - One direction only: the server reads the status code of the answer and nothing else. It does not follow redirects, and nothing the collector answers changes the server’s configuration or behaviour.
- Failures stay local: a send gives up after 5 seconds without a
connection or 10 seconds in total. A failure is logged once at DEBUG. Boot,
/healthand request handling never wait on the report, so a slow or unreachable collector affects none of them. A daily report that does not reach the collector is tried again an hour later with the same window; one the collector answered, even with a refusal, is not resent.
Privacy notice
This section gives the information that Articles 13 and 14 of the GDPR list, for the personal data a report and its stored record can contain. A report names no person. It can still relate to one: the collector stores the address each report comes from, and a server run by one person on their own internet connection can be linked to that person through the address or through the instance id. Where that is so, the report is personal data, and this notice applies to it.
Controller
Cadasto B.V., the Netherlands. Write to info@cadasto.com or use https://www.cadasto.com/contact/. Cadasto has not appointed a data protection officer.
Purposes and lawful basis
Cadasto processes the reports for three purposes:
- to know which FerroTERM versions run in the field, so it can tell how many installations a security release concerns and whether they upgrade;
- to know which families of code systems the installations serve;
- to see how the operations perform in real use, so it can find and fix slow paths.
The lawful basis is Art. 6(1)(f) GDPR, legitimate interest. The interest is Cadasto’s, as the maker of FerroTERM: keeping the product it publishes secure and maintained, which needs the versions in use and the reach of each security release, and improving it, which needs the code system mix and field performance. The data come from the FerroTERM server itself. You are not obliged to send them: the report can be switched off, and switching it off changes nothing else in FerroTERM.
What the collector stores
- Every report as it arrives, with the fields listed above.
- With each report: the time of receipt, the UTC day, the source IP address (IPv4 or IPv6), and the country derived from that address.
- Per instance id: when it was last seen, its current version, and whether it is alive.
- Per day: latency totals summed across the whole fleet, without instance ids.
The country comes from an IP-to-country database bundled with the collector, so no address leaves the collector host for the lookup. IP geolocation by DB-IP, licensed CC BY 4.0. Reports from the address ranges of GitHub-hosted CI runners are marked as such and left out of every dashboard.
Recipients and location
Hetzner Online GmbH (Gunzenhausen, Germany) hosts the collector in its cloud and processes the reports as Cadasto’s processor, under a data processing agreement Cadasto has signed (Art. 28 GDPR). The collector runs in Hetzner’s data centre in Falkenstein, Germany. The FerroPULSE dashboard that shows the reports sits behind a GitHub login with no public or anonymous access; only members of the FerroHEALTH organisation can sign in.
Retention
A daily job deletes reports and start reports 13 months after they were received, an instance record 13 months after the instance was last seen, and the fleet-wide daily totals 13 months after their day. Backups of the collector’s database are kept for 14 days, so a deleted report can remain in a backup for at most 14 days more.
Your right to object
Important
You have the right to object at any time, on grounds relating to your particular situation, to the processing of reports that are personal data concerning you (Art. 21(1) GDPR).
To stop future reports, switch the report off as described in Switch it off. No request to anyone is needed.
To have the reports already stored erased, write to info@cadasto.com with the instance id, which the server keeps in
<FERROTERM_STATE_DIR>/instance-id. Under the compose file it sits on theferroterm-statevolume:docker run --rm -v <project>_ferroterm-state:/s busybox cat /s/instance-idprints it. Cadasto then deletes every report, start report and instance record stored for that instance id, source addresses included (Art. 17(1)(c) GDPR). Switch the report off first, or the next report starts a new record.The fleet-wide daily totals hold no instance ids, so an erased instance’s share in days already summed stays in those totals.
Your other rights
At the same address you can ask for access to the reports stored for your instance id (Art. 15), for their rectification (Art. 16) or erasure (Art. 17), or for restriction of their processing (Art. 18). You have the right to lodge a complaint with a supervisory authority, in particular in the Member State of your habitual residence, place of work or place of the alleged infringement (Art. 77).
Operators in Switzerland
Under the Swiss Federal Act on Data Protection (DSG Art. 19 Abs. 4), these are the states your report’s data are disclosed to: Germany, where the Hetzner data centre in Falkenstein runs the collector, and the Netherlands, where Cadasto B.V. is established. Both are listed in Annex 1 of the Swiss Data Protection Ordinance (DSV) as states whose legislation provides adequate protection (DSG Art. 16 Abs. 1).
Verifying releases
Every FerroTERM release artifact carries a signed provenance attestation.
Verify it before you run a binary or pull an image, so you know the artifact
was built by the project’s own release workflow and not tampered with. The
pipeline and its rationale are in
docs/ci-cd.md.
What a release carries
For each target (x86_64 and aarch64, glibc and musl), the release holds:
| File | What it is |
|---|---|
ferroterm-<tag>-<target>.tar.gz | ferroterm and ferroterm-build, built with cargo auditable so the dependency list is embedded in the binary |
….tar.gz.sha256sum | the checksum |
….tar.gz.sigstore.json and ….tar.gz.intoto.jsonl | the SLSA provenance, signed keyless through Sigstore by the release workflow’s identity |
ferroterm-<tag>-<target>.cdx.json and ferroterm-build-<tag>-<target>.cdx.json | CycloneDX SBOMs of the two binaries |
….tar.gz.sbom.sigstore.json and ….tar.gz.build-sbom.sigstore.json | the SBOMs’ attestations |
compose.yaml | the quickstart, pinned to the release’s image tag |
The container image carries the same provenance and SBOM attestations per platform.
Verify the provenance
$ gh attestation verify ferroterm-v0.1.8-x86_64-unknown-linux-musl.tar.gz \
-R FerroHEALTH/FerroTERM \
--signer-workflow FerroHEALTH/FerroTERM/.github/workflows/release-build.yml
$ gh attestation verify oci://ghcr.io/ferrohealth/ferroterm:0.1.8 \
-R FerroHEALTH/FerroTERM \
--signer-workflow FerroHEALTH/FerroTERM/.github/workflows/release-image.yml
The --signer-workflow flag is the point of the check. It requires that the
attestation was produced by that exact reusable workflow in this repository, so
a signature from any other workflow or repository fails. FerroTERM builds its
releases in reusable workflows to reach SLSA Build Level 3, where the signing
identity is not reachable by user build steps.
Releases up to v0.1.5 were built before the repository moved from the
rubentalstra account to the FerroHEALTH organisation. Their attestations
stay with the account that signed them, so verify one of those with
--owner rubentalstra --signer-workflow rubentalstra/FerroTERM/.github/workflows/release-build.yml
(or release-image.yml) in place of the two flags above.
Check the checksum
$ sha256sum -c ferroterm-v0.1.8-x86_64-unknown-linux-musl.tar.gz.sha256sum
Run both checks. The checksum tells you the bytes are intact, and the attestation tells you where the bytes came from.
The FHIR terminology API
FerroTERM speaks the HL7 FHIR terminology API. If your client already talks to
a FHIR terminology server, it talks to FerroTERM. It speaks FHIR JSON and
FHIR XML: _format=xml in the query or Accept: application/fhir+xml
selects XML for a response, Content-Type names the format of a request
body, and JSON is the default. Accept quality values count, and an Accept
that names neither format gets 406 Not Acceptable. The base is /r4b for R4B,
/r4 for R4, /r5 for R5, and /r6 for the R6 ballot (see
FHIR versions); the
Worked examples page shows requests and the responses a running
server gave.
- The operations
- Value sets
- Request-scoped resources
- Code system identity and versions
- Which artifact a code system came from
- Which implicit value sets a code system resolves
- Which levels an operation answers at
- Languages
- Persisted resources
- Primitive lexical forms on local content
- Concept status on local content
- SNOMED CT implicit concept maps
- Batch
- Closure tables
The operations
| Operation | Answers | How |
|---|---|---|
CodeSystem/$lookup | a concept’s display, designations, and properties | a point read in the store; property narrows, displayLanguage picks the language |
CodeSystem/$validate-code | whether the code exists and the display is right | the store; the right display comes back when the given one is wrong; inactive codes validate with a message |
CodeSystem/$subsumes | subsumes, subsumed-by, equivalent, or not-subsumed | a bitmap membership test in the closure; either side may be a SNOMED CT expression (Post-coordinated expressions) |
ValueSet/$expand | the members of a value set, paged | the compose layer over the providers’ filters and bitmaps |
ValueSet/$validate-code | whether a code is a member, and its display | membership without enumerating the set |
ConceptMap/$translate | the targets a code maps to, with the relationship | loaded, inline, or request-scoped maps |
$versions | the FHIR version of the base (4.3) | |
$cache-control | a cache of request-scoped resources | below |
metadata, metadata?mode=terminology | the CapabilityStatement and the TerminologyCapabilities listing every served code system with its filters and properties |
Every operation accepts GET with query parameters and POST with a
Parameters resource, at the type level (ValueSet/$expand?url=…) and at
the instance level (CodeSystem/{id}/$validate-code,
ValueSet/{id}/$expand, ValueSet/{id}/$validate-code,
ConceptMap/{id}/$translate). The instance form runs the operation on the
resource the URL names (https://hl7.org/fhir/R4B/operations.html#request), so
it needs no url, and works for a resource that carries none of its own. A
url, a version, or an inline resource that names another one is a 400; a
canonical carrying the instance’s own version after a | is the instance. The levels a version offers are the ones its OperationDefinition
declares, so CodeSystem/{id}/$lookup answers under /r5 and /r6 and is
not a route under /r4 and /r4b
(https://hl7.org/fhir/R5/codesystem-operation-lookup.html). $expand returns the
expanded ValueSet; the others return Parameters. Every failure is an
OperationOutcome whose issue carries severity, code, and details.text,
never a bare 500. A terminology failure adds a details.coding from
http://hl7.org/fhir/tools/CodeSystem/tx-issue-type (invalid-code,
not-found, vs-invalid, too-costly, and the rest); a refusal at the wire
layer, such as an unreadable id or a failed precondition, carries the status
and the text without one. The parameter set of each operation is what the
served version’s OperationDefinition declares, plus the parameters the HL7
terminology ecosystem requires of every server
(https://hl7.org/fhir/uv/tx-ecosystem/requirements.html); a parameter outside
that set is refused.
A $validate-code against a code system the server does not hold answers 200
with result = false and one x-caused-by-unknown-system per missing system,
so a validator can tell its user which resources to load. The value is a
canonical, url on its own or url|version when the request pinned a version
the server does not serve. CodeSystem/$validate-code names the system the
request asked for. ValueSet/$validate-code names a system the value set
itself selects from; a system the request brings that the value set never
selects from is reported as x-unknown-system instead, because the value set
is not the cause. Neither name is declared by any OperationDefinition, so
both come from the ecosystem overlay and are marked as such in the
CapabilityStatement.
Value sets
A value set reaches $expand and ValueSet/$validate-code in five ways:
- Loaded: a
ValueSetJSON underFERROTERM_CODESYSTEMS, named byurl(andversion; without one the greatest version answers). Loaded value sets are readable atGET ValueSet/{id}andGET ValueSet?url=…&version=…, where{id}is theidthe resource carries, or one the server mints when it carries none (see Configuration). - Persisted: a
ValueSetwritten through the REST API (see below). - Inline: the
valueSetparameter of aPOST. - Request-scoped:
tx-resourceparameters, see below. - Implicit: a URL a code system defines, answered by its provider without
a resource: LOINC’s
http://loinc.org/vs,/vs/[answer list], and/vs/[part]; UCUM’s/vsand/vs/[unit]; RxNorm’s/vs; ICD-11’s<entity>/postcoordinationScale/<axis>; SNOMED CT’s?fhir_vs,?fhir_vs=isa/[sctid],?fhir_vs=refset, and?fhir_vs=refset/[sctid], on the system, edition, or version URI (Value sets and ECL).
A compose may include whole systems, enumerated concepts, and filters. The
filters a system answers are the ones its provider declares in
TerminologyCapabilities, and they differ by system, so read the declaration
rather than assuming a common set. SNOMED CT declares concept with is-a,
descendent-of, and in, then constraint for an expression constraint and
expressions for post-coordination
(Post-coordinated expressions).
The others declare their own: LOINC’s
parent and ancestor and its table fields, RxNorm’s STY, SAB, TTY,
REL, and RELA, UCUM’s canonical and property, ICD-10’s note kinds. $expand
honours count and offset (at most 1,000 members without count), filter
(word-prefix text search over designations), activeOnly, excludeNested,
includeDesignations, displayLanguage, system-version,
check-system-version, force-system-version, and exclude-system, and
echoes every effective parameter plus one used-codesystem per system version
the expansion drew on.
An expansion nests where the compose is the system’s own hierarchy: one include
of one system, taken whole or narrowed by is-a or descendent-of, with
nothing enumerated and nothing excluded. Each root then carries its children
under contains
(https://hl7.org/fhir/R4B/valueset-definitions.html#ValueSet.expansion.contains),
total counts every member rather than the roots, and a page is the slice of
the pre-order walk. excludeNested=true flattens it, and a text filter over a
whole system is flat already, because its matches have no root to hang from.
Request-scoped resources
A client can bring its own CodeSystem, ValueSet, and ConceptMap
resources for one request: each tx-resource parameter of a POST carries
one, and the operation sees them layered over the loaded resources (a resource
with the same url and version as a loaded one shadows it for that request).
A supplement in a tx-resource applies to the system it names. Any other
resource type is refused with not-supported. This is what the HL7 terminology
ecosystem runner sends; tx-resource is a declared parameter in R6 and an
ecosystem extension on R4B
(https://build.fhir.org/ig/HL7/fhir-tx-ecosystem-ig/requirements.html).
To avoid resending the same resources, start a cache:
POST [base]/$cache-control?mode=start (Parameters of tx-resource)
→ Parameters { cache-id }
Then name it with the X-Cache-Id header on later requests; their own
tx-resources stack on top. POST [base]/$cache-control?mode=end with the
header (or a cache-id parameter) releases it. A cache unused for 30 minutes
expires, and an unknown id answers 404.
Code system identity and versions
system is the code system’s canonical URI (http://snomed.info/sct,
http://loinc.org, http://hl7.org/fhir/sid/icd-10-cm, …). Each loaded
version is one CodeSystem instance; without a version the server resolves
its default and echoes the version it used in every response. For SNOMED CT
the version is the edition and version URI of the SNOMED CT URI standard
(http://snomed.info/sct/11000146104/version/20260630), never a bare date.
Each instance is readable at GET CodeSystem/{id} and findable with
GET CodeSystem?url=…&version=…, on every version prefix, alongside the
resources a client persisted. A system loaded as a CodeSystem resource is read
at the id that resource carries; a system behind an index is read at the id
Configuration
describes and the server prints at startup. A system the server holds behind an index answers with its
definition and content = not-present: the codes come from $lookup,
$validate-code, and $expand, never from the resource
(https://hl7.org/fhir/R4B/codesystem-content-mode.html). A system loaded as a
CodeSystem resource under FERROTERM_CODESYSTEMS answers with the content
it declared, so a complete one carries its concepts. A supplement is applied
to the system it supplements and is read and searched like any other
CodeSystem this server holds, with content = supplement and the
supplements canonical it declares
(https://hl7.org/fhir/R4B/codesystem.html#supplements).
Which artifact a code system came from
metadata?mode=terminology names the artifact behind each served version, so a
client can tell which built index answered without asking the operator. No FHIR
specification records that fact, so FerroTERM defines an extension for it, the
form FHIR provides for information outside a resource’s basic definition
(https://hl7.org/fhir/R4B/extensibility.html). It sits on
TerminologyCapabilities.codeSystem.version, which is where the fact belongs:
one artifact holds one version, and two SNOMED CT editions share one
codeSystem entry. Every served FHIR version carries it, because R4, R4B, R5,
and the R6 ballot all define that element as a BackboneElement.
The canonical is
https://ferroterm.eu/fhir/StructureDefinition/terminology-artifact, with two
string sub-extensions:
name: the artifact directory’s own name, without the directories above it.release: the release identifier the offline build wrote into the artifact’s manifest. For SNOMED CT this is the release date, for the others the release version.
{
"url": "https://ferroterm.eu/fhir/StructureDefinition/terminology-artifact",
"extension": [
{ "url": "name", "valueString": "int" },
{ "url": "release", "valueString": "20260901" }
]
}
The definition is the StructureDefinition at
https://ferroterm.eu/fhir/StructureDefinition/terminology-artifact, and the
JSON is
terminology-artifact.json.
A version that came from no artifact carries no extension: the registries the
binary holds (UCUM, BCP 13, BCP 47, ISO 3166) and any CodeSystem resource a
deployment loaded or persisted. Nothing about the operator’s filesystem and
nothing from the code system’s content reaches the wire here.
Which implicit value sets a code system resolves
metadata?mode=terminology also lists, per served version, the implicit value
set forms that version resolves. No element of TerminologyCapabilities says
which implicit value sets a server answers
(https://hl7.org/fhir/R5/terminologycapabilities.html), so FerroTERM defines a
second extension on TerminologyCapabilities.codeSystem.version, repeated once
per form, on R4, R4B, R5, and R6.
The canonical is
https://ferroterm.eu/fhir/StructureDefinition/implicit-value-set, with two
sub-extensions:
pattern(valueString): the value set URL template, spelled as the code system’s page spells it, with each placeholder in square brackets.argument(valueCode): what the placeholders take.nonemeans the pattern is the URL as it stands,codemeans the one placeholder takes a code of the system, andexpressionmeans the placeholders take free text such as an ECL expression or a UCUM unit.
{
"url": "https://ferroterm.eu/fhir/StructureDefinition/implicit-value-set",
"extension": [
{ "url": "pattern", "valueString": "http://snomed.info/sct?fhir_vs=isa/[sctid]" },
{ "url": "argument", "valueCode": "code" }
]
}
The definition is the StructureDefinition at
https://ferroterm.eu/fhir/StructureDefinition/implicit-value-set, and the
JSON is
implicit-value-set.json.
The forms declared today:
| System | Patterns |
|---|---|
| SNOMED CT | ?fhir_vs, ?fhir_vs=isa/[sctid], ?fhir_vs=refset, ?fhir_vs=refset/[sctid], ?fhir_vs=ecl/[ecl] on http://snomed.info/sct |
| LOINC | http://loinc.org/vs, /vs/[id] (an answer list), /vs/[partcode] |
| RxNorm | http://www.nlm.nih.gov/research/umls/rxnorm/vs |
| UCUM | http://unitsofmeasure.org/vs, /vs/[expression] |
| ICD-11 | [entity]/postcoordinationScale/[axis], on a linearization |
A version that resolves no implicit value set carries no such extension: the
ClaML classifications, BCP 13, BCP 47, ISO 3166, and every CodeSystem
resource. A supplemented system declares the forms of the system it
supplements. A declared form can still answer an error for one argument, for
example ?fhir_vs=refset on an edition that defines no reference sets.
Which levels an operation answers at
A CapabilityStatement records that a server answers an operation and never
at which level; OperationDefinition.system, .type and .instance carry
that (https://hl7.org/fhir/R4B/operationdefinition-definitions.html#OperationDefinition.instance).
metadata states the levels the version’s own definition declares as an
extension on CapabilityStatement.rest.resource.operation, repeated once per
level, on R4, R4B, R5 and R6. The canonical is
https://ferroterm.eu/fhir/StructureDefinition/operation-level, with a
valueCode of system, type or instance:
{
"name": "validate-code",
"definition": "http://hl7.org/fhir/OperationDefinition/CodeSystem-validate-code",
"extension": [
{ "url": "https://ferroterm.eu/fhir/StructureDefinition/operation-level", "valueCode": "type" },
{ "url": "https://ferroterm.eu/fhir/StructureDefinition/operation-level", "valueCode": "instance" }
]
}
The definition is the StructureDefinition at
https://ferroterm.eu/fhir/StructureDefinition/operation-level, and the JSON is
operation-level.json.
Languages
displayLanguage and the Accept-Language header both select the display
and, on $expand, the displays of the members; the parameter wins when both
are given. Either carries a language range list with quality values
(en, en-AU; q=0.4, de,*), and the first range the code system carries, by
quality then by position, is the display language; * is the system’s own.
A language the code system does not carry falls back to the system’s own
(English for LOINC and ICD-11, the classification’s language for a ClaML
document, the default language for SNOMED CT), and the response states what
it used. Designations carry their BCP 47 language and their use.
Persisted resources
A deployment that names a database in FERROTERM_RESOURCES accepts
CodeSystem, ValueSet, and ConceptMap resources over the REST API and
keeps them across restarts. The interactions are the ones the FHIR RESTful API
defines (https://hl7.org/fhir/R4B/http.html), under every version prefix:
| Request | Answer |
|---|---|
POST {type} | 201 Created with Location, ETag, and Last-Modified; the server assigns the id; 409 Conflict when another resource carries the url and version |
PUT {type}/{id} | 200 OK when the id existed, 201 Created when it is new, 409 Conflict when the id is one a loaded resource is read at or another resource carries the url and version |
GET {type}/{id} | 200 OK with ETag and Last-Modified, 404 for an unknown id or one this version has no representation of, 410 Gone for a deleted one |
GET {type}/{id}/_history/{versionId} | 200 OK with that version of the resource, 410 Gone for the version a delete made |
GET {type}/{id}/_history | a history Bundle of every version, newest first; empty for a loaded resource |
GET {type}/_history | a history Bundle of every version of every persisted resource of the type |
GET {type}?url=…&version=… | a searchset Bundle, entry.fullUrl absolute |
DELETE {type}/{id} | 204 No Content, and deleting again has no effect and is not an error |
meta.versionId starts at 1 and rises with every write; the ETag is its
weak form (W/"2"). Send If-Match with that value on a PUT or a DELETE
to make the write conditional; a value that names another version is a
412 Precondition Failed. A delete counts as a version too, so a resource
written again after a delete carries on from the version after it.
A history entry carries the version in resource, the interaction that made
it in request (POST, PUT or DELETE with its relative URL), and its
status, etag and lastModified in response; the delete has no resource
(https://hl7.org/fhir/R4B/http.html#history). _since keeps the versions
written at or after an instant. _count, _at and _list answer 400 with
a not-supported issue. A version written on a base whose release this base
cannot read keeps its response, with the reason in response.outcome, and
has no request, because an entry that names PUT or POST must carry the
resource.
A url and version identify one resource
(https://hl7.org/fhir/R4B/resource.html#canonical). A write whose pair
another resource of the same type already carries, one written through the API
or one the deployment loaded, answers 409 Conflict with an issue of code
duplicate naming that resource’s id. Update the resource that holds the pair,
or write the same url under a new version.
A read and a search both take _elements and _summary
(https://hl7.org/fhir/R4B/search.html#summary, https://hl7.org/fhir/R4B/http.html#read).
_elements=url,title returns those elements plus resourceType, id and
meta. _summary=true returns id, meta and the top-level elements the
version’s definition marks as summary elements (for a CodeSystem, url,
version, name, status and the rest of its metadata, without concept).
_summary=text returns text, id, meta and the top-level
elements the version’s definition makes mandatory, _summary=data drops
text, _summary=false is the whole resource, and on a search
_summary=count returns total and no entries. A resource that lost an
element carries the SUBSETTED tag, so do not write it back. A value outside
those five is a 400.
Each entry.fullUrl is the absolute URL of the resource
(https://hl7.org/fhir/R4B/bundle.html#bundle-unique): the base URL of
FERROTERM_BASE_URL plus the version’s own root, or, where a deployment
declares no base URL, the authority the request names. Declare the base URL
behind TLS or a path prefix, because a request carries no scheme. The bundle
also carries its self link, the URL the search was made at, which R5 and the
R6 ballot require of every searchset (bdl-18,
https://hl7.org/fhir/R5/bundle.html).
A resource is stored as the JSON it arrived as, with the FHIR version of the
endpoint that received it, and a read renders it through the reading version’s
own codec. A written resource is never converted between FHIR releases. R4B states a
target’s direction with concept-map-equivalence and R5 with
concept-map-relationship; equal, subsumes, specializes, inexact and
unmatched have no code in the R5 set
(https://hl7.org/fhir/R4B/valueset-concept-map-equivalence.html,
https://hl7.org/fhir/R5/valueset-concept-map-relationship.html), and HL7’s
R4-to-R5 transforms live in a cross-version implementation guide that is
informative and under development
(https://hl7.org/fhir/extensions/conversions-ConceptMap.html), so no core
specification states what a conversion should produce.
A search on a version that has no representation of a stored resource leaves
that resource out of its matches and adds one entry carrying an
OperationOutcome with search.mode of outcome, severity of warning,
code of not-supported, and text naming the resource and the version it was
written as (https://hl7.org/fhir/R4B/http.html#search); total counts the
matches, and the rest of the searchset answers as usual. Reading that resource
by id on that version is a 404: serving one release per base is the strategy
the specification describes as one where “the same record has a different
identity depending on the version of FHIR in use”
(https://hl7.org/fhir/R5/versioning.html), and a read answers 200, 404 or
410 and nothing else (https://hl7.org/fhir/R4B/http.html#read). The
OperationOutcome names the release the resource does read as, so read it
there.
Every operation sees a persisted resource exactly as it sees one loaded from
FERROTERM_CODESYSTEMS, on every served version.
A deployment that names no database refuses every write with a 422 and
declares no write interaction in its capability statement.
Primitive lexical forms on local content
A resource you write is decoded by the generated FHIR model of the version
you send it to, and since fhir-types 0.1.105 that decode checks every
primitive against the regex its FHIR package publishes for the type
(https://hl7.org/fhir/R5/datatypes.html#primitive). A date of
yesterday, a valueDateTime of 2001-6, a url with a space, or a
status with a leading space answers 400 with an OperationOutcome whose
issue.code is invalid and whose issue.expression names the element
(CodeSystem.date). The versions differ where their packages differ: R4,
R4B, and the R6 ballot require a timezone offset once a dateTime carries a
time, R5 leaves it optional. Nothing is stored that the version’s own codec
refuses, so a later read never meets a value it cannot parse.
Concept status on local content
A code you author has a lifecycle, and FHIR keeps it in two places. The
resource’s own standing is CodeSystem.status, so a system you are still
drafting is a draft CodeSystem; no version defines a per-concept draft. A
single concept states its standing in the standard concept properties
(https://hl7.org/fhir/R5/codesystem-concept-properties.html):
| Property | Type | What the server reads |
|---|---|---|
inactive | boolean | true makes the concept inactive |
status | code | retired makes the concept inactive; active, experimental, deprecated, and any other value leave it active |
retirementDate | dateTime | a date the request is behind makes the concept inactive |
deprecated, deprecationDate | dateTime | a date the request is behind marks the concept deprecated: it stays active and $validate-code adds the DEPRECATED_CONCEPT_FOUND warning, the same note the standards-status extension earns; status = deprecated does the same |
deprecated, deprecationDate | dateTime | returned as written, and the concept stays active |
R4 and R4B define inactive and deprecated, and R5 adds the rest. A
persisted resource is stored as it arrived, so the R5 properties are data on
every version prefix and are read the same way on R4, R4B, R5, and R6.
Deprecated is not inactive: the specification says concepts “that are
deprecated but not inactive can still be used, but their use is discouraged”.
Mark a concept inactive, or give it a status of retired, when it must
stop being chosen.
Three points no FHIR specification settles, which the server decides for
itself. Any marker that says inactive wins, so inactive = false beside a
status of retired reads as inactive. A retirementDate is compared against
the time of the request, and a value that is not a readable dateTime reads as
a retirement already in force, so a typo never leaves a retired code active. A
retirementDate of 2026 or 2026-03 takes effect at the start of the period
it names.
Retire a concept rather than delete it. An inactive concept keeps answering:
$expand with activeOnly=true leaves it out, activeOnly=false lists it
with contains.inactive = true, $validate-code answers result = true with
inactive = true and a warning, and $lookup returns the properties as
written. Removing the concept from the resource instead makes every record that
already carries the code fail validation. The status output of
$validate-code reports a status only where the resource stated one, so a
concept retired by a date alone answers inactive with no status.
SNOMED CT implicit concept maps
ConceptMap/$translate answers url=http://snomed.info/sct?fhir_cm=[sctid],
where the SCTID names a reference set of the loaded edition. The edition or
edition-version URI may take the place of the bare system URI; the map states
the served edition version either way
(https://hl7.org/fhir/R4B/snomedct.html, “Implicit Concept Maps”).
Two kinds of reference set answer. An association reference set maps a SNOMED concept to another SNOMED concept, and the FHIR SNOMED CT page fixes the relationship each one asserts:
| Reference set | SCTID | Relationship |
|---|---|---|
| POSSIBLY EQUIVALENT TO | 900000000000523009 | inexact |
| REPLACED BY | 900000000000526001 | equivalent |
| SAME AS | 900000000000527005 | equal |
| ALTERNATIVE | 900000000000530003 | inexact |
R5 and R6 have no equal or inexact code, so those become equivalent and
related-to on those versions, as the generated relationship vocabulary maps
them.
A map reference set maps a SNOMED concept to a code of another system through
mapTarget. No RF2 file records which system that code belongs to, so the
reference set itself says which scheme it maps to, and the group names that
system:
| Reference set | SCTID | Target system |
|---|---|---|
| ICD-10 extended map | 447562003 | http://hl7.org/fhir/sid/icd-10 |
| ICD-9-CM equivalence complex map | 447563008 | http://hl7.org/fhir/sid/icd-9-cm |
| ICD-O simple map | 446608001 | http://terminology.hl7.org/CodeSystem/icd-o-3 |
| CTV3 simple map | 900000000000497000 | http://terminology.hl7.org/CodeSystem/read-Codes |
| ICD-10-CM complex map (US Edition) | 6011000124106 | http://hl7.org/fhir/sid/icd-10-cm |
R4B needs the group to name a target system whenever the targets are real
codes: it may be left out only when the target value set names a single system,
or when every target equivalence is unmatched
(https://hl7.org/fhir/R4B/conceptmap-definitions.html#ConceptMap.group.target).
A map reference set outside the table, such as a national extension’s own map,
is answered with not-supported rather than a target Coding that carries a
code and no system. RF2 records no version of the target scheme, so the group
names the system alone.
The complex and extended map columns (mapGroup, mapPriority, mapRule,
mapAdvice, correlationId, mapCategoryId) travel as product parts of the
match. No specification says where those columns belong in a $translate
result; this placement is FerroTERM’s own design.
A $translate that names no url and finds no stored map falls back to the
historical associations of an inactive concept: the SAME AS and REPLACED BY
reference sets name what stands in its place, and each match names the implicit
map it came from. No specification asks for this fallback; it is FerroTERM’s
own design. An active concept has no successors.
A ?fhir_cm= naming a reference set the edition does not hold, or one that
maps nothing, is a not-found; a base that is not the served edition, or an
SCTID that does not parse, is a 400.
Batch
POST [base] with a Bundle of type batch runs every entry and answers a
batch-response with one entry per request, in the order they were sent
(https://hl7.org/fhir/R4B/http.html#transaction). The entries are
independent: one that fails answers the OperationOutcome that the same
request would have answered on its own, the rest still answer, and the batch
itself answers 200.
Each entry names its request the way it would have been sent on its own:
{"resourceType": "Bundle", "type": "batch", "entry": [
{"request": {"method": "GET", "url": "CodeSystem/$lookup?system=http://loinc.org&code=1963-8"}},
{"request": {"method": "POST", "url": "ValueSet/$validate-code"},
"resource": {"resourceType": "Parameters", "parameter": [
{"name": "url", "valueUri": "http://loinc.org/vs"},
{"name": "coding", "valueCoding": {"system": "http://loinc.org", "code": "1963-8"}}
]}}
]}
A GET entry carries the operation’s inputs in the query of request.url; a
POST entry carries them in a Parameters resource on the entry. Every
terminology operation this server answers is reachable, at the type level and
at the instance level, and entry.fullUrl comes back on the response entry so
a client can pair the two. response.status is the code and its reason, such
as 200 OK or 404 Not Found.
A transaction Bundle is refused with not-supported: a transaction succeeds
or fails as one unit across its entries, and this server does not process one.
A failed entry carries its OperationOutcome as the entry’s resource, the
body the same request would have answered alone. entry.response.outcome is
reserved by the specification for hints and warnings and is “not used for error
responses in batch/transaction”, and no version names another slot for the
error, so this placement is FerroTERM’s reading of an ambiguous rule.
Closure tables
POST [base]/$closure maintains a named transitive closure table for a client
(https://hl7.org/fhir/R4B/terminology-service.html, “Maintaining a Closure
Table”). The client registers concepts as it meets them and the server answers
with the subsumption relationships that hold between them, as a ConceptMap
delta. Any code system with subsumption maintains a closure, not only SNOMED CT.
| Request | Answer |
|---|---|
name alone | the table, created or emptied, as an empty ConceptMap at version 0 |
name and one or more concept | a ConceptMap of the relationships the client did not have, at a new version |
name and version | everything the server sent after that version, at the server’s latest version |
A version of 0 resynchronises the whole table. Pass a concept or a
version, never both. The version is the server’s own value: treat it as
opaque and hand it back unchanged.
An entry’s equivalence is read from target to source, so subsumes means the
target subsumes the source. R4 and R4B use equal, subsumes, and
specializes; R5 states the same relationships as equivalent,
source-is-narrower-than-target, and source-is-broader-than-target. A concept
is never related to itself, and a client assumes that relationship. Two concepts
that subsume neither way get no entry.
Naming a table the server has not been asked to create is a 404; an add never
creates one. Registering a concept whose code system changed under the table
answers 422 with closure "[name]" must be reinitialized, and the client’s
move is to initialise and replay its codes.
The R6 ballot ships no ConceptMap-closure definition, so /r6 offers no
$closure and its capability statement declares none. The tables live in the
database FERROTERM_RESOURCES names, so they outlive a restart.
Value sets, implicit value sets, and ECL
A value set names a set of codes. FerroTERM answers value sets defined as FHIR resources, brought with the request, or implied by a URL a code system defines. This page covers what is answered today and what the ECL milestone adds.
- Implicit value sets today
- The SNOMED CT implicit value sets
- ECL
- Post-coordinated expressions
- Paging a large expansion
Implicit value sets today
Several code systems define value sets by URL convention, without a stored resource, and their providers answer them:
| Code system | URL | Members |
|---|---|---|
| LOINC | http://loinc.org/vs | every code |
| LOINC | http://loinc.org/vs/LL… | the answers of the answer list |
| LOINC | http://loinc.org/vs/LP… | everything under the part in the multiaxial hierarchy |
| UCUM | http://unitsofmeasure.org/vs | every valid unit (validation only; it cannot be enumerated) |
| RxNorm | http://www.nlm.nih.gov/research/umls/rxnorm/vs | every RXCUI |
| ICD-11 | <entity URI>/postcoordinationScale/<axis> | the values a stem takes on that axis |
Pass the URL as url to ValueSet/$expand or ValueSet/$validate-code.
The SNOMED CT implicit value sets
The FHIR SNOMED CT page defines value sets by URL over the SNOMED CT URI (https://hl7.org/fhir/R4B/snomedct.html, “Implicit Value Sets”), and the provider answers every form:
| URL | Members |
|---|---|
http://snomed.info/sct?fhir_vs | every concept of the edition |
http://snomed.info/sct?fhir_vs=isa/[sctid] | the concept and its descendants, from the transitive closure |
http://snomed.info/sct?fhir_vs=refset | the concepts that are reference sets with concept members |
http://snomed.info/sct?fhir_vs=refset/[sctid] | the active concept members of the reference set |
http://snomed.info/sct?fhir_vs=ecl/[expression] | the concepts the expression constraint selects; the expression is URI-encoded |
The base may be the edition URI (http://snomed.info/sct/11000146104?fhir_vs=…)
or the edition and version URI, which pins the expansion to that served
version; another edition’s URI is refused. An unknown SCTID or a concept that
is not a reference set is vs-invalid with the code named. The reference set
memberships come from every RF2 reference set whose members are concepts (the
simple, association, attribute value, and map reference sets); the language
reference sets are read as acceptabilities instead.
The same sets are reachable through a compose, inline or loaded, with the
filters the provider declares: concept is-a [sctid], concept descendent-of [sctid], and concept in [refset] (reference set membership, as the page
defines in for SNOMED CT), plus the generic is-not-a, generalizes,
not-in, and regex:
{
"resourceType": "ValueSet",
"status": "active",
"compose": {
"include": [{
"system": "http://snomed.info/sct",
"filter": [{ "property": "concept", "op": "in", "value": "31000147101" }]
}]
}
}
ECL
An expression constraint names a set of concepts, and the evaluator answers it
as set algebra over the precomputed structures: << X is the descendant
bitmap plus X, ^ X the members of the reference set, a refinement such as
< 404684003 : 363698007 = << 39057004 the sources of the attribute with a
value in the set (from an inverted index), a grouped refinement counts the
role groups that satisfy it, and AND, OR, and MINUS are bitmap
operations. The parser follows the official ANTLR grammar for ECL 2.2 rule
for rule and is tested against the published example corpus. Every operator
evaluates, as do the concept filters, the history supplements, and alternate
identifiers. Four filters are refused because the artifact does not carry what
they ask for: a member filter on inactive members, an acceptability other than
preferred or acceptable, and the description module and effective time
filters. Each is refused by name rather than answered wrongly.
ECL reaches the wire two ways: the implicit value set
http://snomed.info/sct?fhir_vs=ecl/[expression] (the expression URI-encoded,
on the system, edition, or version URI) and the constraint filter of a
ValueSet.compose.include:
{ "property": "constraint", "op": "=", "value": "< 404684003 |Clinical finding| : 363698007 |Finding site| = << 39057004" }
Malformed ECL is an OperationOutcome with issue code invalid naming the
byte offset; an identifier the edition does not have is code-invalid; a
construct the artifact cannot answer (a description module or effective time
filter, a filter on inactive members) is not-supported.
Post-coordinated expressions
A SNOMED CT expression in Compositional Grammar is a valid code and is
“subject to the same rules as precoordinated concepts”
(https://hl7.org/fhir/R4B/snomedct.html), so you send one wherever you send a
concept id:
POST [base]/CodeSystem/$validate-code
{"resourceType":"Parameters","parameter":[
{"name":"url","valueUri":"http://snomed.info/sct"},
{"name":"code","valueCode":"83152002 |Oophorectomy| : 405815000 |Procedure device| = 122456005 |Laser device|"}]}
Send an expression by POST. It carries spaces, pipes, and braces, so a GET query string has to percent-encode every one of them.
$validate-codechecks the syntax and that the version defines every concept the expression names. A syntax error answersfalsewith the byte the parser stopped at; an unknown concept answersfalsenaming it.$lookupanswers the expression in one canonical order ascode, and adisplaythat is the expression with the edition’s terms written in, since SNOMED International publishes no terms for expressions.normalFormandnormalFormTerseanswer the expression’s necessary normal form, and the expression’s own refinement comes back as the concept-model properties keyed by attribute concept id. An expression over an inactive concept validates withinactive = true, the way an inactive concept does.$subsumestakes an expression ascodeA, ascodeB, or as both.$closurerelates an expression to the concepts a table already holds.
The expressions filter decides whether a filtered include admits one:
{"property": "expressions", "op": "=", "value": "true"}. A filtered include
that states false, or states no expressions filter at all, refuses an
expression; one that states true admits an expression whose focus concepts it
contains. An include that enumerates an expression in
compose.include.concept contains it whichever way either side spells it, and
carries no filter at all, since vsd-3 forbids one beside enumerated concepts.
An expansion lists only the expressions a value set enumerates, and
excludePostCoordinated=true drops those; a filtered selection stays the
valueset-unclosed case.
Three boundaries are deliberate. No concept model (MRCM) check runs, so an
expression the concept model forbids still validates. No close-to-user
transformation runs, so an expression is compared as written. Subsumption is
decided over the edition’s inferred view without a description logic
classifier: what the server answers subsumes or subsumed-by holds, and
what it answers not-subsumed is what that view does not state. SNOMED
International withdrew normal-form comparison as a subsumption test in the
July 2019 International Edition and directs implementers to a classifier
(https://docs.snomed.org/snomed-international-documents/snomed-ct-glossary/n/normal-form).
Paging a large expansion
A broad expansion names a large set, so $expand pages with count and
offset and refuses an unpaged expansion beyond 1,000 members with
too-costly. expansion.total reports the full size, expansion.offset the
page, and the order is deterministic across calls.
FHIR versions
FerroTERM answers FHIR R4 under /r4, R4B under /r4b, R5 under /r5, and
the R6 ballot (6.0.0-ballot5) under /r6, marked ballot-tracking.
Why one server can serve four versions
HL7 publishes the whole type system and every operation as machine-readable resources, in versioned packages. FerroTERM vendors and pins those packages and generates per-version Rust modules from them, so each version’s operation surface is correct by construction. An operation parameter that R5 adds appears in the R5 module and is absent from R4 and R4B, because the generator emits what each package declares. The engine beneath is version-neutral; a version’s endpoint maps the engine’s answers into that version’s shapes. R4 and R4B declare the same terminology parameters and elements, so one set of macros instantiates both endpoints from their own generated modules and they cannot drift apart.
Version status
| Version | Package | Status |
|---|---|---|
| R4 | hl7.fhir.r4.core 4.0.1 | served under /r4 |
| R4B | hl7.fhir.r4b.core 4.3.0 | served under /r4b |
| R5 | hl7.fhir.r5.core 5.0.0 | served under /r5 |
| R6 | hl7.fhir.r6.core 6.0.0-ballot5 | served under /r6, marked ballot-tracking |
R4B is the current stable release of the R4 line and a near-superset of R4;
both endpoints serve the same engine, and a $cache-control cache started on
one serves the other. The R5 shapes matter most to validators and IG tooling
($validate-code issues and the validated code, system, and version;
$lookup definition; $expand property), and /r5 emits them; the R4
and R4B endpoints emit only what their own definitions declare.
What this means for you
Send R4 requests to /r4, R4B requests to /r4b, R5 requests to /r5, and
R6 ballot requests to /r6. GET {base}/$versions names the version of the
base (4.0, 4.3, 5.0, or 6.0), GET {base}/metadata is that version’s
CapabilityStatement, and a parameter the version does not declare is refused
with an OperationOutcome rather than absorbed.
The /r6 endpoint answers in the ballot’s shapes. Its capability statement
says which ballot it serves and that every behaviour grounded only in the
ballot is re-verified against the published specification when it appears. A
ballot parameter the server does not implement yet (manifest,
filterProperty) is refused as not-supported, never absorbed.
Worked examples
The requests below were sent to v0.1.0 serving the public ICD-10-CM FY2026 release, the RxNorm prescribable subset, and the built-in UCUM; the responses are what the server returned, trimmed only where marked. Substitute the code system you load.
- $lookup a concept
- $subsumes two concepts
- $validate-code with a wrong display
- $expand an inline value set with a code system filter
- $lookup a unit, with nothing loaded
- An error
$lookup a concept
GET /r4b/CodeSystem/$lookup?system=http://hl7.org/fhir/sid/icd-10-cm&code=E11.9&property=parent
{
"resourceType": "Parameters",
"parameter": [
{ "name": "name", "valueString": "International Classification of Diseases, Tenth Revision, Clinical Modification" },
{ "name": "version", "valueString": "2026" },
{ "name": "display", "valueString": "Type 2 diabetes mellitus without complications" },
{ "name": "designation", "part": [
{ "name": "language", "valueCode": "en" },
{ "name": "use", "valueCoding": { "system": "http://hl7.org/fhir/sid/icd-10-cm", "code": "preferred" } },
{ "name": "value", "valueString": "Type 2 diabetes mellitus without complications" } ] },
{ "name": "property", "part": [
{ "name": "code", "valueCode": "parent" },
{ "name": "value", "valueCode": "E11" } ] }
]
}
Without property every property comes back: the class kind, the notes
(excludes1, codeAlso, …), parent, child, and valid.
$subsumes two concepts
GET /r4b/CodeSystem/$subsumes?system=http://hl7.org/fhir/sid/icd-10-cm&codeA=E11&codeB=E11.9
{ "resourceType": "Parameters", "parameter": [ { "name": "outcome", "valueCode": "subsumes" } ] }
$validate-code with a wrong display
GET /r4b/CodeSystem/$validate-code?url=http://hl7.org/fhir/sid/icd-10-cm&code=E11.9&display=Diabetes
{
"resourceType": "Parameters",
"parameter": [
{ "name": "result", "valueBoolean": false },
{ "name": "message", "valueString": "the display `Diabetes` is not a designation of `E11.9`" },
{ "name": "display", "valueString": "Type 2 diabetes mellitus without complications" }
]
}
$expand an inline value set with a code system filter
The drugs containing aspirin (RXCUI 1191), through RxNorm’s has_ingredient
filter, two at a time:
POST /r4b/ValueSet/$expand
Content-Type: application/fhir+json
{
"resourceType": "Parameters",
"parameter": [
{ "name": "valueSet", "resource": {
"resourceType": "ValueSet", "status": "active",
"compose": { "include": [ {
"system": "http://www.nlm.nih.gov/research/umls/rxnorm",
"filter": [ { "property": "has_ingredient", "op": "=", "value": "CUI:1191" } ] } ] } } },
{ "name": "count", "valueInteger": 2 }
]
}
The response is the expanded ValueSet (metadata trimmed):
{
"resourceType": "ValueSet",
"expansion": {
"total": 93,
"parameter": [
{ "name": "count", "valueInteger": 2 },
{ "name": "used-codesystem", "valueUri": "http://www.nlm.nih.gov/research/umls/rxnorm|09082026" }
],
"contains": [
{ "system": "http://www.nlm.nih.gov/research/umls/rxnorm", "code": "1153231", "display": "aspirin / caffeine / orphenadrine Oral Product" },
{ "system": "http://www.nlm.nih.gov/research/umls/rxnorm", "code": "1153232", "display": "aspirin / caffeine / orphenadrine Pill" }
]
}
}
$lookup a unit, with nothing loaded
GET /r4b/CodeSystem/$lookup?system=http://unitsofmeasure.org&code=mg/dL
{
"resourceType": "Parameters",
"parameter": [
{ "name": "name", "valueString": "Unified Code for Units of Measure (UCUM)" },
{ "name": "version", "valueString": "2.2" },
{ "name": "display", "valueString": "mg/dL" },
{ "name": "designation", "part": [
{ "name": "language", "valueCode": "en" },
{ "name": "value", "valueString": "milligram per deciliter" } ] },
{ "name": "property", "part": [
{ "name": "code", "valueCode": "canonical" },
{ "name": "value", "valueCode": "m-3.g" } ] }
]
}
An error
An ICD-10-CM code without its period is not a code (the FHIR ICD page
requires the period), so the server answers 400 with an OperationOutcome:
GET /r4b/CodeSystem/$lookup?system=http://hl7.org/fhir/sid/icd-10-cm&code=E119
{
"resourceType": "OperationOutcome",
"issue": [ {
"severity": "error",
"code": "not-found",
"details": {
"coding": [ { "system": "http://hl7.org/fhir/tools/CodeSystem/tx-issue-type", "code": "invalid-code" } ],
"text": "code `E119` is not in code system `http://hl7.org/fhir/sid/icd-10-cm` version `2026`"
}
} ]
}
See The FHIR terminology API for the operation list and Value sets and ECL for the implicit value set URLs.
Build and test
This page is a short pointer for contributors. The deeper design and the working discipline live in the repository, and this book keeps to what you need to get building.
- Read these first
- The workspace
- The local gates
- Conformance in CI
- The two layers
- No code system content in the repository
- Correctness oracles
Read these first
docs/architecture.md: the design authority, with citations.docs/terminologies.md: every code system, its FHIR page, its licence, and what the provider serves.CONTRIBUTING.md: the contribution rules, branches, commit signing, and pull-request checklist.
The workspace
One Cargo workspace, Rust 1.98, edition 2024: crates/* are the libraries
(rf2, loinc, classification, rxnorm-rrf, icd11 the release readers;
concept-graph, concept-store, designation-index the substrates;
fhir-terminology the engine and providers), app/ferroterm-server is the
axum server, and tools/* are the offline build and the synthetic test
fixtures. Every crate carries a CLAUDE.md with its
local discipline.
The local gates
The local gates mirror CI. Run them before you open a pull request:
$ cargo fmt --all --check
$ cargo clippy --workspace --all-targets --all-features -- -D warnings
$ cargo nextest run --workspace --locked
$ RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items
$ cargo deny check
$ scripts/checks/comment-style.sh --all
Scope a command to a crate with -p <crate> while you iterate, then run the
full --workspace gates before you push. Cargo.lock is committed and CI
builds with --locked. Shell scripts stay clean at shellcheck; workflows at
actionlint and zizmor.
Conformance in CI
scripts/checks/tx-ecosystem.sh runs the HL7 terminology ecosystem suite
against a release build of the server with the FHIR Validator’s txTests
mode (the one place a JVM runs, in CI, never in the server) and compares the
result with the committed pass list under conformance/tx-ecosystem/. A
listed test that fails is a regression; a test that starts passing is added in
the same change. --mode icd-11 --index <dirs> runs a code system mode by
hand over local artifacts.
The two layers
- The FHIR model is the generated
fhir-typescrate, taken from crates.io and generated in another repository. Never re-model a FHIR shape locally. See The FHIR model. - Everything else is hand-written, idiomatic Rust, with the FHIR and SNOMED specifications as the authority: a conformance-facing test cites the spec clause it asserts, and a decision the specs leave open is marked as the project’s own.
No code system content in the repository
Tests use shaped, synthetic content only: the testkit writes a small
SNOMED-shaped edition, a LOINC-shaped release, ICD-shaped ClaML and CMS files,
an RxNorm-shaped RRF release, and an ICD-11-shaped API cache, all with
invented identifiers. Never commit a real release or content extracted from
one. See Loading code systems for the
licensing rule; a licensed release for local runs lives under the gitignored
data/ directory.
Correctness oracles
The specifications are the oracle. Reference servers (Snowstorm, the Dutch national server, tx.fhir.org) settle edge cases the specifications leave silent, and a divergence from them is investigated, never adopted blindly.
The FHIR model
The FHIR layer of FerroTERM is generated, not hand-written, and it is generated in another repository. This page explains what that means for a contributor.
Why the FHIR layer is generated
HL7 publishes the whole FHIR type system and every operation as machine-readable
StructureDefinition and OperationDefinition resources, in versioned packages.
Rust modules are generated per version from those packages, so each version’s
operation surface is correct by construction: a parameter that R5 adds appears
in the R5 module because the R5 package declares it.
Where it comes from
The model is the fhir-types crate. The
FerroBRIDGE repository vendors
the pinned HL7 packages, runs the generator, and publishes the crate.
FerroTERM depends on it from crates.io, at the version docs/VERSIONS.md pins
and the root Cargo.toml requires. No HL7 package is vendored in this
repository.
The rules for a contributor
- Consume the generated types directly. Never re-model or re-serialize FHIR by hand, and never shadow a generated shape with a local type, an adapter layer, or a placeholder value.
- A shape that is wrong or missing is fixed upstream. Open it on the FerroBRIDGE tracker; the fix is a generator change there, released as a new version of the crate, then taken here.
- Taking a new release moves three things in one change: the requirement in
the root
Cargo.toml, the pin row indocs/VERSIONS.md, andCargo.lock.scripts/checks/versions.shfails when they disagree, so the served model and the recorded pin cannot drift apart. Dependabot opens that pull request by itself when a release lands.
The generator design follows the sibling project FerroEHR, which generates its openEHR model from vendored machine-readable specs the same way.