Skip to main content

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

TablePurpose
visitA patient visit; relational status (ACTIVE/ENDED), start/stop.
encounterAn interaction within a visit.
observationA coded observation (concept-bound). value_coded_non_coded carries an unmapped answer beside value_coded_concept_id (TERM-010).
diagnosis / condition / allergyProblem list, with certainty/severity/status. diagnosis.diagnosis_concept_id is nullable since TERM-010, paired with diagnosis_non_coded.
clinical_note / care_planNarrative + plans. clinical_note.note_type is ClinicalNoteType (TERM-008N).
patient_summary_conceptWhich concepts the patient summary panel shows, and in what order (M26-001). Tenant-owned, optionally narrowed to one facility.
non_coded_term_triageOne 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.
prescriptionThe 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_orderOne 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_dispenseOne 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_typeThe 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_orderA 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_itemA named group of orderables (admission panel, sepsis bundle); instantiating it places one order per item in one call.
result_reviewOne "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_outboxOne 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 authorityencounter_location is still the only answer to where a patient is. Read by the hl7 reconciliation sweep through GET /clinical/patient-movements.
referralThe 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 ACTIVE from 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_type is a closed enum (TERM-008N). OpenAPI and search filters assumed PROGRESS / DISCHARGE / CONSULT while the column accepted any spelling. Jackson rejects unknown values (400); migration clinical/018 normalises 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. recorded is a separate flag from a null value so a client renders "No recorded value" deliberately instead of leaving a blank that reads as a rendering fault — and every value carries its observedAt, because a weight from 2019 shown without its date reads as current.
  • The panel is a clinical read of one patient and goes through PatientAccessGuard like 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 403 on denial and 404 on 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 plain 403, 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). EncounterContextService owns 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_coded and diagnosis.diagnosis_non_coded. The question stays coded: observation.concept_id is NOT NULL and 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.
  • observation also gained the first CHECK its value family has ever had. clinical/003 created all six value columns unconstrained, so one row could carry a number and a coded answer and prose. ck_observation_value_family now 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 on observation (a numeric answer legitimately has both coded columns null, and a CHECK cannot reach the question concept's datatype in another table); on diagnosis, where every row is an answer, it is — and is enforced as ck_diagnosis_coded_or_non_coded.
  • The fallback is never value_text. observation stores values OpenMRS-style, one typed column per datatype chosen by the question concept's concept_datatype_id, so value_text is 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 (default false), accepted only on a conceptSearch (type-ahead) field. A select/radio already has the right escape hatch — an Other concept 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 encounterTypeId or encounterTypeCode and a request with neither is refused rather than silently mistyped. Imaging seeds IMAGING_ORDER (M10B-003B) and IMAGING_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-orders takes a drugConceptId, or an rxNormCode resolved 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 in observation, not in these tables.
  • CDS seam. Every order placement consults the discovered exchange.spi.clinical.ClinicalDecisionSupportHook beans; 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.OrderTypeProcessor beans 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 urgentclinical_order.priority
who referredclinical_order.ordered_by_provider_id
from whereclinical_order.facility_id
for whom, in which encounterclinical_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_destination requires to_facility_id XOR to_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_at is null exactly while the status is REQUESTED (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|write is its own resource, not an action on clinical.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 a 403 can 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 a 403: 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 leave totalElements counting 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.*):

VerbPathPermission
GET/api/v1/clinical/result-reviews?status=PENDINGclinical.result-review.read
POST/api/v1/clinical/result-reviews/{resultReviewId}/acknowledgeclinical.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.
  • abnormal is written by the fulfilling module, not by the status change. An order reaching RESULTED carries no clinical detail (OrderStatusChangedEvent is ids only, deliberately, so it never carries PHI). The lab's release path does: the clinical engine subscribes to exchange.spi.lab.LabResultReleaseHook and stamps the review with the abnormal flag and coded interpretation AbnormalFlaggingService derived 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/NORMAL are clinical vocabulary, so the column holds a dictionary concept. The lab speaks the ObservationInterpretation enum, and global_property keys clinical.interpretation_concept.<INTERPRETATION> bind the two, resolved facility → tenant → platform like any other setting and seeded platform-wide by platformconfig/007. A literal concept id in Java would be wrong on the next database — concept_id is our own surrogate key and the CIEL dictionary loads only under the concept-dictionary Liquibase context. The seven CRITICALLY_* / 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 records abnormal. 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 parameterMeaning
sourceOBSERVATION or DIAGNOSIS; omit for both
facilityIdone facility, or omit for the whole session tenant
conceptIdone question concept (observation terms only; diagnoses are skipped when set)
from / toinclusive / exclusive bounds on when the term was recorded
limitdefault 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:

FieldWhat it isWho fixes it
unmappedTermsthe clinician searched the whole dictionary and the term was not therethe terminology steward adds a concept
otherSpecifyTermsthe clinician chose Other from a curated answer set and typed what they meantthe 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):

EndpointWhat it does
POST /non-coded-terms/triage/mappingsmap to a concept that already exists
POST /non-coded-terms/triage/mappings/with-new-conceptcreate the concept through the concept engine's own create path and map
POST /non-coded-terms/triage/dismissalsdismiss 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, so dismissedTermCount and mappedTermCount keep "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 on clinical.non-coded-term. That resource's .read is the TERM-010 counters, which return a number per term and are safe to hold widely. A separate resource also means a future glob over clinical.non-coded-term.* cannot reach the privileged codes. (.read/.manage rather than a .steward verb because PlatformModuleDescriptorsTest pins verbs to read|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_READ audit entry per read, recording the actor, the filters and the item counts. It never carries term text — not sampleTerm, not normalizedTerm, 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 and clinical.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:

EndpointPermission
GET·POST·PATCH·DELETE /patient-summary-conceptsclinical.patient-summary-concept.read / .write
GET /patients/{patientId}/summary-panelclinical.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.

CaseWhat happens
The type is mapped to no programmeNothing is stamped, and nothing is logged — most encounters are not programme activity
Exactly one enrolment matchesIt is stamped
No enrolment matchesThe encounter saves unattributed · one warn carrying NO_ENROLMENT
Several enrolments matchThe encounter saves unattributed · one warn carrying AMBIGUOUS_ATTRIBUTION · a clinician chooses
The caller named an enrolmentValidated against this patient, this encounter type and the encounter date — a 400 if it fails
The programme engine failsCaught, 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.

SurfaceBehaviour past the window
updateEncounterrefused
deleteObservationrefused
An observation still PRELIMINARYexempt — 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.


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 testEncounterContextServiceImpl. 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 swallowingVisitServiceImpl. 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 ownPatientAccessGuard. 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 movementPatientMovementOutboxService. 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, deliberatelyReferralServiceImpl. 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.