Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 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:

MethodPathPurpose
GET/terminologylist 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, so fail_on_error does not change it.
  • The value set could not be resolved → fail_on_error decides, 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_id travels verbatim as the FHIR system parameter, and no openEHR specification defines a mapping between terminology_id values (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 the cnf.tpl.dv_coded_text_binding_sct template with a code inside its bound value set and it is accepted, with a code outside it and the answer is 422; the /terminology/* routes look codes up; AQL TERMINOLOGY() expands a value set into a MATCHES operand.
  • 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.test domain, 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.test domain: 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 CodeSystem and ValueSet in the directory FERROTERM_CODESYSTEMS names, and serves them read-only. A value set it does not hold is an unresolved binding, which your fail_on_error setting 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.