Imaging — How it works
Overview
Zhenus Imaging is a separate domain plug-in module (com.zhenus.uhp.api.imaging) with its own
imaging PostgreSQL schema. It is a RIS that integrates with a PACS — it is not a PACS. It owns the
imaging procedure catalog, orders and accession numbers, and the seam to whatever archive a facility
runs. Platform engines (tenant, demographic, access-control, notification) are reached only via Feign.
Design: MILESTONE10B_PLAN.md.
The one sentence to keep: we never store pixels. What we keep is the Study Instance UID and where the study can be fetched from; the bytes stay in the archive. A contract test fails the build if the study reference DTO ever grows a pixel, instance or frame field.
Data model & ownership
Owned tables live under imaging/src/main/resources/db/changelog/imaging/. The master changelog is
deliberately named imaging.db.changelog-master.yaml, not db.changelog-master.yaml — the latter
collides with core's on the single-jar classpath.
| Area | Migration | Purpose |
|---|---|---|
| Procedure catalog | 002, 008 (TERM-003) | imaging_procedure (required concept_id since 008), imaging_modality. imaging_code_system was removed by TERM-003 (absorbs TERM-002) — procedure coding uses platform concepts, not a local registry |
| Orders | 003 | imaging_order, imaging_requested_procedure, imaging_accession_format |
| Archive configuration | 004 | facility_pacs_config, facility_calling_ae_title |
| RIS mode | 005 | facility_ris_config, plus imaging_order.accession_issuer |
Key rules & invariants
Everything categorical is an enum or an entity — never free text
Plan §5.1. If an external standard fixes the value set, or our own code branches on it, it is an enum in
exchange (order priority, order status). If a deployment extends it, it is an entity with CRUD
scoped by the three rungs (modality). Procedure coding is not a local CRUD entity — TERM-003
binds each imaging_procedure to a platform concept_id, validated over Feign
(ConceptValidationClient); LOINC/RadLex arrive through concept_mapping like every other clinical code.
displayName stays a local catalog label beside the concept, like program.name. The middle ground — a
String with a format check — is forbidden: format constrains the shape and says nothing about meaning,
so a typo becomes a new category, CT and Ct become two modalities, and the worklist filter silently
misses one.
Procedure catalog uniqueness (TERM-003). Live rows are unique on (tenant, facility, concept_id).
concept_id is immutable on update — change the concept by void-and-recreate. Migration 008 HALTs if
any imaging_procedure or imaging_code_system row remains; there is no backfill from the old
procedure_code string.
Scope is resolved once, and fails closed
ImagingScopeResolver is the only place the module asks what scope it is in. It calls
GET /api/v1/tenant/type-scopes/current (M10B-002A) because the three-rung TypeScope lives in core
and a domain module may not import it. If the platform cannot be reached it throws rather than
assuming — guessing would silently widen catalog visibility, which is what the three-rung model exists to
prevent. Note the asymmetry with the archive seam below: guessing a scope leaks data, so it fails
closed; an unreachable archive just means no images, so it degrades.
Accession numbers cannot be recalled, so they are validated early and never reused
DICOM PS3.5 §6.2 caps VR SH at 16 characters, which binds the Accession Number (0008,0050), the
Requested Procedure ID (0040,1001) and the Study ID (0020,0010). AccessionNumberGenerator validates
the longest value a format could ever produce at configuration time, because by generation time every
issued accession is already printed on films and recorded in the archive.
The sequence is reserved under a pessimistic row lock on imaging_accession_format, so the database
owns uniqueness rather than the generator, and ux_imaging_order_accession deliberately carries no
voided = false predicate — a cancelled or voided order never releases its accession.
Cancelling and discontinuing are different states, not synonyms: an order the modality never started is cancelled; one it started and stopped is discontinued through MPPS. Billing and dose reporting treat them differently.
A facility that configured nothing can still order (M10B-003C)
PUT|GET /api/v1/imaging/accession-format configures the shape, under the imaging catalog
permissions — administering a numbering scheme is the same job as administering the archive next door,
so it is deliberately not a new imaging.accession.* namespace that every existing administrator would
have to be granted.
If a facility never configures one, the first order creates the platform default rather than being refused. A numbering scheme is administration; a clinician requesting a chest X-ray should not be blocked on it, and requiring configuration first made the module unusable out of the box — for a while it was worse than that, because nothing in the product could create a format at all and every facility refused every order.
The default is R + six hex characters of the facility id, no year, an eight-digit sequence — 15
characters, inside DICOM's 16 with room to spare. It is derived from the facility because several
facilities can point at the same archive, and a shared constant would have them all start at
R00000001 and hand the PACS two different studies under one accession. No year segment: the counter
is monotonic and never resets in January, so four characters of year would distinguish nothing. The row
is flagged system_default so an administration screen can say "nobody chose this, review it"; saving
over it clears the flag.
Two simultaneous first orders both find nothing and both try to create. The partial unique index
ux_imaging_accession_format_facility decides it; the loser catches the constraint violation and takes
the winner's row. Creation runs in its own transaction (REQUIRES_NEW) precisely so that losing
costs the loser only that attempt — a constraint violation inside the order's own transaction would
have cost it the whole order. The lock guarantee is unchanged: the sequence is still taken from a row
read FOR UPDATE, after the row exists.
Changing a format may not bring an issued accession back
next_sequence is never read from the payload and is never reset — not on an update, not when the
shape changes. The subtle failure is not a reset but a reshape: prefix RAD with width 4 has already
issued RAD0124, and prefix RAD0 with width 3 would produce RAD0124 again from a counter that never
went backwards.
So before any shape change the service asks the orders themselves — not the format's history, since
a facility may have changed shape more than once — whether an accession of the shape the new format
would produce has already been issued here. Every value a format produces has one fixed prefix and one
fixed total length, so if nothing issued matches both, nothing it goes on to produce can repeat. The
query is native rather than JPQL because ImagingOrder carries @SQLRestriction("voided = false"), and
inheriting that would declare a voided order's accession free for reuse. It is not.
A width narrower than the counter already needs is refused for a different reason: it would turn the next order into a hard failure inside the generator, which is far too late to be told.
One order is not one procedure
The IHE SWF.b identity model, and it cannot be retrofitted once accession numbers are in circulation:
imaging_order (accession number)
└── imaging_requested_procedure (procedure id, code, modality, body site, laterality)
├── scheduled step → DICOM MWL entry (M10B-004)
├── performed step → MPPS (M10B-006)
└── study → StudyInstanceUID + WADO endpoint, reference only
A CT abdomen+pelvis with contrast is one order and two requested procedures.
Every order is anchored to an encounter (M10B-003B)
imaging_order.encounter_id is NOT NULL. An order with no encounter has no clinical context,
nothing to bill against, and never appears on the patient timeline.
It is required on the row, not on the request — two opposite rules, and conflating them breaks one of them:
| The caller… | What happens |
|---|---|
| names an encounter | It is validated: it must exist, and it must belong to the ordering patient. A mismatch is refused, never corrected. |
| names none | Not an error. The server resolves one: the patient's open visit at this facility, or a new visit, and an encounter within it. |
The cross-patient case is the one that matters. An order filed against another patient's encounter
looks entirely correct while attaching the study to the wrong record, and nothing downstream questions
it. The check lives in the service rather than a screen because POST /api/v1/imaging/orders is also
reached by CPOE (M13-004), by HL7 ORM (M12-007), and by anything holding imaging.order.write — a
frontend rule constrains none of them.
The resolution is not implemented here. Deciding when a visit is reused and when one is opened is
clinical policy; it lives in the clinical engine and imaging reaches it over
Feign (ClinicalRecordClient.resolveEncounterContext), naming the encounter type by code
(IMAGING_ORDER) because this module holds no clinical type ids. A copy of that policy inside a
domain module would drift from the one the rest of the platform obeys, and the first symptom would be
a patient carrying two concurrent visits — one opened by an observation form, one by an imaging order.
The anchor is resolved before an accession is reserved. An accession is never reused, so one spent on an order that is about to be refused permanently retires a number nothing will ever carry.
Every report is anchored to its own reporting encounter (M10B-007A)
Findings release under the reporting encounter, not the ordering encounter. The order encounter is the attendance where a clinician requested the scan; the report encounter is the radiologist's reporting act — often days later, by a different provider, in a different visit. Releasing observations under the order encounter misattributes when a finding was made and by whom.
imaging_report (M10B-007) will carry patient_id and encounter_id NOT NULL from its first
migration. Authoring a report resolves or creates that encounter via Feign
(ClinicalRecordClient.resolveEncounterContext) with encounter type code IMAGING_REPORT — seeded
alongside IMAGING_ORDER in clinical/016 — never by copying the order's encounter_id. M10B-008
writes released findings as observations under imaging_report.encounter_id.
Structured reporting (M10B-007)
POST/GET/PUT /api/v1/imaging/reports drafts and reopens structured reports. Reports remain
order-anchored; study_instance_uid is optional DICOM text even after M10B-006 landed
imaging_study (linking reports to the study FK is follow-on work).
imaging_order_idis required — the clinical anchor for authoring.study_instance_uidis optional DICOM Study Instance UID text.patient_id+encounter_idNOT NULL — authoring callsresolveEncounterContextwithIMAGING_REPORT, never by copying the order's encounter.template_idis a form-definition id (DnD templates). Optionalform_submission_idlinks a runtime submission.- Coded findings live on
imaging_report_finding(finding_concept_idis a platform dictionaryLong, same as procedures). - No
ReportStatusenum — lifecycle isworkflow_definition_id(+ optional instance). Instance start is deferred until a facility-configured definition is wired the same way as workforce approvals; the definition id is stored when supplied. - Corrections append —
POST …/{id}/amendcreates a new row withreplaces_report_id; verified reports refusePUT.
Permissions: imaging.report.read / imaging.report.write. Nav route key imaging.reports.
Scheduling and modality worklist (M10B-004)
imaging_scheduled_step is the MWL entry. The JSON face at GET /api/v1/imaging/worklist exists
only for the FE-219 board. Scanners negotiate DIMSE and issue C-FIND against the MWL SCP
(imaging.dimse.*, default off so CI boots without port 11112). Both faces call
ModalityWorklistService so filters and demographics cannot drift.
- Schedule with
POST /api/v1/imaging/scheduled-steps(permissionsimaging.schedule.read/write). - Demographics resolve at query time via
PatientClient— never cached on the step. RisRouter.ownsTheWorklistmust be true; otherwise schedule is refused and C-FIND / JSON return empty.- Procedure catalog may carry
defaultStationAeTitle; the step can override it. - Optional
appointmentIdlinks to the M13 scheduling engine without a cross-module FK. - dcm4che 5 is declared from
https://maven.dcm4che.org(not Central); licence LGPL arm — seeimaging/README.md. - Worklist rows also carry MPPS / commitment fields for FE-221 (see M10B-006).
MPPS and Storage Commitment (M10B-006)
This ticket is DIMSE-only — there is no general DICOMweb equivalent. MPPS is how the department learns what the modality actually did; Storage Commitment is how custody transfers so a lost study is detectable.
- Migration
imaging/012:imaging_performed_step,imaging_study, and schema-onlyimaging_study_reconciliation(M10B-016 must not retrofit history later). - MPPS statuses on the wire stay DICOM's:
IN_PROGRESS/COMPLETED/DISCONTINUED(N-CREATEthenN-SET).DISCONTINUEDrequires a reason. - On
COMPLETED, the service upsertsimaging_studyby facility + Study Instance UID, links accession / requested procedure / patient from the order, and requests Storage Commitment when a PACS host/AE is configured; otherwise commitment staysNONE. - Commitment states:
NONE→PENDING(afterN-ACTION) →COMPLETE/FAILED(from a laterN-EVENT-REPORT) orEXCEPTION(ageing sweep when the report never arrives). - Ageing:
imaging.commitment.ageing.*(default TTL 24h). Proven by a unit test that never delivers the event report. - JSON: worklist enrichment +
GET /api/v1/imaging/studies/commitment-exceptions(imaging.study.read). Nav route keyimaging.study. - Same
DimseServerLifecyclebinds MWL + MPPS SCP + Storage Commitment event SCP (imaging.dimse.*, default off).
Study identity: PIR and orphan linking (M10B-016 / M10B-017)
Wrong-patient attribution is a clinical-safety defect; correcting it is not an ordinary edit.
- History table
imaging_study_reconciliation(schema inimaging/012) is append-only — no void columns. Every reconcile and every orphan link writes a row (previous_patient_id,new_patient_id, reason, actor, when). POST /api/v1/imaging/studies/{id}/reconcile— re-attach a study to another patient; reason required. Updatesimaging_study.patient_idand matchingimaging_report.patient_idrows for the Study Instance UID. When a PACS is configured, archive correction is best-effort (soft-fail / log) so a powered-off archive cannot undo the EHR correction.GET /api/v1/imaging/studies/unlinked— studies withpatient_id IS NULL(orphan / unscheduled).POST /api/v1/imaging/studies/{id}/link— link an orphan to an explicitly confirmed patient; optionalimagingProcedureIdplaces a retrospective order viaImagingOrderService.placeand binds accession. Never auto-matches demographics.GET /api/v1/imaging/studies/{id}/reconciliation-history— oldest first.- Permission and nav:
imaging.study.reconcile(distinct fromimaging.study.write); route keysimaging.study.reconcileandimaging.study.unlinked.
Report release into the clinical record (M10B-008)
POST /api/v1/imaging/reports/{id}/release is the gate that makes a verified report visible on the
patient chart. Verify (verified_at) and release (released_at) are distinct stamps —
verification freezes editing; release writes coded findings as clinical observations.
- Observations are created under
imaging_report.encounter_id(IMAGING_REPORT), never the order's encounter. - Each non-voided coded finding becomes one
Observation(conceptId= finding concept,valueBoolean= true,status=FINAL). Narrative and impression stay on the report row. - The observation
commentcarries ACR-oriented provenance: accession number, Study Instance UID, requested procedure ids, plus finding body site / laterality / severity when present. - Release is idempotent when
released_atis already set; unverified reports are refused. - Missing PACS does not block release.
imagesAvailableon the DTO is advisory (DicomStoreRouter.hasArchive). - List filter:
GET …/reports?patientId=…&releasedOnly=truefor chart / summary surfaces. - FHIR
DiagnosticReport/ HL7 ORU emission waits on M12 (not this ticket).
The order list resolves its own catalog details
procedureDisplayName and modalityCode are filled in on the server from the scope-filtered catalog,
not left to the caller. The reason is a permission boundary, not convenience: the modality lives in the
catalog, so a client fetching it would need imaging.catalog.read — a different grant from the
imaging.order.read that got it as far as the order. A user holding only order-read would otherwise see
procedure names with the modality column blank, which is an authorisation rule leaking into the page as
missing data.
Both lookups go through the same visibility queries the catalog screens use, so a procedure never shows a modality the session may not see; when either is out of scope the field is null rather than borrowed from another owner, because a borrowed code reads as fact.
The archive seam (M10B-005)
DicomStoreRouter copies NotificationProviderRouter's shape: per-facility configuration, an
isConfigured() that declares an adapter unusable rather than failing, and fall-through at every rung —
facility's named provider → platform default (imaging.dicom.default-provider) → any configured provider.
A facility with no PACS is a supported deployment, not an incomplete one. Power, cost and bandwidth are the binding constraints in the settings this platform targets, and many facilities have no archive at all. So:
- every field on
FacilityPacsConfigDtois optional; GET /api/v1/imaging/pacs-configurationsreturns 204, not 404, so the UI can state "this facility has no image archive" as a fact rather than an error;- ordering, reporting and release never consult the archive. Whether images are required before release
is per-facility configuration (
images_required_before_release), defaulting to false.
There is no simulator at the end of the chain — the one deliberate divergence from the notification
router. A simulator answers "no studies" in exactly the words a powered-off archive does, and those two
facts must look different to whoever is reading the chart. So resolve() returning empty is a legitimate
terminal answer, and hasArchive() exists to tell "nothing retrievable here" from "asked and got nothing".
hasArchive() means images can be retrieved, which is not the same as "the facility has a PACS". A
facility configured for DIMSE only — host, port and AE Titles, no DICOMweb URLs — has a real archive that
scanners talk to and still gets false, because nothing in this seam can fetch a study from it. That is
the honest answer for a chart; it is the wrong question to ask when deciding whether the facility is
configured at all.
No method on DicomStoreProvider may throw to report an unreachable archive. An archive that is off,
slow or misconfigured is a normal deployment state; it is reported by returning empty, which is what lets
a network timeout degrade to "images not available" instead of failing a clinical action.
WADO URLs are composed, never fetched. retrieveUrl builds the URL and returns without any request
leaving the process, so a chart render never waits on an archive — the FE-198 lesson, where a server that
accepted a connection and never answered pinned the whole UI. Only QIDO, a genuine search, goes over the
wire, through a dedicated RestClient with its own short timeouts (imaging.dicom.connect-timeout
2s, read-timeout 3s). A shared client would force either the platform Feign calls to inherit an
archive's tolerance or the archive to inherit theirs.
The calling-AE-title allow-list is authentication, not a preference
A DICOM association carries no bearer token, so core's filter chain never sees it and
facility_calling_ae_title is the only thing between an arbitrary host and the worklist. Consequences:
- it lives in an audited, tenant-scoped table, and withdrawal is a void, not a delete, so the record of what was permitted and when survives;
- an inbound association names only itself, so the facility is derived from which allow-list the title appears in;
- therefore a title permitted at two facilities has no derivation.
resolveCallingFacilityrefuses rather than picks — choosing either would hand a modality at one hospital another hospital's worklist, a cross-tenant leak arrived at by guessing; - and the guarantee lives at the database, in
ux_calling_ae_title_global, not only in the service. The service check alone loses a race between two facilities permitting the same title, after which both are refused — a self-inflicted outage. The index is partial onvoided = false, so decommissioning a scanner frees its title for someone else.
Matching is case-sensitive, as DICOM defines AE Titles; folding case would silently widen the list.
Titles are validated against VR AE (≤16 printable ASCII characters, no backslash) at configuration
time, because the alternative failure surfaces as a rejected association at the scanner, hours later, in
front of somebody who cannot see the admin screen.
Contract (M10B-005A). callingAeTitles is read-only on PUT — if the field is present the server
returns 400 with a message pointing at POST/DELETE …/calling-ae-titles. Permitting a caller may
create the parent facility_pacs_config row implicitly when none exists yet (default provider, no
URLs required). An already-permitted title returns 409 with
businessErrorCode: CALLING_AE_TITLE_ALREADY_PERMITTED so the UI can recognise the global-uniqueness rule
without parsing prose.
The external-RIS seam (M10B-015)
Everything above assumes we are the RIS. A teaching hospital that already runs one will not replace it to adopt this platform, so the mode is per-facility — the same "do not assume greenfield" reasoning that produced the no-PACS path, one layer up. M10B-005 answers the question for the archive; this answers it for the information system.
| Mode | Accession number | Worklist |
|---|---|---|
INTERNAL (default) | reserved from imaging_accession_format under a row lock | ours — RisRouter.ownsTheWorklist is true, so M10B-004's MWL SCP may answer C-FIND |
EXTERNAL | accepted from the external RIS on the order | theirs — we must not answer C-FIND for that facility |
RisRouter is the third use of the NotificationProviderRouter shape (after DicomStoreRouter), and the
fall-through is stated in exactly one place: no configuration means INTERNAL. Callers ask the router
rather than reading facility_ris_config, because the plan's rule is that INTERNAL must be a choice and
not an absence — a second caller re-deriving the fall-through eventually disagrees with the first.
One asymmetry is deliberate: for a facility we cannot identify, modeFor answers INTERNAL while
ownsTheWorklist answers false. The first decides how we handle an order that already named its
facility; the second is asked before handing a worklist to a device, and an inbound association that
resolved to no facility has not proved it may have one.
A sibling table, not columns on facility_pacs_config. The archive and the information system are
independent: an external RIS with no PACS, and our RIS against a vendor archive, are both real
deployments. Folding them together would force a facility to invent an archive configuration in order to
say somebody else owns its worklist, and would give the absence of that row a second, contradictory
meaning.
How EXTERNAL accepts an accession — and why INTERNAL still refuses one
ImagingOrderDto.accessionNumber stays READ_ONLY in both modes. It was not made writable, because
a contract cannot say "writable only when a facility setting says so": the same field would then be
writable in INTERNAL mode, where a client-chosen accession can collide with one already printed on a
film. An externally issued number arrives on its own field, externalAccessionNumber, where accepting
it is visibly a different act from assigning it:
EXTERNAL— required. Validated byAccessionNumberGenerator.acceptExternalagainst the same VRSHlimit (≤16 printable ASCII characters, no backslash), because the cap is DICOM's and binds whoever issued the value. It is refused rather than truncated: two of their orders can share a 16-character prefix, and truncation would make one accession point at two studies. Trailing space is trimmed, since DICOM padsSHto an even length and treats that padding as insignificant.INTERNAL— refused, not ignored. Silently dropping it would let an integration believe its identifier had been honoured while the study is filed under a different one, which surfaces only when somebody cannot find an image.
Uniqueness needed no new index: ux_imaging_order_accession already covers (facility_id, accession_number) with no voided = false predicate, which is exactly right for an accession that is
never reused. Two consequences:
- the duplicate pre-check is a native query (
countAccessionIncludingVoided). A derivedexistsBy…is filtered by the entity's@SQLRestriction("voided = false")and would report a number free that the index will reject; - the insert is flushed inside the service and a
DataIntegrityViolationExceptiontranslated to 409, so a lost race is a stated conflict rather than a 500 raised after the service returned. The index, not the pre-check, is the guarantee — the same division of labour as the row lock inINTERNALmode.
imaging_order.accession_issuer records which system minted the number, per order. A facility migrating
onto an external RIS — or off one — must not have the provenance of orders already placed rewritten by the
switch.
Deferred to M12-007
No HL7 interface exists here, deliberately. Carrying an order outward (ORM^O01, including the
modification case Bahmni's mapping covers and our plan originally missed) and ingesting a report back
(ORU^R01) is M12-007, which is not built; delivery must also be append-only and retried through the
NotificationDelivery attempt model rather than a synchronous send inside the ordering transaction. Until
then, an EXTERNAL facility's orders are recorded here with their RIS's accession and are carried to that
RIS by whatever integration the deployment already has. Nothing in this module fabricates that transport.
Critical findings (M10B-009)
Radiology authors which coded findings are critical per facility. On report release,
ImagingCriticalFindingService evaluates active criteria and raises at most one open critical
result through CriticalResultClient with source_module=imaging and the report id as
source_record_id. A facility with no criteria raises none.
What stays out of imaging: there is no imaging_critical_result table and no imaging-local
escalation scheduler. Delivery, opt-out carve-out, the worklist, acknowledgement, and escalation
belong to notification (M10B-009A). Notify failure is logged and does not undo
the clinical release stamp.
Criteria rows store the notify channel/recipient used on raise (required by the shared raise API).
Permissions: imaging.critical.read / .write. Route keys: imaging.criticalResults (read) and
imaging.criticalCriteria (write) — both must exist in AuthorizationCatalog or the FE links never
appear.
API surface
| Path | Ticket |
|---|---|
/api/v1/imaging/procedures, /modalities | M10B-002, TERM-003 |
/api/v1/imaging/orders (POST / GET / DELETE) | M10B-003 |
/api/v1/imaging/pacs-configurations (GET / PUT), /providers, /calling-ae-titles | M10B-005 |
/api/v1/imaging/ris-configuration (GET / PUT) | M10B-015 |
/api/v1/imaging/critical-criteria (GET / POST / PUT / DELETE) | M10B-009 |
/api/v1/imaging/teleradiology/worklist, /teleradiology/assignments (+ /{id}/reassign, /start, /cancel) | M10B-011 |
/api/v1/imaging/national-extracts/packs (GET / PUT .../enablement), /runs (GET / POST / GET /{id}) | M10B-012 |
/api/v1/imaging/kpi-sources (GET / POST / PUT / DELETE), /kpi-sources/by-key/{sourceKey} | M10B-013 |
/api/v1/imaging/metrics/* (turnaround, backlog, utilisation, repeat/reject, dose, critical ack, summary) | M10B-013 |
The RIS mode is gated by imaging.catalog.read|write, the same permissions as the PACS configuration,
and is intended to sit on the same admin screen — so it needs no new AuthorizationCatalog route key
and rides imaging.pacs. Give it its own screen and it needs its own route key, or the link silently
never appears for anyone but a scope-bypassing super admin.
GET /ris-configuration answers 200, never 204 — the opposite of the PACS configuration beside it,
and for the reason that makes both right: a facility with no PACS row genuinely has no archive, while
every facility has a RIS mode. Answering "nothing" would leave the frontend to re-derive the default, and
a worklist that is legitimately somebody else's would look exactly like one that failed to load.
Permissions: imaging.catalog|order|study|report|critical|teleradiology|extract|kpi × .read|.write
(ImagingModuleDescriptor), plus imaging.study.reconcile for PIR / orphan linking.
A screen needs a route key as well as a permission. AuthorizationCatalog must carry both
modules.put("imaging", …) and an AuthorizationRoute per screen — imaging.catalog, imaging.orders,
imaging.pacs, imaging.criticalResults, imaging.criticalCriteria, imaging.viewer,
imaging.teleradiology, imaging.extracts, imaging.kpis. Without the route key the link simply never appears, for
everyone except a scope-bypassing super admin, and nothing errors. That has now been missed twice, so
AuthorizationCatalogTest pins each one.
Viewer embed + image access (M10B-010)
Image access is PHI access. The browser never receives PACS credentials or the raw archive host.
POST /api/v1/imaging/viewer/sessions(requiresimaging.study.read) resolves the study, authorises viaAccessDecisionClientwithpatientPersonId(SEC-011: denial answers as 404), and mints a short-lived HMAC token bound to user + patient + facility + Study Instance UID.- When the facility has no archive (
DicomStoreRouter.hasArchiveis false), the response statesimagesAvailable=falseand mints nothing — the same calm "images not available" contract as M10B-005 / FE-220. GET /api/v1/imaging/viewer/wado/{token}/{*path}and…/qido/{token}/{*path}validate the token, refuse a path for any other study UID, write aPHI_READaudit event viaAuditCommandClient, and 302 redirect to the facility's WADO/QIDO base. Redirect (not byte-proxy) keeps a slow archive off the app server.- Those brokered roots are Spring Security
permitAll— the opaque token is the credential so OHIF can follow redirects without re-attaching a session JWT. Session minting stays authenticated.
Configure imaging.viewer.token-secret / IMAGING_VIEWER_TOKEN_SECRET in every real deployment.
Default TTL is five minutes (imaging.viewer.token-ttl).
Teleradiology read assignments (M10B-011)
imaging_read_assignment (migration 014) is who is reading a requested procedure, and by when.
It rides the platform's existing access-scope model rather than inventing a second one: no new
permission kind, no separate cross-facility mechanism.
facility_id is the source facility of the requested procedure — never the reader's home. A
remote radiologist has no home facility in this row; only radiologistId names them. The row is
owned by whoever ordered the study and is merely visible to whichever reader it names, wherever
they are signed in. Getting this backwards would leak one facility's teleradiology queue into
whatever facility a reader happens to be signed into that day.
POST /api/v1/imaging/teleradiology/assignmentsassigns a requested procedure to a radiologist. Runs the sameAccessDecisionClientpatient-access checkImagingViewerServiceImplruns before minting a viewer session, resolving the caller throughImagingCallerResolver— a denial answers as not-found (SEC-011), never a 403 that would confirm the record exists.- SLA.
dueAt = assignedAt + slaMinutes.slaMinutesdefaults from the order's priority when omitted:STAT= 60,URGENT= 240,ROUTINE= 1440 (minutes). Stored on the assignment, not derived at read time, so a later change to the default priority mapping cannot retroactively move an already-issued due date. - One live assignment per requested procedure. "Live" means
ASSIGNEDorIN_PROGRESS. Enforced twice, the same division of labour as the accession-format race in M10B-003C: the partial unique indexux_imaging_read_assignment_live_procedure(WHERE voided = false AND state IN ('ASSIGNED', 'IN_PROGRESS')) is the actual guarantee; a service-level check exists only to turn the race into a clean refusal instead of a raw constraint violation. - Reassignment updates the same row, deliberately, rather than voiding and recreating —
POST …/{id}/reassign— so the assignment's identity and its audit history survive a hand-off. The SLA clock restarts from now (assignedAt/dueAtreset) on the theory that a new reader cannot fairly inherit the previous reader's turnaround clock. The previous radiologist is captured in the audit event the reassignment writes, not in a second row. - Auditing. Assign and reassign always attempt
AuditCommandClient(AuditEventCategory.AUDIT/AuditEventType.CLINICAL_WRITE,CREATE/UPDATE). A transport failure is logged loudly rather than blocking the clinical action — the same fail-open contractImagingViewerServiceImpluses for its own audit calls, since refusing to hand off a read because the audit sink is down would be a worse outcome than a delayed audit record. - Worklist —
GET /api/v1/imaging/teleradiology/worklist. Visible to an assignment's source facility (coordinators see their facility's outgoing reads) or to the assigned radiologist (a remote reader sees their queue regardless of which facility they are signed into), denormalized with order/procedure detail (accessionNumber,patientId,priority,procedureDisplayName) so a board renders without a second round trip.slaBreachedis computed at query time, never stored —dueAt < nowand the state is notCOMPLETED/CANCELLED— because a breach is a fact about the clock, not a fact about the row, and storing it would drift the instant nobody re-saved the assignment. POST …/{id}/cancelwithdraws a live assignment without a read (terminal, never counted against SLA);POST …/{id}/startmovesASSIGNED→IN_PROGRESS. Neither affects the report itself — report authoring and release remain M10B-007/008's business;COMPLETEDhere only marks the read done for worklist purposes.
Permissions: imaging.teleradiology.read / .write. Route key: imaging.teleradiology. Paired
FE-226.
National extract packs (M10B-012)
Every national reporting standard (NHS DIDS, USCDI, Canada DI-r/XDS-I.b, Australia MHR, Nigeria) is a
pluggable pack, mirroring labour_rule_pack (M17-012) in spirit: the SPI
com.zhenus.uhp.api.imaging.national.NationalImagingExtract (packCode, displayName, isStub,
supports(facilityId), run(request)) is implemented by a Spring bean per pack, and
NationalExtractPackRegistry collects all of them so the service picks one by packCode() with no
switch statement that grows every time a country ships. Unlike labour_rule_pack, the pack itself is
code, not a configured row — a national schema is not something an operator authors at runtime; only
the per-facility on/off switch is data.
Migration 015 adds two tables:
imaging_extract_pack_enablement—pack_code+enabledper facility. A partial unique index (uq_imaging_extract_pack_enablement_facility_pack_live) keeps at most one live row per(facility_id, pack_code).imaging_extract_run— append-only history:pack_code,period_from/period_to,status(SUCCESS/EMPTY/FAILED),started_at/finished_at,row_count,validation_ok,content_type,artifact_content(TEXT),artifact_digest(SHA-256, for integrity checking without re-reading the artifact),error_summary, standardAuditTrail.
DIDS's five timestamps are first-class columns, not derived
The ticket's acceptance criterion is that request → schedule → acquire → report → verify are captured
as they happen, never reconstructed from an audit trail after the fact. Checking the existing schema
before adding anything found that every stage already has a dedicated column from earlier tickets — so
migration 015 adds no new timestamp columns, only the pack tables above:
| DIDS stage | Existing column |
|---|---|
| Request | imaging_order.ordered_at |
| Schedule | imaging_requested_procedure.scheduled_at |
| Acquire | imaging_performed_step.started_at (M10B-006 MPPS) |
| Report | imaging_report.dictated_at |
| Verify | imaging_report.verified_at |
NhsDidsExtract is the one pack with a real payload in this ticket, because DIDS carries the only
external deadline (mandatory 1 April 2026). For each requested procedure scheduled in the requested
period it resolves the order, the performed step (if MPPS ran) and the report (matched by
studyInstanceUid, falling back to the most recent report on the order), and emits one JSON record per
accession with all five timestamps. A record with any timestamp missing is still emitted, never
dropped — validationOk flips to false and errorSummary names the gap, so an incomplete DIDS
submission is visible rather than silently short. The envelope validates against the minimal schema at
imaging/src/main/resources/national/dids-v2-minimal.schema.json.
The four remaining packs (UscdiImagingExtract, CanadaDirXdsIbExtract, AustraliaMhrExtract,
NigeriaExtract) share AbstractStubNationalImagingExtract: a minimal JSON envelope carrying pack
identity, facility, period and an order-volume count, so enablement and run history work end-to-end for
every pack without blocking this ticket on four more national schemas. isStub() is true for all four
and surfaces in the pack catalog so the frontend shows plainly that these are not yet full
national-schema extracts.
Run is synchronous and request-scoped (GAP-003)
POST .../runs executes the pack inline and persists the outcome before responding — there is no
background scheduler. FeignContextPropagationConfig reads RequestContextHolder, which is empty
off-request, so a scheduled runner would fail authorisation the moment it tried to resolve facility
scope or call a Feign client. Nothing in the repo has needed a service identity yet (see the note under
GAP-003 in the ticket history), so this is deferred rather than solved locally: v1 only runs a pack
inside an authenticated request the operator issues themselves, and a run that throws still persists a
FAILED row rather than losing the attempt from history.
API, under /api/v1/imaging/national-extracts:
| Path | Purpose |
|---|---|
GET /packs | Catalog of every registered pack + this facility's enablement |
PUT /packs/{packCode}/enablement | Enable/disable a pack for the session facility |
POST /runs | {packCode, periodFrom, periodTo} → run now, return the detail (with artifact) |
GET /runs | History for the facility (summaries, no artifact content) |
GET /runs/{id} | One run's detail, including artifact content |
Permissions: imaging.extract.read / .write. Route key: imaging.extracts (read). Paired FE-227.
Imaging KPI sources + live metrics (M10B-013)
KPI contracts and source definitions live in the exchange model and are admin-managed:
imaging_kpi_source— catalog metadata (source_key, display name, family,metrics_path, enabled, sort order). Seeded with the six sample sources M10B-013 names (imaging.tat_by_stage,unreported_backlog,modality_utilisation,repeat_reject_rate,dose_per_procedure,critical_ack_time). This table is not a metrics store.- Live metrics — computed on read from imaging's own schema (and the shared critical-result
loop over Feign for acknowledgement time). Exposed under
/api/v1/imaging/metrics/...plus a combined/summary.
Peers (including future M11 reporting) communicate only through exchange DTOs and Feign clients:
ImagingKpiSourceClient— discover which sources existImagingKpiMetricsClient— fetch values
Permissions: imaging.kpi.read / .write. Route key: imaging.kpis (read). Paired FE-228.
Testing
Integration tests extend imaging/integration/AbstractionContainerBaseTest, which uses the
test-only DedicatedTestDatabase helper (persistent uhp_test on the LAN Postgres host; see
dedicated test database).
Milestone gate (M10B-014)
Milestone10bImagingIntegrationTest is the milestone gate. It proves, against the dedicated test
database:
- No-PACS journey — order → schedule → MWL → report → verify → release → released chart list
(
imagesAvailable=false; missing archive never blocks clinical actions). - Two contrasting configured report lifecycles — free-text single-reader vs coded double-read
with append-only amendment (lifecycle is
workflow_definition_id, not a status enum). - Configured DICOMweb seam — STOW/QIDO/WADO URL composition + QIDO against an in-process archive stand-in.
Handoff: core/MILESTONE10B.md.
Orthanc in the E2E compose stack
docker-compose.e2e.yml includes Orthanc (orthancteam/orthanc) with DICOMweb and worklists
enabled so live DICOM conformance (STOW-RS → QIDO-RS → WADO-RS) is not faked. Default ports from
.env.e2e.example: HTTP 8042, DICOM 4242. Point a facility's facility_pacs_config
qido/wado/stow base at http://localhost:8042/dicom-web (host) or http://orthanc:8042/dicom-web
(compose network). See core/README.md → Running the E2E environment.
Patient merge re-point (M37-005)
POST /api/v1/imaging/patient-merges/repoint bulk-updates patient_id on imaging_order,
imaging_report, and imaging_study. Core orchestrates this after M37-004; responses report moved
counts and any tables still referencing the loser patient.
Why it is this way
Imaging procedure is concept-backed (TERM-003). procedure_code and local code-system tables
were removed; modality stays TypeScope CRUD, display name is a local label, conceptId is
immutable on update. FE and API consumers must use ConceptPicker, not bare codes.
No PACS is a supported deployment. Order, schedule, report, verify, and release must complete
when imagesAvailable=false; a missing archive must not block clinical actions. The DICOMweb seam
has no simulator at the end of the chain — a simulator answers like a powered-off PACS.
Report lifecycle is workflow-driven, not a status enum. Single-read vs double-read vs amendment
paths bind to workflow_definition_id; hard-coding status transitions loses specialty-specific
rules.
Calling AE titles authenticate the DIMSE port and are globally unique at the database — not merely per-facility — because two facilities racing the same title lock each other out.
Traps
⚠ MWL SCP must call RisRouter.ownsTheWorklist before answering C-FIND — otherwise a facility
sees another tenant's worklist entries.
⚠ Accession format must be creatable before first order — pessimistic lock + partial unique index settle concurrent first orders; mocking the lock in tests hides missing seed/configuration.
⚠ Viewer tokens are short-lived HMAC — do not cache WADO URLs as permanent links; patient access
check and PHI_READ audit belong on every brokered retrieve.
⚠ Critical results share M10B-009A with lab — do not rebuild a radiology-only panic queue; consume the platform critical-result APIs.
⚠ Findings release under the reporting encounter — not the order's encounter; M10B-007A resolved this before the first report migration.