Clinical Foundation — How it works
Overview
The clinical engine records the core clinical record: visits and the encounters within them, coded observations, the problem list (diagnoses, conditions, allergies), clinical notes and care plans, and a patient clinical summary. It is concept-backed and references patients by id (logical, no cross-engine FK); dates and statuses are relational and indexed for query.
Data model & ownership
| Table | Purpose |
|---|---|
visit | A patient visit; relational status (ACTIVE/ENDED), start/stop. |
encounter | An interaction within a visit. |
observation | A coded observation (concept-bound). value_coded_non_coded carries an unmapped answer beside value_coded_concept_id (TERM-010). |
diagnosis / condition / allergy | Problem list, with certainty/severity/status. diagnosis.diagnosis_concept_id is nullable since TERM-010, paired with diagnosis_non_coded. |
clinical_note / care_plan | Narrative + plans. clinical_note.note_type is ClinicalNoteType (TERM-008N). |
patient_summary_concept | Which concepts the patient summary panel shows, and in what order (M26-001). Tenant-owned, optionally narrowed to one facility. |
non_coded_term_triage | One triage decision per (tenant, stream, source, question concept, normalised term) — mapped or dismissed (TERM-011). Workflow state, deliberately not stored on the clinical rows: a dismissal says nothing about the patient. |
prescription | The prescriber's document grouping medication orders (M11-009). Prescriber always attributed — derived from the signed-in user's provider record when not sent. ACTIVE/COMPLETED/CANCELLED. |
medication_order | One drug instruction within a prescription: concept-coded drug (never free text), dose/route/frequency/quantity as concept-coded qualifiers, ACTIVE/ON_HOLD/COMPLETED/DISCONTINUED/CANCELLED. |
medication_dispense | One pharmacy handover against an order. Several rows = a partial dispense; dispensed_concept_id differing from the order's drug = a substitution, stated without rewriting the order. |
order_type | The configured order-type vocabulary (M13-004): LAB/IMAGING/MEDICATION/PROCEDURE/REFERRAL seeded on the country's government tenant; deployments add rows. Reference-type ownership (facility/tenant/country). |
clinical_order | A generic order (CPOE, M13-004): concept-coded orderable, DRAFT→PLACED→IN_PROGRESS→RESULTED→COMPLETED/CANCELLED, priority, orderer, logical fulfiller_reference. Named clinical_order because ORDER is an SQL reserved word. |
order_set / order_set_item | A named group of orderables (admission panel, sepsis bundle); instantiating it places one order per item in one call. |
result_review | One "somebody has to look at this" per resulted order per reviewer (M13-010). Points at the order, never at a copy of the result: a result can be amended after acknowledgement, and a copy would claim someone reviewed a value they never saw. abnormal + interpretation_concept_id are stamped by the fulfilling module at release (M13-010B); uq_result_review_order_reviewer keeps a re-release from raising a duplicate task. |
patient_movement_outbox | One row per admit / transfer / discharge, written inside the ADT transaction (M12-006C). Append-only, identifiers only, no PHI. It exists because the movement is announced by an AFTER_COMMIT listener, and a process that dies between the commit and the listener owes a message nothing would ever find. Not a bed-state authority — encounter_location is still the only answer to where a patient is. Read by the hl7 reconciliation sweep through GET /clinical/patient-movements. |
referral | The thin 1:1 extension of a clinical_order of type REFERRAL (M13-009): destination, response and disposition only. Its own REQUESTED→ACCEPTED|DECLINED|COMPLETED status, CHECK-constrained. Everything a referral shares with any other order stays on clinical_order. |
Key rules & invariants
- A visit is
ACTIVEfrom creation until ended; encounters/observations attach to it. - Clinical facts bind to concept ids; statuses (
ClinicalStatus,DiagnosisCertainty,AllergyCategory/AllergySeverity,CarePlanStatus) are relational. clinical_note.note_typeis a closed enum (TERM-008N). OpenAPI and search filters assumedPROGRESS/DISCHARGE/CONSULTwhile the column accepted any spelling. Jackson rejects unknown values (400); migrationclinical/018normalises then CHECK-constrains and HALTs on dirty data. The column stays nullable when only free-text content is recorded.- Reads are access-scope filtered; the patient reference is logical (owned by demographic).
- The patient summary panel is configuration, not code. What belongs in a patient's at-a-glance header is a clinical decision that differs by deployment — a maternity unit and an emergency department reasonably disagree — so it is a table an administrator maintains from the UI.
- A facility list replaces its tenant's rather than extending it. Unioning the two would leave a facility unable to remove a concept its tenant configured, and "we do not weigh people on this ward" has to be sayable. A facility with no rows of its own inherits the tenant's.
- The panel returns one row per configured concept, recorded or not.
recordedis a separate flag from a nullvalueso a client renders "No recorded value" deliberately instead of leaving a blank that reads as a rendering fault — and every value carries itsobservedAt, because a weight from 2019 shown without its date reads as current. - The panel is a clinical read of one patient and goes through
PatientAccessGuardlike any other, even though it is assembled from parts that are individually authorised. - A record you may not see answers exactly as one that does not exist (SEC-011). A record-scoped
read — anything addressed by a visit, encounter, observation, diagnosis, condition, allergy, note,
care-plan or placement id — has to load the row before it knows whose patient it is, so answering
403on denial and404on an unknown id made the id alone an existence oracle. Both now answer that path's own not-found, word for word. Subject-scoped calls, where the caller named the patient itself (create*, the paged searches, the clinical summary), keep the plain403, because a refusal confirms nothing the caller did not already assert. Full rules, the exemptions, and the convention for adding a new read: Denied record reads answer as not-found. - "Which visit does this record belong to" is stated once (M10B-003B).
EncounterContextServiceowns it: the explicitly named visit, else the patient's open (ACTIVE) visit at that facility, else a new one — then the encounter within it. Observation form submissions used to hold that rule privately; it was extracted, not copied, because two implementations of "reuse the open visit or open a new one" eventually disagree and leave a patient with two concurrent stays. - A clinician may record a term the dictionary does not hold; care is never blocked by a missing
concept (TERM-010,
CORE_PLAN.md§6C). It applies to answers only —observation.value_coded_non_codedanddiagnosis.diagnosis_non_coded. The question stays coded:observation.concept_idisNOT NULLand stays that way, because questions are authored in the form builder with the dictionary in front of the author. - The pair has three legitimate states, so the CHECK is "not both null", never "exactly one": concept only (coded at entry), term only (unmapped — what the counters count), and both (retrospectively coded by a terminology steward, original wording retained per TERM-011). "Exactly one" would reject the third, which is the very row the fallback exists to produce. Write-time exclusivity — a caller may not submit a concept and a term as an initial entry — is a service rule, because only the service knows whether it is looking at an entry or at a mapping.
observationalso gained the first CHECK its value family has ever had.clinical/003created all six value columns unconstrained, so one row could carry a number and a coded answer and prose.ck_observation_value_familynow permits at most one family member, counting the coded pair as one —<= 1, not= 1, because a grouping observation carries a construct concept and no value of its own. The pair-level "not both null" rule is not expressible onobservation(a numeric answer legitimately has both coded columns null, and a CHECK cannot reach the question concept's datatype in another table); ondiagnosis, where every row is an answer, it is — and is enforced asck_diagnosis_coded_or_non_coded.- The fallback is never
value_text.observationstores values OpenMRS-style, one typed column per datatype chosen by the question concept'sconcept_datatype_id, sovalue_textis the storage for questions whose datatype is text. Reusing it would make a legitimate narrative answer and a failed coding attempt the same bytes, and the counts — the entire point of allowing the fallback — meaningless. - Off by default, per field, and only on a dictionary search. The form-builder field schema gains
allowNonCoded(defaultfalse), accepted only on aconceptSearch(type-ahead) field. A select/radio already has the right escape hatch — anOtherconcept in the answer set plus a conditional text field — which produces a legitimately coded answer; offering both on one field would give authors two ways to say the same thing with opposite downstream meaning. - The encounter type is never defaulted. Reporting must be able to tell a vitals round from an
admission from an imaging order or report, so a caller supplies
encounterTypeIdorencounterTypeCodeand a request with neither is refused rather than silently mistyped. Imaging seedsIMAGING_ORDER(M10B-003B) andIMAGING_REPORT(M10B-007A) for order placement and report authoring respectively.
API
See the API Reference. Endpoint groups under /api/v1/clinical: visits,
encounters, observations, diagnoses, conditions, allergies, notes, care plans, the patient
clinical summary, the patient summary panel, and — since M11-009 — prescriptions, medication
orders, and medication dispenses (permission family clinical.medication.*). Since M13-009,
referrals (clinical.referral.*).
Medication (M11-009)
The drug chart is three separately-auditable facts: what the prescriber signed (prescription),
what the clinician instructed per drug (medication_order), and what the pharmacy actually handed
over (medication_dispense). The gap between order and dispense is what a chart review looks at,
so the write paths keep them independent: a dispense against anything but an ACTIVE order is
refused naming the state, and a substitution lives on the dispense while the order keeps the
prescriber's drug.
- Drugs are concepts, never free text.
POST /medication-orderstakes adrugConceptId, or anrxNormCoderesolved through the concept dictionary's mappings — an invalid concept or an unmapped code is rejected with a message naming it. Clinical evidence (adherence, side effects, post-dose vitals) stays inobservation, not in these tables. - CDS seam. Every order placement consults the discovered
exchange.spi.clinical.ClinicalDecisionSupportHookbeans; a blocking verdict refuses the order with its message. No implementations registered (today's state) means pass through — an unconfigured safety net must not stop care. Drug–allergy checking over M27's allergy data is the first expected implementer. - Attribution. The prescriber is derived from the signed-in user's provider record when not sent (a client that could name any prescriber could attribute prescriptions to someone else); an explicit id is honoured for server-to-server replay. The dispenser resolves the same way but may stay empty — a stock-room account without a provider record must not block a handover that already happened.
Order widgets in clinical forms (M11-012)
Prescriptions, lab/imaging orders and — since M13-009 — referrals can be captured inside any
clinical DnD form via widget blocks (see the form engine rules): OrderWidgetProcessor
(clinical.submission, the same clinical→form SPI direction as the observation handler) places
widget sections after encounter-context resolution and before observation writes, stamping
source_submission_id provenance. Amendments never re-place; failures compensate in-transaction;
lifecycle stays on the screens. The medication and order rows a widget places are
indistinguishable from screen-placed ones except for their provenance column — charge capture,
CDS and the processors all apply identically.
Generic orders / CPOE (M13-004)
One order model for every fulfilling service, per the Zhenus_UHP Orders pattern, so lab, imaging
and pharmacy modules stop reinventing statuses. Endpoints: /orders, /order-types,
/order-sets, and POST /order-sets/{id}/orders to instantiate a set against a patient
(permission family clinical.order.*; the type and set vocabularies write under
clinical.order-type.write / clinical.order-set.write).
- Order types are configuration, not an enum. The vocabulary is rows (five seeded on the
country's government tenant), and per-type behaviour registers as
exchange.spi.clinical.OrderTypeProcessorbeans matched by code — exactly like the form engine's domain-target registry. A type with no processor is data-only: stored, searchable, event-published, and nothing else. Processors run inside the order transaction; durable side effects that must not gate the write belong behind the event instead. - Events feed the consumers. Every placement and status change publishes
OrderStatusChangedEvent(ids only, no PHI) — HL7 ORM delivery (M12-007) and charge capture (M13-007) subscribe there, so an order commits even when a downstream is unwired. - Same clinical-write posture as medication: orderable concepts validated in-process and
rejected naming the id; orderer derived from the signed-in provider (explicit id honoured,
including on set instantiation); reads behind
PatientAccessGuard; the orderable and type are immutable on update — a different orderable is a new order.
⚠ Integrators: a 404 from these endpoints is not proof the record does not exist — it is also
what a record you may not see answers. Never create a replacement record or drop a local reference on
the strength of one. POST /encounter-locations answers 400 and
GET /patients/{patientId}/current-location answers 204 for both cases instead, because that is
what each already answered for a miss. See
Denied record reads answer as not-found.
POST /api/v1/clinical/encounter-context (clinical.encounter.write, plus the patient gate) resolves
the visit and creates the encounter in one call. Its own endpoint rather than a flag on
/encounters, because it is a different act: /encounters creates an encounter in a visit the caller
already knows; this one decides which visit that should be. Engines inside core call
EncounterContextService in-process — the endpoint exists for domain modules, which cannot import
core and would otherwise reimplement clinical policy locally
(exchange.client.clinical.ClinicalRecordClient.resolveEncounterContext). Imaging orders are its
first caller (M10B-003B); lab and pharmacy orders are next.
Referrals (M13-009)
A referral IS a clinical_order of type REFERRAL. It is not a spine of its own. The ticket's
original wording — "referral (reason concept, urgency, from/to provider+facility, status,
disposition)" — predates M13-004, and building it literally would duplicate five columns
clinical_order already owns:
| The referral says… | …and the order already holds it |
|---|---|
| what it is for (reason) | clinical_order.orderable_concept_id — a concept, per the vocabulary rule |
| how urgent | clinical_order.priority |
| who referred | clinical_order.ordered_by_provider_id |
| from where | clinical_order.facility_id |
| for whom, in which encounter | clinical_order.patient_id / encounter_id |
So referral carries only what the order model has no place for: where the patient was sent
(to_facility_id / to_facility_name / to_provider_id), who answered (status, responded_at,
response_note), and what they decided (disposition_concept_id).
Why ReferralStatus is separate from OrderStatus
OrderStatus is DRAFT | PLACED | IN_PROGRESS | RESULTED | COMPLETED | CANCELLED, and none of
those can say "the receiving facility looked at this and said no". That refusal is the clinically
important event of a referral — it is the moment the referrer learns the patient still needs
somewhere to go — and there is no order state that means it.
CANCELLED is not that state either. DECLINED is not a cancellation:
- a cancelled order is simply not happening and carries nothing forward;
- a declined referral keeps its destination and its reason on file, because the next questions are always "declined by whom, and why?" and "where do we send them instead?". A referrer who cannot see that St. Luke's already refused will send the patient there again.
Collapsing the two would lose the distinction exactly when it matters, so the referral carries its
own closed lifecycle: an enum in Java and ck_referral_status on the column, never free text
(modelling rule 1). What the receiving service decided is a different question and is
concept-coded — "admitted", "treated and returned", "onward referral" are terminology and belong
in the dictionary, not in a module-local enum (modelling rule 2).
Rules the database enforces, not just the service
The service is not the only writer — the referral widget and inbound HL7 both create these — so the invariants sit on the table:
- Exactly one destination.
ck_referral_destinationrequiresto_facility_idXORto_facility_name. The widget picks a facility this platform knows; an inbound referral names one it does not. A referral with neither is not a referral. - The destination is deliberately not a foreign key. A referral may point at a facility that later closes or leaves the platform, and losing the record of where a patient was sent is worse than holding an id that no longer resolves.
- One live referral per order, via a partial unique index (
WHERE voided = false), so voiding one frees the order to be referred again. responded_atis null exactly while the status isREQUESTED(ck_referral_responded_at) — a decision with no timestamp cannot be audited, and a timestamp with no decision is noise.
API and access
Endpoints under /api/v1/clinical/referrals (permission family clinical.referral.*):
POST to attach referral detail to an existing REFERRAL order, POST /{referralId}/response to
accept/decline/complete, GET /by-order/{clinicalOrderId}, GET /patients/{patientId} for a
patient's history, and GET /inbound/{toFacilityId} for a receiving facility's worklist.
- There is deliberately no "create referral order" endpoint here. The order is placed through the order pipeline, so order rules keep exactly one enforcement point.
- A referral is always born
REQUESTED. The create path ignores any status a caller sends — accepting one would let a client record that a facility agreed to something it was never asked. The response is a separate request because it is made by a different party at a different time. clinical.referral.read|writeis its own resource, not an action onclinical.order, because the audience differs: the people answering inbound referrals are the receiving facility's staff, who have no reason to place orders on the sending facility's patients. There is no delete — a declined referral stays on file.- Reads use
authorizeOrNotFound, so a403can never confirm that a patient exists (denial convention). - The inbound worklist is scoped to its destination, before the read.
GET /inbound/{toFacilityId}asks "do you work at the destination?" — because that is what the endpoint is addressed by — and a caller with no scope over that facility gets an empty page, not a403: a worklist's miss answer is an empty page, so a denial has to look like "nothing has been sent here". The gate runs before the query rather than filtering rows afterwards, because a post-filter would leavetotalElementscounting referrals the caller cannot see, which leaks the size of another facility's queue. It fails closed when no caller can be resolved. - ⚠ There is deliberately no per-patient grant filter on the worklist.
PatientAccessGuard's visibility test resolves to "the caller holds an active grant or break-glass for this patient", and the receiving facility holds neither — not holding one is precisely why a referral is being sent to them. Filtering on it would return a permanently empty worklist. Making a referral confer a scoped, time-boxed grant on its destination is the right answer and is an open modelling question (M13-009 follow-up), not something to approximate here.
The referral widget
Capture is a fourth order-widget branch (referralWidget, payload key referrals) alongside
prescription, lab and imaging — not a submission handler of its own, because a referral is a
clinical order and a separate handler would put a second write path into clinical_order. It follows
the processor's two-phase discipline exactly: the destination check, the reason concept and the
disposition concept all validate in phase one, before any write, so a malformed item shows the
clinician a form error instead of poisoning the shared submission transaction. Referrals are placed
one at a time rather than through the bulk forEach, because each needs the id of the order it
extends. Amendments never re-place, like every other widget.
The placed REFERRAL order carries source_submission_id like every other widget-placed row — that
column is the retry floor (existsBySourceSubmissionId), so an order without it would let a
resubmitted form refer the patient a second time. One consequence to know:
uq_clinical_order_source_submission is (submission, type, orderable), so two referrals in a single
submission must differ by reason — which is exactly what "one order per submission+type+orderable"
already means for lab and imaging.
Results review and sign-off (M13-010)
A resulted order raises one task for the provider who ordered it, and that task leaves the worklist
only when a provider records that they have seen it. Endpoints (permission family
clinical.result-review.*):
| Verb | Path | Permission |
|---|---|---|
GET | /api/v1/clinical/result-reviews?status=PENDING | clinical.result-review.read |
POST | /api/v1/clinical/result-reviews/{resultReviewId}/acknowledge | clinical.result-review.write |
- Neither endpoint takes a provider id. The worklist is always the caller's own and the sign-off
is always attributed to the caller, both resolved from the signed-in user's provider record — the
same posture as the prescriber on a prescription and the orderer on an order. A provider id in the
payload would let one clinician read another's inbox and record another's sign-off, and "who saw
this result, and when" is the only question the record exists to answer. A signed-in user with no
provider record gets
400, not an empty list: an empty worklist reads to a clinician as "nothing to review". - Abnormal first, then oldest first. The ordering is the safety property — a critical potassium sitting below thirty normal results is exactly the failure a worklist exists to prevent — and within that, the longer a result has waited the more likely it is the one nobody has looked at.
abnormalis written by the fulfilling module, not by the status change. An order reachingRESULTEDcarries no clinical detail (OrderStatusChangedEventis ids only, deliberately, so it never carries PHI). The lab's release path does: the clinical engine subscribes toexchange.spi.lab.LabResultReleaseHookand stamps the review with the abnormal flag and coded interpretationAbnormalFlaggingServicederived from the catalog's reference ranges. Either trigger raises the review and the other finds it, through the same dedup rule the unique index enforces. Before this the two columns were never written and "abnormal first" had nothing to sort by.- Abnormal latches on and an acknowledged review is frozen. One order can answer several times — one release per analyte — and an order whose second analyte came back abnormal is an abnormal order; clearing the flag on a later normal value would drop the row down a list sorted on exactly that column. After acknowledgement the row records what the clinician was shown, so nothing edits it further.
- The interpretation is a concept id, bound by configuration.
HIGH/LOW/NORMALare clinical vocabulary, so the column holds a dictionary concept. The lab speaks theObservationInterpretationenum, andglobal_propertykeysclinical.interpretation_concept.<INTERPRETATION>bind the two, resolved facility → tenant → platform like any other setting and seeded platform-wide byplatformconfig/007. A literal concept id in Java would be wrong on the next database —concept_idis our own surrogate key and the CIEL dictionary loads only under theconcept-dictionaryLiquibase context. The sevenCRITICALLY_*/OFF_SCALE/SIGNIFICANT_CHANGE_*values are deliberately unbound, because the seeded dictionary has no concept for them: the review keeps a null interpretation and still recordsabnormal. A wrong coded interpretation on a clinical row is worse than none. - A second acknowledgement is refused, and a denial reads as not-found. Who saw it first and when
are the two facts the record holds; a second look is not a correction of the first. Sign-off is
guarded with
authorizeOrNotFound, so a review whose patient the caller may not see answers exactly what an unknown id answers.
⚠ Nothing in the platform moves an order to RESULTED on its own. The lab's release writes the
observation and fires the hook above; it does not touch clinical_order.status, and imaging orders
carry no clinical_order_id at all. So a review today is raised by a released lab result, or by an
integration that PUTs the order to RESULTED. Closing that loop is a separate ticket.
Unmapped-term counters (TERM-010)
GET /api/v1/clinical/non-coded-terms (clinical.non-coded-term.read) returns unmapped terms ranked by
how often they were recorded, grouped by question concept and normalised term. This is what makes
"the escape hatch has become the default" visible, and it is the aggregation TERM-011's triage screen is
built on.
| Query parameter | Meaning |
|---|---|
source | OBSERVATION or DIAGNOSIS; omit for both |
facilityId | one facility, or omit for the whole session tenant |
conceptId | one question concept (observation terms only; diagnoses are skipped when set) |
from / to | inclusive / exclusive bounds on when the term was recorded |
limit | default 100, hard ceiling 500 |
Each row carries occurrences, unmappedOccurrences (still no concept beside the term) and
mappedOccurrences (retrospectively coded — a useful signal in itself: it shows which of the
dictionary's terms were learned from the field rather than imported).
Normalisation is trim + case-fold only. HTN and hypertension stay two rows on purpose: deciding
they are the same term at this facility is a human judgement, and merging them at write time destroys
the evidence that judgement needs. Rows are cross-patient verbatim clinical phrases, which is why the
endpoint has its own permission rather than riding on clinical.observation.read, and why the limit is
capped rather than unbounded.
Unmapped-term triage (TERM-011)
The counters make the gap visible; the triage API is what closes it. An unmapped term is a work item, not a dead end — a fallback with no triage queue is just a free-text column with a longer name.
Two streams, one queue, never merged. GET /api/v1/clinical/non-coded-terms/triage
(clinical.terminology-triage.read) returns two arrays, not one list with a discriminator:
| Field | What it is | Who fixes it |
|---|---|---|
unmappedTerms | the clinician searched the whole dictionary and the term was not there | the terminology steward adds a concept |
otherSpecifyTerms | the clinician chose Other from a curated answer set and typed what they meant | the form author extends that question's answers |
They are the same shape of evidence and a different fix, so merging them would route half the queue to
someone who cannot act on it. Every item carries stream, and actionable says outright whether the
mapping actions apply — only the unmapped stream is mappable, because an Other answer is already
coded and no amount of dictionary work fixes a deficient answer set. Other-specify items also carry
parentQuestionConceptId: the question a form author must actually edit.
The Other-specify stream is detected without any naming convention. The built pattern stores the free text as a second observation whose question concept is the very concept its sibling chose as a coded answer, so that pairing is the query — no concept has to be called "Other".
Query parameters: facilityId, conceptId, from, to, limit, and includeDecided (default
false). Items also carry facilityIds and formDefinitionIds — a term seen on one form in one
facility is a local habit; the same term across twelve is a dictionary gap.
Three actions, each its own endpoint (all clinical.terminology-triage.manage, all 201, all
returning NonCodedTermTriageDecisionDto):
| Endpoint | What it does |
|---|---|
POST /non-coded-terms/triage/mappings | map to a concept that already exists |
POST /non-coded-terms/triage/mappings/with-new-concept | create the concept through the concept engine's own create path and map |
POST /non-coded-terms/triage/dismissals | dismiss as noise, with a mandatory reason |
with-new-concept delegates to ConceptService.createConcept and never inserts a concept itself, so
one code path owns dictionary writes. Everything that could refuse the request is checked before
the dictionary is written, so a failed mapping never leaves an orphan entry behind.
It is atomic. Creating the concept, recording the mapping and re-coding the historical rows are
one transaction — the concept-create path uses the default REQUIRED propagation, so it joins the
caller's transaction rather than committing on its own. A failure at any step leaves no concept, no
mapping and no re-coded rows. This matters more than ordinary transactional hygiene because the
dictionary has no undo that reaches the analytics extracts it feeds: a concept committed independently
would be permanent, and one committed after rows were coded against it would leave clinical records
pointing at an entry the rest of the operation disowned. aTransactionSpansTheWholeCreateAndMap pins
the propagation, because giving a delegate REQUIRES_NEW is the cheapest way to break this and no
behavioural test would notice.
⚠ A concept created here that maps to no standard is expected and correct. Creating a local
concept and mapping it to SNOMED / LOINC / ICD later through concept_mapping is the intended
platform workflow; that resolution is the system-maintenance team's job, and this flow neither forces
nor pre-empts it. The request body stays { source, questionConceptId, term, concept: ConceptDto } —
ConceptDto is the platform's concept contract, and this wrapper deliberately grows no
concept-describing fields of its own.
Mapping is retrospective coding, not an amendment. In real hospitals coding is a retrospective
activity, so putting a concept on last month's rows does not change what anybody meant. Each affected
row is versioned, never mutated: the original is voided and a replacement inserted carrying the
concept and the original text, with previous_version pointing back (observation.previous_version
since clinical/003; diagnosis.previous_version added by clinical/018 for exactly this). The
central audit trail gets one entry per affected row — a single "mapped a term" entry would record
that a mapping happened without recording whose records it reached.
Rules the API enforces, and why:
- Never across tenants. The tenant comes from the session and no request field can name one. "CVA" is not reliably the same term in two hospitals.
- The question is part of a term's identity. Required for an observation, refused for a diagnosis (which has none) — supplying one there would read as a narrowing filter and be silently ignored, mapping far more rows than intended.
- You must be able to see the source rows. Mapping or dismissing a phrase whose records are outside
your access is refused (
403), because dismissing evidence about patients you cannot read is silencing somebody else's record. - Re-mapping is refused, not silently double-versioned (
400). A second mapping would write a second version of every row and leave two stewards' readings in the amendment chain with nothing to say which is current. A term previously dismissed may still be mapped: the dismissal is voided and the mapping proceeds. - A dismissed term leaves the queue but stays countable. The decision is a row in
non_coded_term_triage, never a deletion, sodismissedTermCountandmappedTermCountkeep "we have triaged 300 of 340 terms" answerable. - The original text is never deleted on mapping. That is the one change that would make a mapping permanently unreviewable.
Why the queue is gated rather than filtered (PO, 2026-08-09)
The queue is tenant-wide and not patient-visibility filtered: sampleTerm is verbatim clinician
text, and a steward sees it for patients they may never have treated.
Per-patient filtering was considered and rejected. It would make the queue partial, and a partial queue hides exactly the term most worth acting on — the frequent one, recorded about many patients, of whom the steward would be scoped to few. The dictionary would then stop being maintained, which is the only reason free-text capture was acceptable in the first place. So the queue stays complete and the exposure is made deliberate and accountable instead:
- Its own permission resource,
clinical.terminology-triage.*— not an action onclinical.non-coded-term. That resource's.readis the TERM-010 counters, which return a number per term and are safe to hold widely. A separate resource also means a future glob overclinical.non-coded-term.*cannot reach the privileged codes. (.read/.managerather than a.stewardverb becausePlatformModuleDescriptorsTestpins verbs toread|write|delete|manage.) - Enforced in the service (
TerminologyStewardGuard), not only by the controller's@RequiresAccess. Engines call each other in-process, so a controller-only control is one the next in-process caller never meets — and the refusal lands before the terms are loaded. - One
ACCESS/PHI_READaudit entry per read, recording the actor, the filters and the item counts. It never carries term text — notsampleTerm, notnormalizedTerm, not a truncated prefix. Auditing a PHI exposure by copying the PHI into a second store, one kept longer and read by more people, makes it worse rather than accountable. What is recorded is the shape of the access, which is what answers "did someone pull the whole tenant's backlog in one call?". - Excluded by name from CLINICIAN / NURSE / FRONT_DESK in
BaselineRole, both this family andclinical.non-coded-term.*.
⚠ Knowingly inconsistent: the TERM-010 counters endpoint returns the same sampleTerm under the
freer clinical.non-coded-term.read. It is merged and FE-376 is built against it, so it was left alone
rather than broken in passing — but it is the same disclosure through a wider door.
The panel is two endpoints with deliberately different gates — choosing what every clinician sees is an administrative act over the deployment; reading one patient's panel is a clinical act over one record:
| Endpoint | Permission |
|---|---|
GET·POST·PATCH·DELETE /patient-summary-concepts | clinical.patient-summary-concept.read / .write |
GET /patients/{patientId}/summary-panel | clinical.observation.read, plus the patient access guard |
GET /patients/{patientId}/summary-panel returns demography (name, gender, birth date, server-computed
age), both identifier kinds — patient-role and person-level — and the latest value of each configured
concept with its units and date. It is one call so a form can render its header without stitching four
responses together.
Programme attribution (M36-002)
An encounter can say which programme enrolment it counts toward, in
encounter.patient_program_id. That is what turns "an ANC visit happened" into "this ANC enrolment had a
visit", and it is what a national programme report is built from.
How it is decided. An administrator declares which encounter types belong to a programme
(program_encounter_type_mapping, M36-001). On every encounter the resolver looks the type up, finds the
patient's enrolments in those programmes that were open on the encounter's date, and stamps the one
that matches.
| Case | What happens |
|---|---|
| The type is mapped to no programme | Nothing is stamped, and nothing is logged — most encounters are not programme activity |
| Exactly one enrolment matches | It is stamped |
| No enrolment matches | The encounter saves unattributed · one warn carrying NO_ENROLMENT |
| Several enrolments match | The encounter saves unattributed · one warn carrying AMBIGUOUS_ATTRIBUTION · a clinician chooses |
| The caller named an enrolment | Validated against this patient, this encounter type and the encounter date — a 400 if it fails |
| The programme engine fails | Caught, warned, unattributed — the encounter always saves |
⚠ Attribution never blocks a clinical write. It runs inside EncounterServiceImpl.createEncounter,
which is the single choke point the encounter controller, every form submission, ADT admission, transfer
and discharge, and every domain module over ClinicalRecordClient all funnel through. A resolver that
threw on a configuration gap would take admission down over an unmapped encounter type.
⚠ Open on the ENCOUNTER's date, not today. A back-dated encounter belongs to the enrolment that was running when the care happened. Matching "currently open" instead would attach last March's visit to whatever the patient is enrolled in now, and would attach nothing at all once an enrolment completes — rewriting history as enrolments close.
⚠ The miss lines carry no PHI. They are read from aggregated logs, so they name the encounter type,
the facility and the number of programmes — never the patient, the enrolment or a name. They are warn,
not error: an unmapped type is an administrator's unfinished configuration, not a fault.
⚠ Update can attribute, but never re-point or clear. An encounter that already counts toward an enrolment is programme data; moving it changes what two registers report, and an ordinary update is the wrong instrument. Naming a different enrolment on update is refused rather than ignored, so a caller is never told a correction succeeded when nothing moved. Re-pointing is a separate audited action.
⚠ patientProgramId is not public. On the way out it is nulled for callers who may not see the
enrolment (M36-008) — otherwise anybody able to read an encounter learns the patient is in some
programme, which for a confidential programme is the disclosure itself.
The clinical edit window (M29-001)
A clinician may correct what they just wrote. Past a configured window they may still correct the record, but the correction becomes a visible amendment rather than an edit.
⚠⚠ The clock runs per authored record, from that author's own submission — not per encounter, and not from the encounter's end. That distinction is what makes the window usable in a real clinical thread.
A lab journey is one thread across three authors: the clinician orders, the phlebotomist collects the sample, the scientist enters the result, and weeks or months may pass between them. Clocking from the encounter's end would hold the entire thread editable for a month. Clocking per encounter would close the window under the phlebotomist before they ever opened it. Because the clock runs per record, the elapsed time that matters is the time between records, and nothing is held open for three months.
| Surface | Behaviour past the window |
|---|---|
updateEncounter | refused |
deleteObservation | refused |
An observation still PRELIMINARY | exempt — an unverified result is meant to be corrected; the window guards the verified record |
Amending after the window
EncounterAmendmentService writes a new encounter carrying amends_encounter_id and a required
amendment_reason. The original is never modified, and an amendment cannot itself be amended —
otherwise the chain stops being a record of what happened and becomes a record only of the last edit.
Configuration & feature flags
uhp.clinical.edit-window.minutes — default 60.
⚠ <= 0 disables the window entirely. That is deliberate: a deployment that has not yet decided
its correction policy should not be silently enforcing an hour it never chose.
Related features
- Denied record reads answer as not-found — what a caller
observes when the patient gate refuses, and why
404is not evidence of non-existence. - Concept, Demographic (patient),
Program (enrollment context). Domain plug-ins read via
exchange.client.clinical.
Why it is this way
Access is decided per patient, on every read, and recorded. Holding a clinical role is not permission to open a particular record — the two are separate layers, and conflating them is how a clinician ends up able to read a colleague's family.
Clinical writes require the visit and encounter context. A record has to belong to a patient and an episode; without that it has nowhere correct to file, which is why forms open from the patient dashboard rather than a forms menu.
A referral is a clinical order (M13-009), not a parallel mechanism. It has the same lifecycle, so it inherits ordering, status and audit rather than growing a second version of each.
Traps
⚠ Existence is not the test — EncounterContextServiceImpl. A visit type belonging to another
tenant or facility exists perfectly well; the question is whether it is usable here. Checking for
existence alone lets one tenant's configuration leak into another's visit.
⚠ The discharge path throws rather than swallowing — VisitServiceImpl. A failure during
discharge must surface: a swallowed one leaves a bed occupied by a patient who has gone home, and the
board is believed.
⚠ An empty answer from the patient access guard proves nothing on its own —
PatientAccessGuard. A removed guard produces exactly the same empty value as a guard that ran and
found nothing, so tests must prove the guard executed, not merely that the result was empty.
⚠ The movement outbox must be called inside the transaction that performs the movement —
PatientMovementOutboxService. Outside it, the event and the movement can disagree, and the record
of where a patient went stops matching where they are.
⚠ Referral listing has no per-patient grant filter, deliberately — ReferralServiceImpl. That is
a decision with a reason attached in the source; read it before "fixing" it.
⚠ Order submissions are deduplicated by a unique constraint, not by a check-then-insert —
OrderWidgetProcessor. A resubmitted form must not raise the same order twice, and only the database
can promise that under concurrency.
Which programme an encounter counts toward is not public (M36-008)
An encounter carries patientProgramId — the enrolment M36-002 attributed it to. That field is
suppressed for a caller who may not see the enrolment: they get the encounter in full, with
patientProgramId: null.
⚠ The column is an inference channel, which is why the enrolment guard is not enough. Anybody who
may read the encounter would otherwise learn the patient is in some programme — and because the
encounter-type mapping is readable configuration, they could narrow it to the mapped one.
EnrollmentAccessGuard protects the enrolment read; it does not protect that inference.
⚠ Nulled, never refused. A 403 would be an oracle: it confirms the enrolment exists, which is the
disclosure being prevented. Absence is the answer. For the same reason, filtering
GET /clinical/encounters?patientProgramId=… by an enrolment the caller cannot see returns an
empty list, not an error — the same answer they would get for an enrolment with no encounters.
⚠ Attribution still happens. A clinician who cannot see the enrolment still records an encounter that is attributed to it; classification is server-side and does not depend on who is looking. They simply cannot read the attribution back.
Every suppression increments uhp.clinical.encounter.programme_attribution_suppressed, so a hidden
attribution is never silent — an unmonitored suppression is indistinguishable from an attribution
that never happened.