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:
| Ticket | Delivers | Status |
|---|---|---|
| M12-001 | Maven module, /fhir namespace, CapabilityStatement, architectural fence | done |
| M12-002 | Read + search: Patient, Practitioner, Encounter, Observation, Condition, AllergyIntolerance, CarePlan | done |
| M12-003 | Write, search parameters, SMART-style scopes, derived CapabilityStatement | done |
| M12-004 | Coverage / Claim / ClaimResponse, MedicationRequest / MedicationDispense, DocumentReference | done (A contracts, B medications+documents, C coverage+claims) |
| M12-010 | Per-patient record export and FHIR Bulk Data $export | not 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 resource | Owning platform engine | Reached via |
|---|---|---|
| Patient | demographic | exchange.client.demographic |
| Practitioner | demographic / workforce | exchange contracts |
| Encounter | clinical | exchange.client.clinical |
| Observation, Condition, AllergyIntolerance, CarePlan | clinical | exchange.client.clinical |
| codings on any of the above | concept dictionary | exchange.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(defaultPATIENT_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.EXPORTwith the purpose, and the central trail'sAuditEventType.EXPORT). ⚠ This endpoint is what turned the object-decision rail ON:PatientRecordFactResolver(demographic engine) is the first productionResourceFactResolver— before it, everydecideObjectansweredRESOURCE_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:
- Kickoff answers 202 Accepted with a
Content-Locationnaming the polling endpoint.purposeis 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._typenarrows the output and is validated against the families the facade serves; an unknown type is refused at kickoff._sinceis refused loudly rather than silently ignored, until the family searches carry a changed-after bound. GET /fhir/$export-status/{id}answers 202 +X-Progresswhile the job runs, then 200 with the Bulk Data manifest:transactionTime,request,requiresAccessToken: true, anoutputarray of NDJSON file URLs with counts, and anerrorarray.- Each
GET …/files/{seq}streamsapplication/fhir+ndjson.DELETEon the polling location cancels the job and deletes its output.
The rules that make it safe:
- Its own permission,
fhir.bulk-export.read— distinct fromfhir.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$everythingwould, 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
OperationOutcomeline in the manifest'serrorfile, 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
fhirschema 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
coreimport, and no sibling domain-module import.FhirArchitectureBoundaryTestrefusescom.zhenus.uhp.api.coreand each ofworkforce,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 anexchangeDTO over Feign, the only coupling that survives extraction. - No HAPI
RestfulServer, and the fence is a test.hapi-fhir-serveris not a declared dependency, and two ArchUnit rules keep it out: one refusesca.uhn.fhir.rest.server.., the other refuses registering any servlet or filter from this module. A HAPIRestfulServerregisters its own servlet and dispatches to resource providers itself — a second HTTP surface inside the single jar, outside the filter chaincoreinstalls. 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 touchingjakarta.servletat all. The first version did and failed on the Feign interceptor readingHttpServletRequest.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
HttpMessageConverterwould change how every controller in the single jar — core's included — negotiates content. - Permission codes are declared before they are used.
FhirModuleDescriptordeclaresfhir.capability.read,fhir.resource.readandfhir.resource.write. A@RequiresAccesscode a module never declares can be granted to nobody, so its endpoint 403s for every caller includingsuper_admin— and does so silently, since a missing permission looks exactly like an unprivileged user.FhirModuleDescriptorTestreads 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.readsays 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
hl7log was built around.FhirResourceRendereris 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 resource | Owner | Exchange client |
|---|---|---|
Coverage, Claim, ClaimResponse | insurance | InsuranceClient |
MedicationRequest, MedicationDispense | core's clinical engine | MedicationClient |
DocumentReference | core document | DocumentClient |
⚠ 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 RECEIVED → PENDED →
IN_REVIEW → ADJUDICATED → REVERSED; 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:
- A foreign
Codingappears only whenconcept_mappingholds that mapping, under thesource_urithe mapping itself stores (terminology_source.canonical_uri— note the field iscanonicalUri, notcanonicalUrl). - Only
SAME_ASmappings become codings.NARROWER_THAN,BROADER_THANandRELATED_TOare dropped. ACodingasserts 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. - A mapping with no
source_uriis dropped, not defaulted. The URI is the half of a coding that says whose code it is. Guessinghttp://loinc.orgbecause the source is called "LOINC" invents the half that carries the meaning. - 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 inFhirModuleDescriptor, 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
FhirAccessGuard→AccessDecisionClient. 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.
| Platform | FHIR R4 | Was |
|---|---|---|
person_name.use = ANONYMOUS | HumanName.use = anonymous | ⚠ exported as official |
patient_identifier.use = TEMP | Identifier.use = temp | ⚠ exported as usual |
person.merged_into_person_id | Patient.link type replaced-by, active = false | absent |
⚠ 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 —
genderandfamilyare bothStringin Java and aretokenandstringin FHIR;patientis aUUIDand is areference. Inferring would publish a document that is confidently wrong, which a client cannot detect. Types and prose live inSearchParamCatalog, and a test fails if a handler accepts a parameter the catalog does not describe. /fhir/metadataitself, 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 configured —
fhir.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.
| Route | Search parameters |
|---|---|
GET /fhir/Patient/{id} · GET /fhir/Patient | family, given, birthdate, gender, identifier |
GET /fhir/Practitioner/{id} · GET /fhir/Practitioner | family, given, identifier (the provider code) |
GET /fhir/Encounter/{id} · GET /fhir/Encounter | patient (required) |
GET /fhir/Observation/{id} · GET /fhir/Observation | patient (required), encounter, code |
GET /fhir/Condition/{id} · GET /fhir/Condition | patient (required), encounter |
GET /fhir/AllergyIntolerance/{id} · … | patient (required), encounter |
GET /fhir/CarePlan/{id} · GET /fhir/CarePlan | patient (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.credentialsMetadataJsonis 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. ACarePlanwith no activities is valid; synthesising one from the description would publish a care instruction nobody wrote.AllergyIntolerance.criticality. Left unstated.AllergySeveritydescribes the reaction that happened and maps toreaction.severity;criticalityis a forward-looking risk judgement a prescriber's decision support reads, and nobody made one.Encounter.partOf→ the visit.visitis 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:
| Field | Why it is ignored |
|---|---|
tenantId / facilityId | derived 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 |
id | a 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:
- the platform's own — its code is the concept id, so no dictionary lookup happens at all;
- any external system the dictionary maps — resolved through
GET /concept-mappings/resolve(TERM-017), which accepts activeSAME_ASmappings only, matched on the source's canonical URI. Sohttp://loinc.org|8480-6now 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.
| Key | Default | Purpose |
|---|---|---|
FHIR_SERVER_BASE_URL | (empty) | Absolute base URL declared as implementation.url, and the base for bundle links and fullUrl |
FHIR_SERVER_IMPLEMENTATION_DESCRIPTION | Zhenus UHP FHIR R4 endpoint | implementation.description |
FHIR_SERVER_PUBLISHER | Zhenus UHP | CapabilityStatement 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.
Related features
- 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 at — ConceptCodingResolver. 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 request — FhirServerProperties. Deriving it
means a proxy header can move where clients think the server lives.
⚠ Bean naming is a single-jar concern, not a style one — FhirContextConfig. 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.