Skip to content

fix(fhirpath): remove the default terminology server (#217)#262

Draft
mauripunzueta wants to merge 2 commits into
mainfrom
fix/217-fhirpath-default-tx-server
Draft

fix(fhirpath): remove the default terminology server (#217)#262
mauripunzueta wants to merge 2 commits into
mainfrom
fix/217-fhirpath-default-tx-server

Conversation

@mauripunzueta

Copy link
Copy Markdown
Contributor

Closes #217.

The decision

Issue #217 asked us to choose between three options for the FHIRPath evaluator's hardcoded default terminology server (evaluator.rs:636-661). This PR implements "configurable with no network default", and explicitly rejects migrating the default to hts.heliossoftware.com.

Two reasons the HTS option does not work:

  1. It relocates the egress rather than removing it. An unconfigured customer deployment would still make an unannounced outbound call carrying patient-derived codes — just to Helios infra instead of HL7's, which makes us a processor of customer data on an endpoint with no auth, and puts every unconfigured install worldwide on one host we would then owe an SLA for.
  2. HTS cannot serve as a version-agnostic default. crates/hts/src/operations/metadata.rs:334 picks fhirVersion from cfg!(feature = ...) at compile time, and translate.rs/expand.rs switch response shapes (relationship vs equivalence) the same way. One root URL serves exactly one FHIR version per build, so it cannot be a correct default for R4/R4B/R5/R6 clients simultaneously.

Why this is a privacy fix, not a config change

Terminology operations send codes drawn from the resource under evaluation. The shipped R5 conformance test proves the shape: txTest02 evaluates %terminologies.validateVS(..., $this.gender) over a Patient. validate_vs transmits system, code and display. In production the same path carries diagnosis and observation codes. An unconfigured HFS deployment silently sent all of that to a public test server with no BAA and no SLA — and an eprintln! to stderr was the only control, which in a container is interleaved with request logs, fires no alert, and appears in no metric. The egress also bypasses helios-audit entirely.

This PR makes the unconfigured path fail closed, consistent with policy the REST layer already applies: crates/rest/src/handlers/search.rs:216 already returns 501 Not Implemented for an unconfigured :in/:not-in search modifier, with a comment rejecting a silent fallthrough "which returns misleading results."

Changes

  • get_terminology_server_url() returns Option<String>. Explicit setting → FHIRPATH_TERMINOLOGY_SERVERNone. The per-FHIR-version match is deleted, so no /r4 or /r5 path segment is appended to a configured base URL (HTS serves operations at the root). This also retires the "R5" | "R6" => .../r5/ arm.
  • TerminologyFunctions::new() is now fallible. It is the single choke point for every terminology op, so one error covers %terminologies.* and memberOf(). The message names the variable to set (FHIRPATH_TERMINOLOGY_SERVER, --terminology-server, HFS_TERMINOLOGY_SERVER, SOF_TERMINOLOGY_SERVER).
  • Naming %terminologies no longer requires a server. A dead let _terminology = TerminologyFunctions::new(context); built and discarded a client merely for referencing the variable; only invoking an operation resolves the server now.
  • Added a 30s HTTP timeout. Client::new() applies none, so an unresponsive terminology server hung the evaluating thread indefinitely.
  • Docs corrected in crates/fhirpath/README.md, crates/hts/README.md, the CLI help and the server's long_about. Note the HTS README claimed these features "fall back to empty results or false" without a server — that was already untrue for search (501) and is now accurate for both.

Tests

test_r5_test_suite was making live network calls to tx.fhir.org on every run. txTest01 (expand) and txTest02 (validateVS) are mode="tx" tests that were asserted, not skipped — the txTest02 guard checks expression.contains("translate("), which its validateVS expression does not contain, so it never fired. With assert_eq!(failed_tests, 0), CI went red whenever that public server was down or rate-limiting.

Those tests now run against an in-process wiremock stub, so no test reachable from cargo test --workspace --all-features makes an outbound call. FHIRPATH_TERMINOLOGY_SERVER still overrides the stub, which is how the expectations get re-validated against a real server. The expected values (4 gender codes, result = true) are fixed by the FHIR spec, not by any one server.

New crates/fhirpath/tests/terminology_config_tests.rs pins both halves of the contract: unconfigured fails with an actionable error (and memberOf() errors rather than answering a misleading false), referencing %terminologies without calling it needs no server, and a configured server is the one actually called.

Behavior change

%terminologies.* and memberOf() now error instead of silently contacting tx.fhir.org. Anyone relying on the old default sets one environment variable to restore it exactly:

export FHIRPATH_TERMINOLOGY_SERVER=https://tx.fhir.org/r4/

get_terminology_server_url() is public API and its return type changed (StringOption<String>); the crate is pre-1.0 and both in-repo callers are updated.

Not addressed

  • txTest03 ($translate) remains skipped — it was already skipped before this change for unrelated reasons, and I did not want to invent a ConceptMap fixture without a way to validate it.
  • TerminologyClient still constructs a fresh reqwest::Client per call (no connection reuse). Pre-existing; worth a follow-up.
  • Whether memberOf() should return spec-style Empty instead of erroring when unconfigured is a defensible alternative. I chose to error because it matches what already happens when the server is unreachable, and a silent false mislabels a valid code as a non-member.

Follow-up to #183 / #184.

The FHIRPath evaluator fell back to the public HL7 test server tx.fhir.org
whenever no terminology server was configured. Terminology operations carry
codes taken from the resource under evaluation -- `%terminologies.validateVS(
..., $this.gender)` sends a field of a Patient -- so an unconfigured HFS
deployment silently shipped patient-derived codes and display strings to a
third party. An eprintln! to stderr was the only control.

There is now no default. Resolution is the explicitly configured URL, then
FHIRPATH_TERMINOLOGY_SERVER, then nothing: %terminologies.* and memberOf()
fail with an error naming the variable to set, rather than calling a host the
operator never chose. This matches the policy the REST layer already applies,
where an unconfigured :in/:not-in search modifier returns 501 Not Implemented.

Pointing the default at hts.heliossoftware.com was considered and rejected: it
would relocate the egress rather than remove it, and HTS selects its FHIR
version from cfg! at compile time, so a single root URL cannot serve R4, R4B,
R5 and R6 clients correctly.

Also:

- Drop the per-version URL match. A terminology base URL is opaque, so no
  /r4 or /r5 segment is appended (HTS serves operations at the root). This
  also retires the indefensible "R6 may use R5 server for now" arm.
- Stop building and discarding a TerminologyClient when %terminologies is
  merely named; only invoking an operation needs a server.
- Give the terminology HTTP client a 30s timeout. Client::new() applies none,
  so an unresponsive server hung the evaluating thread indefinitely.
- Serve the R5 suite's mode="tx" tests from an in-process stub. txTest01 and
  txTest02 were asserted, not skipped (the txTest02 guard tests for
  `translate(`, which its validateVS expression does not contain), so
  `cargo test` made live calls to tx.fhir.org and went red whenever that
  server was down. FHIRPATH_TERMINOLOGY_SERVER still overrides the stub, which
  is how the expectations get re-validated against a real server.
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…rver (#217)

Removing the default terminology server silently dropped three conformance
tests, because both harnesses relied on the evaluator falling back to
tx.fhir.org.

The Windows "Test FHIRPath" job runs the official FHIR R5 test cases against
fhirpath-server, which is started with no terminology server configured. With
no default, txTest01-03 now error, and the runner reports an error as
Assert.Inconclusive -> SKIPPED. The job stayed green while quietly losing the
only end-to-end coverage of %terminologies. Point that server at our own HTS
(per #183) rather than the public tx.fhir.org. Because the runner downgrades
errors to skips, an HTS outage cannot turn CI red.

Un-skip txTest03 in the Rust R5 suite. It was skipped because tx.fhir.org
answered ConceptMap/cm-address-use-v2 incorrectly. HTS answers it correctly --
verified against the live server, which returns concept.code = "H", exactly
what the test expects -- so the stub now serves $translate with HTS's actual
response shape and the test asserts again. The expectation is re-derived
against the source we now point people at, which is what #217 asked for.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FHIRPath: migrate the runtime default terminology server off tx.fhir.org (evaluator.rs)

1 participant