Lab (LIMS) — How it works
Overview
Zhenus LIMS is a separate domain plug-in module (com.zhenus.uhp.api.lab) with its own lab
PostgreSQL schema. It mirrors the imaging module's boundary: a decoupled Feign plug-in that never
imports core or a sibling domain module, and reaches platform engines (tenant, facility,
demographic, accesscontrol, clinical, concept, notification, audit) only over Feign. Design:
MILESTONE20_PLAN.md.
M20-002 adds the orderable test catalog, panels, and reference ranges.
M20-003 adds order intake and accessioning (accession numbers, barcodes, per-specimen
tracking ids) via a single LabOrderService#accession path consumed by the LAB
OrderTypeProcessor and the REST accession desk. M20-004 adds the specimen lifecycle
(collect → receive → aliquot → store / reject) with an append-only custody trail. M20-005
adds result entry, two-step validation (tech → pathologist), reference-range abnormal
flagging, and release into clinical observations (Feign); outbound ORU plugs in via
LabResultReleaseHook (M12-007B). M20-008 adds panic bounds on reference ranges and
raises critical results on release through the shared M10B-009A acknowledgement loop. M20-006 adds
control-lot QC runs,
write-time z-scores for Levey-Jennings charts, and a vendor-neutral analyzer interface seam.
Data model & ownership
Owned tables live under lab/src/main/resources/db/changelog/lab/. The master changelog is
deliberately named lab.db.changelog-master.yaml, not db.changelog-master.yaml — the latter
collides with core's on the single-jar classpath.
| Area | Migration | Purpose |
|---|---|---|
| Schema | 001 | CREATE SCHEMA IF NOT EXISTS lab |
| Catalog | 002 | lab_specimen_type, lab_method, lab_test, reference_range, panel, panel_test |
| Orders | 003 | lab_accession_format, lab_order, lab_order_item |
| Specimens | 004 | lab_specimen, lab_custody_event (append-only) |
| Results | 005 | lab_result, lab_result_value |
| Quality control | 006 | qc_run, qc_result (expected mean/SD, observed value, derived z-score) |
| Panic bounds | 007 | reference_range.panic_low_value, reference_range.panic_high_value |
Catalog rows are GLOBAL-CAPABLE config: tenant_id from the session, optional facility_id
(null = tenant-wide). Visibility is facility ∪ tenant ∪ country via TypeScopeClient /
LabScopeResolver. Soft-delete via AuditTrail + @SQLRestriction("voided = false").
A lab test's clinical identity is a concept_id (Long) — never a LOINC string on the row.
LOINC arrives through concept_mapping. Units are a UCUM units_term_id (TERM-006), never
free text. Specimen type and method are CRUD reference entities (same categorical-field reason as
imaging modalities).
Reference ranges are many rows per test (sex + age band in days). Optional panic bounds
(panic_low_value / panic_high_value) sit on the same row, wider than the normal band.
Accessioning (M20-003)
lab_orderis the module's view of a clinicalLABorder.clinical_order_idis a logical reference (no cross-module FK). The clinical order stays the source of truth.- Accession numbers are unique per tenant (
ux_lab_order_accessionhas novoided = falsepredicate — a cancelled order never releases its accession). - The format/counter is per facility (
lab_accession_format) under a pessimistic row lock. A facility with no configured format gets a platform default on first accession (imaging's M10B-003C lesson). lab_order_itemlines expand a panel's members or a single catalog test; lines that share a specimen type share onespecimen_tracking_id. Barcode payloads are the accession (order label) and the specimen tracking id (specimen label) for the FE to print.LabOrderTypeProcessor(orderTypeCode = LAB) calls the sameaccessionmethod and returns the lab order id so core stampsclinical_order.fulfiller_referenceinside the placement transaction.
Specimen lifecycle (M20-004)
- Specimens are created on collect from an accession-time
specimen_tracking_id. - Status moves collect → receive → aliquot → store (validated); reject is allowed from any non-rejected status and keeps the row with a mandatory reason.
lab_custody_eventis append-only (no void columns, no update API).LabSpecimenService#requireAcceptsResultsrefuses rejected specimens — the gate M20-005 uses.
Result entry, validation, and release (M20-005)
- Status is relational:
ENTERED→TECH_VALIDATED→PATH_VALIDATED→RELEASED. - Release is the gate — clinical observations are created only on release after pathologist validation. Auto-release on entry is refused.
- Abnormal flagging is driven from
reference_range(sex + age band in days); never hardcoded thresholds. - Panic bounds on the matched reference range are evaluated only after release (never on
entry or validation).
LabCriticalValueReleaseHookcallsLabCriticalValueService, which raises throughCriticalResultClientwithsource_module = laband the result id assource_record_id. Lab owns no escalation table or scheduler (M10B-009A incore). - Amendments append a new
lab_resultwithreplaces_result_id; the prior released row is never overwritten. Releasing an amendment publishes anAMENDEDobservation withpreviousVersion. - Outbound ORU is not inline:
LabResultReleaseNotifiercallsLabResultReleaseHooksubscribersAFTER_COMMIT(same shape asPatientMovementHook). Panic raise is one such hook; zero other hooks is normal until M12-007B.
Quality control and analyzer seam (M20-006)
- A
qc_runrecords the test, optional method, control lot/level, expected mean and strictly positive standard deviation. Eachqc_resultrecords an observed value and its write-timez_score = (observed - expectedMean) / expectedSdat four decimal places. - Manual entry calls
QcRunService#recordManualdirectly and does not resolve an analyzer. A lab with no physical analyzer configured therefore retains the full QC workflow. InstrumentInterfaceis the deployment adapter SPI: stableproviderName(),isConfigured(), and a normalized ingest method.InstrumentInterfaceRouterfollows the notification-provider fall-through shape.SimulatedAnalyzeris always configured and is the default.Hl7AstmInstrumentAdapteris only a vendor-neutral hook: it reports unconfigured until an HL7 or ASTM endpoint is set. Vendor dialect parsing belongs in deployment-specific adapters, not the QC service.
Key rules & invariants
A separate module with its own schema, so extraction needs no caller changes
Pointing lab's Feign URLs elsewhere later requires no change to any caller — every cross-module reference is logical (Feign-resolved), never a foreign key.
Every M20 permission family is declared from the skeleton onward
LabModuleDescriptor declares lab.catalog, lab.order, lab.specimen, lab.result, lab.qc
and lab.critical (.read/.write). Catalog CRUD uses lab.catalog.read / .write. Accessioning
uses lab.order.read / .write; the accession-format admin reuses catalog permissions. Specimen
lifecycle uses lab.specimen.read / .write. Result entry / validation / release uses
lab.result.read / .write. QC listing and chart data use lab.qc.read; manual/instrument writes
use lab.qc.write.
No core import, no sibling-domain import
LabArchitectureBoundaryTest asserts lab never depends on core, imaging, workforce or hl7.
COALESCE uniqueness on nullable facility_id
Unique indexes key (tenant_id, COALESCE(facility_id, nil-uuid), …) WHERE voided = false so two
tenant-level rows cannot silently duplicate.
API
Base path ApiV1Paths.LAB (/api/v1/lab):
| Surface | Paths |
|---|---|
| Specimen types / methods | /specimen-types, /methods |
| Tests | /tests, /tests/page, /tests/{id} |
| Reference ranges | /tests/{id}/reference-ranges |
| Panels | /panels, /panels/{id}/tests |
| Orders (accession desk) | POST/GET /orders, GET /orders/{id}, GET /orders/by-accession/{accession} |
| Accession format | GET/PUT /accession-format |
| Specimens | POST /specimens/collect, GET /specimens, GET /specimens/{id}, GET …/by-tracking/{id}, GET …/custody, `POST …/receive |
| Results | POST/GET /results, GET /results/{id}, POST …/validate-tech, POST …/validate-path, POST …/release, POST …/amend |
| Quality control | POST/GET /qc-runs, GET /qc-runs/{id}, POST /qc-runs/ingest?provider=… |
Cross-module read contract (M20-009): exchange.client.lab.LabCatalogClient and
LabResultReadClient mirror the catalog and order/result GET surfaces above (listTests,
searchTests, getTest, listReferenceRanges, listPanels, searchPanels, getPanel,
listPanelMembers, listOrders, getOrder, getOrderByAccession, listResults, getResult).
Read-only — portals and BFF layers consume released results over Feign without importing lab. URL:
${lab.service.url:${uhp.platform.url}} (default in lab/uhp-module-defaults.yml and loopback in
app/application.yml). ⚠ Scheduled callers still 403 until SEC-018 lands service identity:
FeignContextPropagationConfig reads RequestContextHolder, which is empty off-request.
Configuration & feature flags
LAB_APP_PORT (default 8087); LAB_SERVICE_URL in .env.example (Feign loopback for
LabCatalogClient / LabResultReadClient).
Analyzer properties are optional: LAB_INSTRUMENT_DEFAULT_PROVIDER defaults to simulated;
LAB_INSTRUMENT_HL7_ENDPOINT and LAB_INSTRUMENT_ASTM_ENDPOINT enable the external adapter hook.
Leaving both endpoints blank is a supported configuration.
Testing
LabApplicationTest boots against the dedicated test DB and applies the lab Liquibase changelog.
Service unit tests cover catalog CRUD and accessioning (unique accession, idempotent clinical-order
key, cancel retains accession), QC z-score derivation, manual no-analyzer entry, and simulated
instrument ingestion. Exchange LabCatalogContractsTest / LabOrderContractsTest pin concept ids
as Long and the OrderTypeProcessor fulfiller-stamp return type. LabClientContractsTest (M20-009)
pins the read-only Feign paths and query-parameter names for LabCatalogClient /
LabResultReadClient.
Milestone gate (M20-007)
Milestone20LimsIntegrationTest walks the full LIMS journey against uhp_test: catalog →
accession → specimen custody → result validate/release → observation publish → panic raise →
critical-result acknowledgement. Platform Feign round-trips are stubbed; lab schema persistence is
real. See core/MILESTONE20.md.
Related features
Imaging shares the critical-result acknowledgement loop this module will consume in M20-008. Workforce is the other domain-module plug-in with the same boundary shape. FE pairs: FE-248 (catalog), FE-249 (accessioning desk).
Patient merge re-point (M37-005)
POST /api/v1/lab/patient-merges/repoint bulk-updates patient_id on lab_order, lab_specimen, and
lab_result for the session tenant. Invoked by core after M37-004; returns per-table counts and
PARTIAL when rows remain on the loser patient.
Why it is this way
Tests are concept-backed; LOINC never lives on a lab-local column. Analyte identity is
concept_id (Long); standard codes arrive through concept_mapping. A per-module analyte table
would fork the dictionary silently.
Release is the clinical gate. Observations publish only after pathologist validation; panic evaluation runs after release, never on raw entry — otherwise unvalidated panic noise reaches the chart.
Critical results use the shared platform loop (M10B-009A). Lab raises through
CriticalResultClient; there is no lab-local escalation table or scheduler to maintain in parallel.
Instrument adapters mirror NotificationProviderRouter. A simulated provider is always
available; real HL7/ASTM endpoints are optional per deployment. Manual QC and manual result entry
must work with no analyzer configured.
Traps
⚠ Chain of custody is append-only — rejections keep the specimen row with a mandatory reason; results against rejected specimens are refused. Deleting custody events destroys medico-legal trace.
⚠ Accession numbers are server-minted — never on input DTOs. Client-supplied accession keys break uniqueness under concurrency and bypass catalog permission checks.
⚠ ORU is AFTER_COMMIT via LabResultReleaseHook — do not inline HL7 send inside the release
transaction; a failed wire must not roll back a validated result. Since M12-007B the hl7 module's
LabOruReleaseHook subscribes here and delivers the ORU^R01 (see the HL7 engine doc); inbound
external lab orders arrive as ORM^O01 and enter through the generic order rail, so they accession
through the same accession(ClinicalOrderDto) method as every other intake path.
⚠ QC z-scores derive at write — store expected mean/SD on the QC run; do not recompute from historical results ad hoc or Levey-Jennings charts drift from persisted facts.
⚠ Integration tests stub platform Feign but use real lab schema — a green unit test with mocks
only does not substitute for Milestone20LimsIntegrationTest on uhp_test.