Skip to main content

FHIR R4 — How it works

Overview

The fhir module is the platform's HL7 FHIR R4 face: the endpoint a national health information exchange, a SMART-on-FHIR app or a partner EMR talks to when it wants records in a standard shape rather than in ours. It is a domain module (com.zhenus.uhp.api.fhir), so it imports only common and exchange and reaches platform engines over Feign — the same call whether it is bundled in the single jar or split into its own service later. Design: MILESTONE12_PLAN.md, CORE_PLAN.md §15.2.

It is a sibling of, not a replacement for, HL7 v2 messaging. The two solve different problems and both are needed: HL7 v2 is a push feed to systems inside the hospital (ADT to a bed board, ORM to a RIS); FHIR is a pull API for systems outside it.

Read and search are live; nothing writes yet. What exists today:

TicketDeliversStatus
M12-001Maven module, /fhir namespace, CapabilityStatement, architectural fencedone
M12-002Read + search: Patient, Practitioner, Encounter, Observation, Condition, AllergyIntolerance, CarePlandone
M12-003Write, search parameters, SMART-style scopes, derived CapabilityStatementdone
M12-004Coverage / Claim / ClaimResponse, MedicationRequest / MedicationDispense, DocumentReferencedone (A contracts, B medications+documents, C coverage+claims)
M12-010Per-patient record export and FHIR Bulk Data $exportnot started

Data model & ownership

This module owns no tables, and that is a decision rather than an omission. Unlike hl7 — which keeps an integration_message log — a FHIR facade holds nothing of its own. Every resource it will serve on M12-002 is projected on demand from the engine that owns the underlying fact:

FHIR resourceOwning platform engineReached via
Patientdemographicexchange.client.demographic
Practitionerdemographic / workforceexchange contracts
Encounterclinicalexchange.client.clinical
Observation, Condition, AllergyIntolerance, CarePlanclinicalexchange.client.clinical
codings on any of the aboveconcept dictionaryexchange.client.concept

A fhir schema could only ever hold a copy of clinical data — a second place for it to leak from and a second thing to keep consistent with the first. So there is no schema, no changelog, and no entry in the platform changelog aggregate. FhirApplication excludes the JPA and Liquibase auto-configuration outright, because common puts spring-boot-starter-data-jpa on the classpath transitively and a standalone boot would otherwise demand a datasource it never reads.

Per-patient record export — $everything (M12-010A)

GET /fhir/Patient/{id}/$everything?purpose=… returns the whole record the facade can serve as one COLLECTION bundle — the data-portability/GDPR read. Three rules make it safe:

  • Its own permission, fhir.export.read — exporting a record is a different act from reading its resources one at a time, and a deployment can grant one without the other.
  • The object-level decision runs first, once, with the caller's PurposeOfUse (default PATIENT_REQUEST): sensitivity policy applies (a RESTRICTED record refuses purposes the policy does not permit), break-glass rides the underlying decision, and denial is a not-found indistinguishable from a miss — audited on both rails (PatientAccessEventType.EXPORT with the purpose, and the central trail's AuditEventType.EXPORT). ⚠ This endpoint is what turned the object-decision rail ON: PatientRecordFactResolver (demographic engine) is the first production ResourceFactResolver — before it, every decideObject answered RESOURCE_NOT_FOUND.
  • Assembly goes through the facade's own family searches, so every family's SMART-scope and permission checks run too — a caller scoped to observations cannot use export as a side door to coverage, and a family the caller may not read fails the export loudly rather than silently thinning "the record". Families are drained past the page cap until a short page.

$-operations are skipped by the CapabilityStatement's endpoint inventory — they are operations, not search interactions — and instead declared by hand with their published HL7 canonical definitions (Patient-everything, the Bulk Data IG's patient-export).

Population bulk export — $export (M12-010B)

GET /fhir/Patient/$export?_type=…&purpose=… starts an asynchronous export of every patient record in the caller's tenant, in the FHIR Bulk Data shape:

  1. Kickoff answers 202 Accepted with a Content-Location naming the polling endpoint. purpose is mandatory — a population export has no presumable purpose, and the reason every record in the tenant is leaving must be stated to be audited; a break-glass export states its reason here and it is carried into every per-patient decision. _type narrows the output and is validated against the families the facade serves; an unknown type is refused at kickoff. _since is refused loudly rather than silently ignored, until the family searches carry a changed-after bound.
  2. GET /fhir/$export-status/{id} answers 202 + X-Progress while the job runs, then 200 with the Bulk Data manifest: transactionTime, request, requiresAccessToken: true, an output array of NDJSON file URLs with counts, and an error array.
  3. Each GET …/files/{seq} streams application/fhir+ndjson. DELETE on the polling location cancels the job and deletes its output.

The rules that make it safe:

  • Its own permission, fhir.bulk-export.read — distinct from fhir.export.read: one patient leaving at their own request and every record leaving at once are different acts with different blast radii.
  • The job runs as the requester. A scheduler instance claims the job (DB lease, FOR UPDATE SKIP LOCKED — exactly one claimant across instances) and binds the requester's identity and tenant scope onto the worker thread. Each patient then gets a real object-level decision (purpose of use, sensitivity policy, break-glass) exactly as an online $everything would, and every audit row attributes to the requester. Transport identity of the platform calls is the module's service identity (SEC-018): authorize as the user, carry as the service.
  • A refused patient is recorded, never silently skipped: it becomes an OperationOutcome line in the manifest's error file, so the output never claims a completeness it does not have — and never a job-fatal error, so one RESTRICTED record cannot make bulk export unusable.
  • Job rows are PHI-free by construction (an acceptance criterion, pinned by a column-allow-list test): the fhir schema stores who asked, the shape, status and opaque storage keys. The exported records themselves live only in object storage.
  • Polling is tenant-scoped by the signed session claim — another tenant's job id answers not-found, indistinguishable from a miss.

⚠ Prerequisite: the fhir module needs a service identity, or no job can run

The kickoff request runs as the caller; the job does not — it is claimed by a scheduler thread with no inbound request, so its platform reads present the module's own credential (SEC-018) rather than going out anonymous. A deployment that has not provisioned a service_identity for fhir (with a backing account holding the demographic/clinical read permissions the export needs) gets jobs that fail their attempts with a 403 on the first platform call and settle at FAILED. This was observed exactly so on the FE-284 walkthrough against an unprovisioned local stack: the per-patient $everything export works (it runs on the caller's own request), while every bulk job fails.

The same applies to the retention sweep, which runs on the same thread.

Retention — the documented window

Finished NDJSON stays downloadable for fhir.export.retention, default PT24H (24 hours). The scheduler's sweep then deletes the storage objects and the file rows and marks the job EXPIRED (its polling location answers not-found from then on). Change the default via FHIR_EXPORT_RETENTION; this doc and .env.example state the window — change both together. Output goes to the fhir.export.* bucket (FHIR_EXPORT_BUCKET, default uhp-fhir-exports), deliberately separate from the document store's bucket: exported records and uploaded documents have different deletion rules. One LocalStack/MinIO serves both locally via the shared S3_ENDPOINT_URL.

Not built (deliberately): Group-level cohort $export; _since; paged patient enumeration — PatientClient.listPatients is unpaged, which a national-scale tenant will need fixed first. Frontend surface is FE-284, owed.

Key rules & invariants

  • No core import, and no sibling domain-module import. FhirArchitectureBoundaryTest refuses com.zhenus.uhp.api.core and each of workforce, imaging, hl7, lab, pharmacy. FHIR is the module most exposed to the sideways import — its resources map onto facts spread across the whole platform, so on M12-002 the shortest path to any of them is an import of whichever module owns it. Every fact must arrive as an exchange DTO over Feign, the only coupling that survives extraction.
  • No HAPI RestfulServer, and the fence is a test. hapi-fhir-server is not a declared dependency, and two ArchUnit rules keep it out: one refuses ca.uhn.fhir.rest.server.., the other refuses registering any servlet or filter from this module. A HAPI RestfulServer registers its own servlet and dispatches to resource providers itself — a second HTTP surface inside the single jar, outside the filter chain core installs. FHIR is served from ordinary Spring MVC controllers so every FHIR request passes the platform's authentication like any other. ⚠ The rules deliberately do not ban touching jakarta.servlet at all. The first version did and failed on the Feign interceptor reading HttpServletRequest.getHeader(…) to forward the caller's token — ordinary MVC work, and exactly how a request stays inside the platform's security. What matters is mounting a second entry point; a wholesale ban would teach the next person to suppress the rule rather than respect it.
  • No global message converter. The CapabilityStatement is serialised explicitly with HAPI's parser at the point of use. Registering a FHIR HttpMessageConverter would change how every controller in the single jar — core's included — negotiates content.
  • Permission codes are declared before they are used. FhirModuleDescriptor declares fhir.capability.read, fhir.resource.read and fhir.resource.write. A @RequiresAccess code a module never declares can be granted to nobody, so its endpoint 403s for every caller including super_admin — and does so silently, since a missing permission looks exactly like an unprivileged user. FhirModuleDescriptorTest reads the codes off the controllers rather than restating them, so a code added later is caught.
  • A FHIR permission is not a patient-level grant. Holding fhir.resource.read says a caller may speak FHIR, never which patients they may see. M12-002's resource reads must run through the ordinary access decision and the patient access guard, exactly as the equivalent REST reads do.
  • No raw resource body is logged. A FHIR resource is PHI in its entirety, the same rule the hl7 log was built around. FhirResourceRenderer is the one place a resource becomes text, and it carries no logging statement at all — a serialiser is exactly where somebody adds "log what we sent" during an integration problem, and that log is then the copy of the clinical record nobody accounted for.

Where M12-004's resources come from (M12-004A)

Every resource M12-004 publishes maps onto a fact another module owns, reached over Feign through an exchange client — never by importing the owning module.

FHIR resourceOwnerExchange client
Coverage, Claim, ClaimResponseinsuranceInsuranceClient
MedicationRequest, MedicationDispensecore's clinical engineMedicationClient
DocumentReferencecore documentDocumentClient

MedicationRequest is a clinical fact, not a pharmacy one. Pharmacy owns stock, lots and the dispensing till; the clinical record owns what was prescribed and what reached the patient. Mapping it from a pharmacy dispense row would describe an inventory movement — a different event.

All three clients are read-only, and a test pins that. Claim submission, prescribing (a DnD form plus its submission handler, M11-012) and dispensing (a controlled register) each already have a controlled route with its own permissions. A FHIR facade publishes what the platform knows; the moment one of these clients grows a write mapping it becomes a second way to do those things, with a second set of checks that must be kept in step with the first.

Coverage is keyed by person; a FHIR beneficiary is a patient. The person is resolved through the patient API, which embeds it — never through a person API. And GET /members/active answers 200 with an empty body when there is no coverage, not 404: read as an error, a self-pay patient becomes an outage.

No document content through FHIR. The bytes stay behind core's access guard and malware-scan status; a DocumentReference carries a description and a link. A second streaming path would duplicate those checks, and duplicated checks drift.

The fence had a hole where this ticket pushes

FhirArchitectureBoundaryTest barred workforce, imaging, hl7, lab and pharmacy — but not insurance, whose facts are exactly what Coverage and Claim map onto. In the single jar that import compiles and runs perfectly. insurance and reporting are now barred too. A fence is only worth having where the temptation is, and the temptation arrives with the ticket.

Medications and documents (M12-004B)

MedicationRequest, MedicationDispense and DocumentReference are read and searched like the other clinical resources: the patient parameter is mandatory, and every read authorises against the patient the record actually belongs to — resolved upstream first, never against a patient id the caller supplied.

rxNormCode is never published as a coding. MedicationOrderDto carries it and it looks like the drug's authoritative code. It is a write-only locator: the clinical service consumes it when resolving which concept was meant and does not keep it. Emitting it would assert a mapping the platform may never have held, and under TERM-014 only a SAME_AS mapping earns a foreign coding. The drug is coded from drugConceptId alone.

DISCONTINUED becomes stopped, not cancelled. FHIR's line is whether the medication was ever acted on — cancelled means retracted before it took effect. Collapsing the two would turn "the patient took this and we stopped it" into "the patient never took this".

batchNumber is deliberately not mapped. FHIR puts lot numbers on Medication.batch.lotNumber, not on MedicationDispense. Folding it into the note would publish it where no conformant client looks, so a recall search would appear to work and silently find nothing. A contained Medication is the correct home if that use case arrives.

DocumentReference publishes metadata, never content

No attachment.url is ever emitted. The bytes are served by core behind its access guard and malware-scan status. A URL here would be a second retrieval path whose checks would drift from the first. A client learns a document exists, what it is and how big it is, then fetches it through the guarded endpoint.

A document that has not passed the scan is not published at all — a read answers 404 and searches omit it. A DocumentReference asserts a retrievable document exists, and for a file the guard will refuse, that assertion is false. Filtering happens before paging, so total counts what the client can actually retrieve.

attachment.hash is base64 of the digest bytes, not of the stored text. The platform stores the SHA-256 as hex; FHIR wants base64Binary of the raw bytes. Base64-encoding the hex string yields a value of the right shape and twice the right length, carrying a hash of the wrong thing — nothing rejects it, and a client verifying integrity concludes every document was tampered with.

Coverage, Claim and ClaimResponse (M12-004C)

These three are search-by-patient only. The insurance module keys its records by personId, and FHIR requires Coverage.beneficiary, Claim.patient and ClaimResponse.patient as 1..1. Patient → person is available and safe — the patient API embeds the person, so no person API is touched. Person → patient has no endpoint, so a read by id could not produce the mandatory reference.

A read-by-id that simply omitted the beneficiary was considered and rejected: a resource missing a mandatory element is not a thin resource, it is a non-conformant one, and a client cannot tell it apart from the server declining to answer. Because the CapabilityStatement is derived from the request mappings, it advertises search-type and not read for these types automatically — the limitation is published truthfully without anyone having to remember to write it down.

Claim.status is not payer progress. PayerClaimStatus runs RECEIVEDPENDEDIN_REVIEWADJUDICATEDREVERSED; FHIR's Claim.status says whether the claim resource is in force. Mapping progress onto it would tell a client a claim had been withdrawn when it had merely been received. Only REVERSED is a cancellation — the adjudication story is ClaimResponse.outcome.

PARTIALLY_APPROVED becomes partial, never complete. Collapsing it reports a claim settled in full when part of it was not, and the difference is money the provider has not been paid. PENDED becomes queued, not error — an error invites resubmission of a claim already in flight, which is how duplicates are created.

An unknown currency is left absent rather than defaulted. The platform runs across several countries, so a default would silently mis-state every amount outside whichever one was picked.

Known cost: the insurance claim API has a worklist by payer status and a read by id, but no per-person query, so a patient's claims are filtered client-side. That is correct — the guard has already run — but proportional to the tenant's claim volume rather than the patient's. A person-scoped claim query on the insurance side is the fix.

Outbound coding policy (TERM-014) — the part that matters most

Never invent a foreign code. A receiving system cannot tell an invented LOINC from a real one. It will not reject it, will not flag it, and will file it against the wrong analyte for as long as the record survives. Emitting no standard code is a reportable data gap somebody can fix; emitting a plausible wrong one is a clinical error nobody will ever find.

ConceptCodingFactory is the only path from a concept_id to a Coding, and it applies four rules:

  1. A foreign Coding appears only when concept_mapping holds that mapping, under the source_uri the mapping itself stores (terminology_source.canonical_uri — note the field is canonicalUri, not canonicalUrl).
  2. Only SAME_AS mappings become codings. NARROWER_THAN, BROADER_THAN and RELATED_TO are dropped. A Coding asserts this is the code for this concept; publishing a broader SNOMED code under that claim changes the clinical statement, and it is harder to spot than an invented code because a real mapping row can be pointed at to justify it. "Diabetes mellitus" sent where the record says "type 2 diabetes mellitus" is a different clinical statement.
  3. A mapping with no source_uri is dropped, not defaulted. The URI is the half of a coding that says whose code it is. Guessing http://loinc.org because the source is called "LOINC" invents the half that carries the meaning.
  4. The local coding always travels, plus text. Standard coding first, local second, text always.

Worked example. A local concept UHP-LOCAL-77 "Traditional birth attendant visit" has no LOINC or SNOMED mapping. It is published as:

{ "coding": [ { "system": "urn:uhp:codesystem:concept",
"code": "UHP-LOCAL-77",
"display": "Traditional birth attendant visit" } ],
"text": "Traditional birth attendant visit" }

— one coding, ours, plus text. Give the same concept a SAME_AS mapping to LOINC 29463-7 and the LOINC coding is prepended; nothing else changes. ConceptCodingFactoryTest asserts both, plus each of the refusals above.

The local system URI resolves in three steps: the configured fhir.server.local-code-system-uri; otherwise {base-url}/CodeSystem/uhp-concept, derived from the base URL the deployment already declares as its own identity; otherwise the URN urn:uhp:codesystem:concept. ⚠ The URN fallback is chosen over inventing an https:// host because an unresolvable URN is honestly opaque, while a URL naming a domain nobody controls invites a receiver to dereference it — and one day invites somebody else to register it.

The same refusal applies outside the concept dictionary. Encounter.class and participant.type bind to HL7 v3 value sets that deployment-defined encounter types and roles do not belong to; both bindings are extensible, so our own code plus display is conformant and no v3 code is guessed. A numeric Observation value carries Quantity.unit but no Quantity.system — declaring one would claim the unit string is a UCUM code, and nothing in the platform's data says so. What is mapped onto a foreign system is the small set of platform enums that were defined against those very value sets — Observation.status, Observation.interpretation, clinical status, allergy category and severity, care-plan status — where the correspondence is by construction rather than by resemblance, and the switch is exhaustive so a new enum member fails compilation instead of falling through.

Authorization and the shape of a refusal

Two gates, and they answer different questions.

  • fhir.resource.read (declared in FhirModuleDescriptor, enforced by @RequiresAccess) says a caller may speak FHIR at all. It says nothing about which patients.
  • The platform's access decision, called per read through FhirAccessGuardAccessDecisionClient. This module never re-derives a decision: a second authorisation implementation would drift from the platform's the moment consent, break-glass or a visibility scope changed, and it would drift silently, because this module's tests would keep passing.

A denial answers 404, in the same words as a genuine miss (SEC-011). FHIR reads are addressed by opaque ids and look the record up before they authorise, so a 403 would confirm that some other tenant holds the record — and "does this person have a record here" is frequently the sensitive question. FhirAccessGuard throws the caller's own not-found constant, so the two responses are identical.

And it is not an empty bundle either. An empty bundle is indistinguishable from "this patient has no observations", which turns an authorisation failure into a clinical falsehood the caller acts on. The absence is stated, without stating which kind of absence it is.

The guard fails closed when no caller resolves. core's PatientAccessGuard deliberately returns silently in that case, so in-process service contexts are not additionally patient-gated. There is no such thing as an internal caller on a FHIR endpoint — every request arrived over HTTP from outside — so this module refuses instead.

Search results are not re-filtered here. DemographicSearchServiceImpl already applies server-side object-level visibility filtering (M10-015) and scopes to the session tenant, so Patient and Practitioner searches trust it rather than adding a second filter that could disagree with it.

Every patient-scoped read leaves an audit row, appended over AuditCommandClient (PHI_READ/SUCCESS, or ACCESS_DENIED/DENIED). It is best-effort — an audit outage must not turn a lawful read into an error for the clinician waiting on it — but it is not silent: a warning naming the resource type and the failure class is logged, never the patient or the body. ⚠ The recorded denial reason is the AccessDecisionReason enum name, never the platform's human-readable displayMessage, which is written for a clinician on a screen and may name the patient or the reason they are confidential.

Identity conformance (M37-007)

What the platform knows about an uncertain identity has to survive the FHIR boundary, or the receiving system is told something more confident than the truth — and it is the receiving system that decides whether to trust an identifier or match on a name.

PlatformFHIR R4Was
person_name.use = ANONYMOUSHumanName.use = anonymousexported as official
patient_identifier.use = TEMPIdentifier.use = tempexported as usual
person.merged_into_person_idPatient.link type replaced-by, active = falseabsent

Both "was" rows were silent leaks, not errors. FhirMappingSupport.humanName hardcoded NameUse.OFFICIAL, so the generated placeholder for somebody nobody could name went out as their legal name — undoing M37-002 at the boundary. Identifier.use was derived from preferred alone, so a provisional number was indistinguishable from a settled one.

⚠ Not $merge: that operation is R5 and this platform is R4. Patient.link is the R4 answer and it is conformant today, which beats waiting.

⚠ An identifier with no recorded use still falls back to the preferred flag. Rows written before the column existed have nothing else, and dropping the fallback would re-label every historical identifier.

API

Base URL: /fhir — outside /api/v1, on purpose

This is the one place in the codebase where a module does not hang off ApiV1Paths.PREFIX. A FHIR client is handed a single base URL and constructs every request from it by appending the resource type ({base}/Patient/123, {base}/metadata); the resource paths are fixed by the specification and version is carried by fhirVersion in the CapabilityStatement, not by the path. Putting the base under /api/v1 would work right up to the first conformance test or SMART app that assumes otherwise.

GET /fhir/metadata

Returns the server's CapabilityStatement as application/fhir+json (not application/json — conformant clients negotiate on the exact type). Gated by fhir.capability.read.

It declares exactly what is implemented, and since M12-003C it no longer takes anyone's word for it. A conformant client reads this document and then issues the interactions it names, so a resource declared ahead of the code that serves it 404s for something the server itself promised — and a search parameter declared but ignored silently returns the wrong result set, which is worse because the client has no way to notice.

This is not a hypothetical. The statement used to be a hand-written list, and it drifted within two tickets: M12-003B added POST /fhir/Observation and M12-003A added the SMART scope layer, while the document still announced that no write interaction existed. A client that trusted it — the only reason the document exists — would have concluded the server was read-only while it was accepting writes.

So the resource types, interactions and search parameter names are now derived from Spring's own request mappings (FhirEndpointInventory). The document is built from the routing table and cannot disagree with it. Two things are deliberately not derived:

  • A parameter's FHIR type, which does not follow from its Java type — gender and family are both String in Java and are token and string in FHIR; patient is a UUID and is a reference. Inferring would publish a document that is confidently wrong, which a client cannot detect. Types and prose live in SearchParamCatalog, and a test fails if a handler accepts a parameter the catalog does not describe.
  • /fhir/metadata itself, which is excluded from the resource list: it is how a client discovers the server, not something the server holds.

SMART-on-FHIR is advertised only when an authorization server is configuredfhir.server.smart-authorize-url and fhir.server.smart-token-url, both required; half a configuration is no configuration. M12-003A maps SMART scopes onto the platform's permission codes, but mapping scopes is not issuing tokens. Advertising an authorize endpoint that answers nothing would send an app into a flow it cannot finish. When unset, the statement says so plainly and notes that scopes are still honoured when a platform token carries them.

Resource reads and searches

All gated by fhir.resource.read, all returning application/fhir+json, all failing as an OperationOutcome.

RouteSearch parameters
GET /fhir/Patient/{id} · GET /fhir/Patientfamily, given, birthdate, gender, identifier
GET /fhir/Practitioner/{id} · GET /fhir/Practitionerfamily, given, identifier (the provider code)
GET /fhir/Encounter/{id} · GET /fhir/Encounterpatient (required)
GET /fhir/Observation/{id} · GET /fhir/Observationpatient (required), encounter, code
GET /fhir/Condition/{id} · GET /fhir/Conditionpatient (required), encounter
GET /fhir/AllergyIntolerance/{id} · patient (required), encounter
GET /fhir/CarePlan/{id} · GET /fhir/CarePlanpatient (required), encounter

Every search also takes _count (default 20, capped at 200) and _offset.

Every clinical search requires patient, and refusing an unscoped one is the safety property, not a missing feature. "All observations" cannot be authorised by a single access decision, and it is the shape a bulk extraction takes; bulk retrieval has a deliberate, audited home in M12-010's $export, and letting it in through a search parameter would put it outside those controls. An unscoped clinical search answers 400 with an invalid OperationOutcome — nothing was named, so nothing can be absent, and saying so leaks nothing about any patient.

A Patient/Practitioner search needs a scoped session. The tenant comes from the signed sessionTenantId claim, never from the query string; a session with no tenant is refused rather than defaulted, because any default searches somebody else's records. (The platform ignores the tenantId it is handed and calls requireCurrentTenantId() itself — which is exactly why this module must not offer a caller-supplied one.)

Errors are OperationOutcome

FhirOperationOutcomeHandler renders 404 / 400 / 403 as FHIR documents, because a FHIR client parses errors as well as successes and would otherwise have to special-case this server to read a failure. ⚠ The advice is scoped to com.zhenus.uhp.api.fhir.controller: an unscoped @RestControllerAdvice is global, so it would take over error rendering for core's controllers the moment the single jar assembled, and every platform API would start answering FHIR documents.

Paging is presentation, not push-down

DemographicSearchClient pages natively (limit/offset/totalCount), so a Patient search passes paging through and reports an honest Bundle.total. ClinicalClient returns whole unpaged lists, so a clinical search fetches everything the caller may see for that one patient and slices it in FhirBundleFactory. That is bounded and correct per patient and does not generalise — which is why M12-010 runs $export as an async job rather than by walking these bundles.

Bundle.link and entry.fullUrl are built from the configured base URL only, never from the request Host header: a client follows those URLs with its bearer token attached. With no base URL configured they are omitted rather than emitted relative — a bundle without them is valid FHIR, and a wrong absolute URL is where the token goes next.

What M12-002 deliberately leaves out

  • Patient addresses, telecom and person attributes. They sit behind demographic sub-resource endpoints this module does not read; an address rendered from nothing is worse than an absent one.
  • Practitioner.qualification. ProviderDto.credentialsMetadataJson is an unvalidated blob whose shape this module does not own, and that element asserts a licence the platform would be standing behind. It belongs to the workforce credential model.
  • CarePlan.activity. The platform has no care-plan activity or goal table and no DTO for one. A CarePlan with no activities is valid; synthesising one from the description would publish a care instruction nobody wrote.
  • AllergyIntolerance.criticality. Left unstated. AllergySeverity describes the reaction that happened and maps to reaction.severity; criticality is a forward-looking risk judgement a prescriber's decision support reads, and nobody made one.
  • Encounter.partOf → the visit. visit is not projected as a resource on M12-002, so the linkage has nowhere conformant to point yet.
  • Concept caching. One Feign call per distinct concept, uncached, so a bundle of N observations makes N concept reads. A cache with no invalidation would serve a code after a steward retired it, and a stale coding is the class of error this module exists to prevent. Sized in M12-010.

Platform contracts this ticket added

ClinicalClient gained getCondition/searchConditions, getAllergy/searchAllergies and getCarePlan/searchCarePlans. The entities, services and controllers had existed in core since M7, but the Feign read contract stopped at visits, encounters and observations — so the only way a domain module could see a condition was to pull an entire PatientClinicalSummaryDto, which is a whole-chart read to answer a single-record question and cannot serve a read by id at all.

Writes (M12-003B) — and SMART scopes (M12-003A)

POST /fhir/Observation is the first write endpoint. Two rules govern it.

Every write goes through the platform's own write client, never the database. The endpoint maps the resource to an ObservationDto and calls ClinicalRecordClient.createObservation. The business rules — signatory provider, active care location, visibility scope, encounter prerequisites — live in the core clinical services, and a FHIR write that reached the database directly would be the one path in the platform that skipped them and the externally exposed one. A refusal from the platform surfaces as the platform's refusal, not something this module reinterprets.

Identity and scope are never taken from the payload. tenantId, facilityId, patientId and id are all ignored:

FieldWhy it is ignored
tenantId / facilityIdderived from the encounter; accepting them would make this the one write surface where a caller picks their own tenant
subject (patient)derived from the encounter; trusting a client-named patient would file one patient's data under another
ida create assigns one; accepting a client id would let a caller overwrite an existing row by guessing it

Observation.encounter is therefore required — it is what everything else derives from.

Coded values: the platform's own system only

Two systems resolve, in this order:

  1. the platform's own — its code is the concept id, so no dictionary lookup happens at all;
  2. any external system the dictionary maps — resolved through GET /concept-mappings/resolve (TERM-017), which accepts active SAME_AS mappings only, matched on the source's canonical URI. So http://loinc.org|8480-6 now writes, provided somebody has mapped it.

A code with no such mapping is still refused, never guessed at. Only concept_mapping can say which local concept a foreign code means; guessing would attach a clinical observation to the wrong observable. The refusal names the systems the client offered and tells them to map it.

When several concepts claim one code, the write is refused rather than resolved to a candidate. Filing a clinical value against an arbitrarily chosen concept is worse than refusing, and the refusal names every candidate so the duplicate can be corrected. A platform coding sent alongside an ambiguous foreign one still wins — that is not a loophole: the platform's code is the concept id, which is more authoritative than any mapping, and the duplicate goes on refusing every client that sends only the foreign code.

This is symmetric with reads: SAME_AS is exactly the map type the outbound policy emits, so whatever a client reads out it can write back.

SMART scopes narrow authority, never widen it

An operation needs both a granted scope covering it and the permission that scope maps to, already held by the caller. Over-scoped (user/*.* against a caller with only clinical.observation.read) is refused; under-scoped is refused. A token carrying no scopes at all is not a SMART client — the ordinary permission and patient-reach checks then stand alone, and empty scopes never read as "all scopes". A malformed scope is dropped rather than treated as a wildcard.

A resource with no permission mapping is denied, not exempt, and a guard test pins the CapabilityStatement's advertised resources to the mapping in both directions — so the server can neither advertise a resource that would always answer 403 nor serve one it never announced.

That guard did not catch the drift M12-003C fixed, and could not. It compares advertised resource types against the permission map, so a missing interaction on a correctly-advertised type is invisible to it: Observation was advertised, was mapped, and was still wrong. It also read the provider's source text with a regex, which means nothing once the list is no longer hand-written. It now runs against the derived statement. A guard that only ever passes is worth re-reading.

A parse failure never echoes the body

HAPI's parse errors quote the offending JSON, and that JSON is a clinical record. The renderer's parse drops the underlying message and reports only the expected resource type and the failure class. An integration problem is exactly when somebody wants the body echoed back, and that echo would be a copy of PHI in a log aggregator.

Configuration & feature flags

Standalone listen port: ${FHIR_APP_PORT:${APP_PORT:8089}}. There are no FHIR_DB_* keys — the module owns no tables.

KeyDefaultPurpose
FHIR_SERVER_BASE_URL(empty)Absolute base URL declared as implementation.url, and the base for bundle links and fullUrl
FHIR_SERVER_IMPLEMENTATION_DESCRIPTIONZhenus UHP FHIR R4 endpointimplementation.description
FHIR_SERVER_PUBLISHERZhenus UHPCapabilityStatement publisher
FHIR_SERVER_LOCAL_CODE_SYSTEM_URI(derived)Overrides the system URI local concepts are published under (TERM-014)
FHIR_SERVER_LOCAL_IDENTIFIER_SYSTEM_URI(derived)Overrides the namespace for identifiers this platform assigns

The two LOCAL_* keys have no entry in fhir/application.yml and do not need one: Spring's relaxed binding resolves an environment variable straight onto @ConfigurationProperties, and both default to a value derived from FHIR_SERVER_BASE_URL (falling back to a URN). Set them only where a deployment publishes a real terminology endpoint.

⚠ The names mirror the property path (fhir.server.base-url) rather than being shortened. The module's application.yml is read only when it runs standalone — the single jar reads app/application.yml, which carries no module blocks at all (nor does it for hl7, imaging, lab or pharmacy). Spring's relaxed binding resolves these variables straight onto @ConfigurationProperties in both deployments, so one variable configures both; a shorter FHIR_BASE_URL would work standalone and silently do nothing in the jar we deploy.

FHIR_SERVER_BASE_URL is configured, never derived from the request Host header. It is handed back to the client as the server's own identity and is what the client builds every following request from — including the one carrying its bearer token. Behind a proxy that header is attacker-influenced. An empty value is safe (a CapabilityStatement without implementation.url is valid FHIR); a wrong one sends tokens somewhere else.

Open question for the PO

Should /fhir/metadata be anonymous? The specification lets a server publish its CapabilityStatement without authentication so clients can discover it before they hold a token, and many deployments do. Here it currently requires authentication like every other route, because making it public means adding a new entry to SecurityConfig's permit-list and the skeleton has no discovery use case that needs one. M12-003 forces the question anyway — SMART's .well-known/smart-configuration genuinely must be anonymous — so it is best decided there, deliberately, rather than inherited.

Library choice

HAPI FHIR 8.10.1 (ca.uhn.hapi.fhir:hapi-fhir-structures-r4), pinned in the root pom.xml. Apache 2.0 — lawful to link into a proprietary deployment outright, with no weak-copyleft arm to stay inside (unlike dcm4che). Java 17 baseline, satisfied by this build's Java 21.

⚠ Note the two unrelated HAPI properties: hapi.version is HAPI HL7 v2 (ca.uhn.hapi) and hapi-fhir.version is HAPI FHIR (ca.uhn.hapi.fhir). Different libraries, independent release cadences; they must never be unified behind one property.

  • HL7 v2 messaging — the push side of interoperability, for systems inside the hospital.
  • Clinical — encounters and observations, the source of most FHIR resources.
  • Concept — the dictionary that supplies SNOMED CT / LOINC / ICD codings.
  • Demographic — patients and persons behind Patient.

Why it is this way

The CapabilityStatement is derived from the request mappings, not hand-written (M12-003C). The hand-written version drifted within two tickets: it announced a read-only server while POST /fhir/Observation was accepting writes. A document built from the routing table cannot disagree with it.

A foreign code is refused rather than guessed at. Only SAME_AS mappings become codings (TERM-014), because narrower and broader relationships say something weaker than equality, and publishing them as equality tells a receiving system two things are the same when they are not.

Writes go through the platform's own services, never straight to the database. A FHIR facade publishes what the platform knows; it must not become a second way to change it, with a second set of checks to keep in step.

Traps

A code with no mapping is refused, never guessed atConceptCodingResolver. Asking "which of these candidates did they mean?" has no safe answer, and attaching an observation to the wrong observable is worse than refusing the write.

The concept is validated, not merely parsed. A syntactically valid id that names nothing is still wrong, and parsing alone would accept it.

baseUrl is configured, not derived from the requestFhirServerProperties. Deriving it means a proxy header can move where clients think the server lives.

Bean naming is a single-jar concern, not a style oneFhirContextConfig. In the assembled jar every module's beans share one context, so a generic name collides with another module's.

One Feign call per distinct concept is a known, accepted cost — ConceptCodingFactory. It is documented rather than hidden, so the next person measuring a slow response knows where to look.

A parse failure never echoes the request body. HAPI's message quotes the offending JSON, and that JSON is a clinical record.