Terminology servers
openEHR records carry coded values: a diagnosis, a route of administration, a laboratory unit. Some codes come from openEHR’s own terminology; others must be checked against an external code system such as SNOMED CT or LOINC. FerroEHR serves the bundled openEHR terminology in-process, and asks one FHIR terminology server, FerroTERM, about every other code system.
- The bundled openEHR terminology
- The terminology server
- FerroTERM beside the CDR
- Running one locally (development and CI)
- On Kubernetes
- Terminology in AQL
The bundled openEHR terminology
The server ships the openEHR terminology bundle (Terminology 3.1.0) and uses it by default, with no configuration and no external dependency. It answers the questions the platform needs while validating and querying: which terminologies exist, whether a code belongs to one, what a term’s rubric is, whether one code subsumes another, and whether a code is a member of a value set.
Enumeration is always the bundle’s job. A lookup or a membership test goes to the bundle when the bundle knows that terminology, and only otherwise to the terminology server.
Exposing the lookups over REST
You can also expose these lookups over a small read-only REST surface. It is a
FerroEHR extension (no openEHR REST contract defines a terminology API) and it
is off by default; while disabled, every route answers 404 as if it were
not mounted. Turn it on with FERROEHR__TERMINOLOGY__API_ENABLED=true and it
serves:
| Method | Path | Purpose |
|---|---|---|
GET | /terminology | list terminologies |
GET | /terminology/{terminology_id} | describe one terminology |
GET | /terminology/{terminology_id}/term/{code} | look up a term |
GET | /terminology/{terminology_id}/subsumes?ref_code=&candidate= | subsumption test |
GET | /terminology/{terminology_id}/value_set/{value_set_id} | get a value set |
GET | /terminology/{terminology_id}/value_set/{value_set_id}/validate?candidate_code=&at_date= | test membership |
All paths are relative to the extensions root, /ferroehr/rest/extensions/v1,
where FerroEHR serves its own extensions beside the openEHR API. An
unknown terminology or value set is a 404; a missing required query parameter
is a 400.
The terminology server
Note
The CDR is only ever a client of the terminology server. FerroEHR does not implement one: you run a FHIR terminology server and point the CDR at it by URL. The one that ships with the product is FerroTERM, started by the quickstart’s terminology overlay; any other server speaking the FHIR terminology operations (Ontoserver, Snowstorm, HAPI FHIR) works the same way.
The terminology server is off by default, and while it is off nothing is
requested: validation uses the in-process bundle alone. The keys live under
[terminology.server]; the full table, including the timeout and OAuth2, is on
Integrations. The minimum
is the switch and the server’s FHIR R4B base URL:
[terminology.server]
enabled = true
url = "http://ferroterm:8080/r4b"
Enabling the server with no URL is a boot error, not a silent fall back to the bundle. So is an incomplete OAuth2 grant: a control you configured either works or the server refuses to start.
One server answers every terminology the bundle does not carry. openEHR puts the
resolver of an archetype constraint binding in a “terminology query server”
outside the CDR, and FerroTERM serves SNOMED CT, LOINC, ICD and any FHIR
CodeSystem or ValueSet from one process, so the CDR holds one client to one
server.
What it changes at commit time
The mechanism is openEHR’s own archetype constraint binding. Where a template
binds an archetype constraint code (an ac code) to a query against an
external terminology, the specification puts the resolver outside the CDR: the
archetype holds an identifier for a query, and the query itself is defined in the
terminology server.
With the terminology server enabled, committing a composition resolves those bindings for every bound coded value the instance actually carries:
- The code is in the value set → the commit proceeds.
- The code is not in the value set →
422, naming the path, the code, and the bound query. That is a real constraint violation, sofail_on_errordoes not change it. - The value set could not be resolved →
fail_on_errordecides, see below.
Only bound external queries leave the process. openEHR and the bundle’s own local terminologies are still answered in-process.
Warning
The composition’s
terminology_idtravels verbatim as the FHIRsystemparameter, and no openEHR specification defines a mapping betweenterminology_idvalues (SNOMED-CT) and FHIR system URIs (http://snomed.info/sct). If your archetypes and your terminology server disagree on that spelling, align them in the terminology-server configuration. The CDR does not rewrite the value.
What it changes when an ADL 2 archetype is uploaded
An ADL 2 archetype’s term_bindings are checked too (AOM2 validity rule
VETDF). A binding target is a URI in the IHTSDO model, …/id/<code>, and the
CDR takes it apart before asking: http://snomed.info/id/50121007 (or the
snomedct.info host the ADL 2 specification’s own examples use) is asked as
system=http://snomed.info/sct, code=50121007, per the SNOMED CT URI Standard;
http://loinc.org/id/LA6742-6 as system=http://loinc.org; any other …/id/…
URI as the URI before /id/. The outer binding key (SNOMED-CT, Snomed,
LOINC, however the author spelled it) is not sent. The server’s
answer then decides:
- the code exists → accepted;
- the code system is served and the code is not in it →
422, naming the rule, the binding and the code; - the code system is not served by the terminology server (or the server cannot say) → accepted with a warning in the log. AOM2 puts it this way: codes for inaccessible terminologies “should be flagged with a warning indicating that no verification was possible”, so a CDR wired at a server that carries only LOINC still stores archetypes with SNOMED CT bindings.
The same rule holds with no terminology server configured at all: nothing is asked and nothing is refused.
Which FHIR operation is used
Membership is tested with ValueSet/$validate-code, one direct yes/no with the
least payload; $expand serves a value set’s contents, $subsumes a
subsumption test and $lookup a term. Every call is a round trip to the
server, which keeps no answer the CDR would have to invalidate.
The CDR speaks to the server through
ferrobridge-term, FerroBRIDGE’s
FHIR terminology client, over the FHIR R4B operation definitions. Each
operation is a POST of a Parameters resource to the operation’s path under
the configured base URL, so a code never travels in a URL. The at_date of a
term lookup or a membership test is sent as the operation’s date input. Each
call is made once, without a retry, so request_timeout_ms bounds the whole
call on the commit path.
A response that does not fit the operation’s R4B definition is treated as an
upstream fault rather than partially read, and takes the same path as an
unreachable server: a $lookup answer without its name or display, an
$expand answer without an expansion, a $subsumes outcome outside its four
codes, or a body of another resource type. The server’s refusals are read
through the tx-issue-type coding of their OperationOutcome: not-found
means the server does not serve the code system or value set, invalid-code
that the code is not in it, and a 404 or 410 without either coding that the
thing asked about is unknown.
Authenticating to the server
A server behind an authorization server takes an OAuth2 client-credentials
grant under [terminology.server.oauth]; the token is cached and renewed shortly
before it expires. The client secret should come from a mounted file rather than
an inline value, client_secret_file. The keys are on
Integrations.
There is no option to disable certificate verification. Server-certificate and hostname verification are always on against the platform’s trust store.
When the terminology server cannot answer
fail_on_error decides what happens when a bound value set cannot be resolved at
all, whether the server is unreachable, answers an error, or does not know the
value set:
false(the default, fail-open): the composition is accepted and a warning is logged. The availability of an external service does not block clinical writes.true(fail-closed): the composition is rejected with a validation error naming the unresolved binding.
A code that is resolved and turns out not to be a member is a different matter: that is a constraint violation and is rejected under either setting.
Pick deliberately. Fail-closed means commits stop while your terminology server is down; fail-open means they are accepted unvalidated. There is no third option that gives you both.
FerroTERM beside the CDR
FerroTERM is the terminology server
FerroEHR ships with: the docker-compose.terminology.yml overlay of the
quickstart starts it beside the CDR and points [terminology.server] at
http://ferroterm:8080/r4b, the compose page
has the commands. The hosted sandbox at https://sandbox.ferroehr.eu runs the
same pair on one machine, so the coded-text binding you see resolving there is a
real round trip to a real terminology server.
What the sandbox shows, and how it is arranged:
- The server is not on the public surface. FerroTERM answers on the
sandbox’s private compose network alone; there is no route to it at
sandbox.ferroehr.eu, its own browser UI is off, and the CDR is its only caller. You exercise it through the CDR: commit a composition against thecnf.tpl.dv_coded_text_binding_scttemplate with a code inside its bound value set and it is accepted, with a code outside it and the answer is422; the/terminology/*routes look codes up; AQLTERMINOLOGY()expands a value set into aMATCHESoperand. - Fail-open, the shipped default: a binding the server cannot resolve is accepted. The sandbox declares this posture and the register entry that records why neither posture is spec-mandated (AMB-172).
- The content is the shaped seed: two code systems and two value sets under
the reserved
example.testdomain, with no licensed terminology in them.
SNOMED CT on a public sandbox
Serving SNOMED CT to the public is a licensed activity, and the arrangement above is what the SNOMED CT Affiliate Licence Agreement (April 2023, snomed.org/get-snomed) asks for. Clause 2.2.4 permits systems “made available to the general public for accessing and/or retrieving any part of the International Release and/or data encoded using the foregoing”, provided users “are not able to extract any substantial portion of SNOMED CT” and no fee is charged for access. Clause 2.7 requires “reasonable measures to ensure that the International Release (and any part of it) cannot be accessed or downloaded from the Licensee’s systems except by authorised users”. A raw FHIR terminology endpoint open to anonymous callers would let anyone walk a code system or expand large value sets; per-code operations through the CDR, behind its rate limit, do not.
The sandbox serves the SNOMED CT International Edition, release 20260901
(http://snomed.info/sct/900000000000207008/version/20260901), loaded by the
operator under their Affiliate Licence from an index built off the machine. The
International Edition rather than a national one: it needs the Affiliate Licence
alone (a Member’s national release also needs an agreement with that Member), its
English displays fit an international audience, and it is the lighter of the two.
Every surface showing that content carries the notice the licence prescribes:
This material includes SNOMED Clinical Terms® (SNOMED CT®) which is used by permission of the International Health Terminology Standards Development Organisation (IHTSDO). All rights reserved. SNOMED CT®, was originally created by The College of American Pathologists. “SNOMED” and “SNOMED CT” are registered trademarks of the IHTSDO.
No SNOMED CT content is in this repository, in any image, or in CI; the index exists only on the sandbox machine. This is a description of the arrangement, not legal advice: the licence text is the authority and the Member’s conditions apply in each territory.
LOINC on the sandbox
The sandbox also serves LOINC version 2.83 (http://loinc.org), from an
index built the same way. The LOINC licence
(loinc.org/license) grants use and distribution
“for any commercial or non-commercial purpose” without fees and names “online
terminology services” among the permitted products, on three conditions this
deployment meets: the notice below is available where the service’s terms are
stated, every LOINC code is shown with one of its LOINC display names (the FHIR
operations return the long common name), and the version is stated. Where a
LOINC record carries a third-party copyright notice of its own, that notice
travels with the record.
This material contains content from LOINC (http://loinc.org). LOINC is copyright © Regenstrief Institute, Inc. and the Logical Observation Identifiers Names and Codes (LOINC) Committee and is available at no cost under the license at http://loinc.org/license. LOINC® is a registered United States trademark of Regenstrief Institute, Inc.
Neither index is in the repository, an image or CI; both exist only on the sandbox machine, beside the licence-free shaped seed the compose profile ships.
Running one locally (development and CI)
The quickest local terminology server is the quickstart’s
terminology overlay:
FerroTERM beside the CDR, the shaped seed served, the CDR wired, one command
and no checkout. The conformance lane runs the same server from a checkout of
the repository, with the shaped seed mounted read-only out of
docker/terminology/seed/:
docker compose -p ferroehr-cnf --project-directory . --profile terminology \
-f docker/sut-ferroehr.yml -f docker/sut-terminology.yml up -d --wait ferroehr
The profile starts FerroTERM (host port 8090 by default,
FERROEHR_TERMINOLOGY_PORT), which reports healthy only once the mounted code
systems and value sets are served, and the CDR waits on that. The overlay file
is what points the CDR at it, by switching on the [terminology.server] the
development configuration already carries in the disabled state.
None of this touches the downloadable quickstart Compose file on its own, which uses the in-process openEHR terminology only; the terminology overlay is the opt-in that adds FerroTERM to it.
Warning
The seeded content is synthetic and lives under the reserved
example.testdomain: one hierarchical code system shaped like SNOMED CT and one shaped like LOINC, each with an enumerated value set. It carries no licensed terminology content. Point the CDR at a real server, and for SNOMED CT hold the appropriate licence, for anything beyond experimentation.
Tip
Serving your own content is the same shape: FerroTERM loads every FHIR
CodeSystemandValueSetin the directoryFERROTERM_CODESYSTEMSnames, and serves them read-only. A value set it does not hold is an unresolved binding, which yourfail_on_errorsetting then decides the fate of.
On Kubernetes
terminology.enabled=true runs FerroTERM in the cluster and wires the CDR at it
(FerroTERM on Kubernetes).
A server outside the cluster goes through the config passthrough, which
renders the keys verbatim into the server’s configuration file
(Any server setting is reachable):
# values.yaml
config:
terminology:
api_enabled: true
server:
enabled: true
url: https://tx.example.org/fhir
fail_on_error: true # fail-closed: an unresolvable binding rejects the commit
secrets:
# only when the server sits behind an authorization server; the grant itself
# goes under config.terminology.server.oauth
terminologyClientSecret: ""
Before you enable it: a reachable terminology server, a decision on
fail_on_error, and (if the chart’s default-deny egress policy is on) an
egress rule that admits the server, or every call fails as a timeout.
To turn it off, set config.terminology.server.enabled: false; validation
falls back to the in-process bundle and no external call is made.
Terminology in AQL
Query authors can constrain a match to a value set with the AQL TERMINOLOGY()
function: TERMINOLOGY('expand', …) resolves a value set and merges its codes
into a matches list at query-analysis time, so the planner sees an ordinary
value list. A terminology://… operand in a matches list is expanded the same
way. See Querying with AQL for the query surface.
Operations with no defined comparison semantics in AQL are typed rejections rather than silent wrong answers.