From 598f64119170d5c2c85222b29ad198cfc2d4c9f4 Mon Sep 17 00:00:00 2001 From: Austin Moody Date: Mon, 17 Aug 2026 16:23:47 -0400 Subject: [PATCH 1/3] fix: label Location.physicalType from the component the IG maps toLocationFromComposite held the IG's component mapping in an array it never read, so every physical-type code landed on the wrong component: a Point of Care came out bd/Bed, a Facility lvl/Level, and Building and Floor were never read at all. The eHealth Exchange review caught it on a Nevada Z42 -- codes we invented, plus a Building the message did send that never appeared. Each component now gets its IG code (bd Bed, ro Room, si Facility, bu Building, lvl Floor). Point of Care gets none: R4's location-physical-type has no code for one, and the old wa/Ward was a guess. Components 9 and 10 are read per datatype, so a PL keeps PL-9 as description instead of PL-10's identifier text, and an LA1/LA2 stops copying its street address there. BREAKING for Location consumers. 2.5.2-SNAPSHOT for pilot testing, releasing as 2.6.0. Adds LocationCompositeTests, 12 tests where Location had no coverage. --- .../.openspec.yaml | 2 + .../design.md | 116 +++++++++ .../proposal.md | 79 ++++++ .../location-composite-conversion/spec.md | 144 +++++++++++ .../tasks.md | 33 +++ pom.xml | 2 +- .../v2tofhir/converter/DatatypeConverter.java | 60 +++-- .../v2tofhir/LocationCompositeTests.java | 235 ++++++++++++++++++ 8 files changed, 652 insertions(+), 19 deletions(-) create mode 100644 openspec/changes/fix-pilot-fhir-conformance-defects/.openspec.yaml create mode 100644 openspec/changes/fix-pilot-fhir-conformance-defects/design.md create mode 100644 openspec/changes/fix-pilot-fhir-conformance-defects/proposal.md create mode 100644 openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md create mode 100644 openspec/changes/fix-pilot-fhir-conformance-defects/tasks.md create mode 100644 src/test/java/test/gov/cdc/izgateway/v2tofhir/LocationCompositeTests.java diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/.openspec.yaml b/openspec/changes/fix-pilot-fhir-conformance-defects/.openspec.yaml new file mode 100644 index 000000000..149631464 --- /dev/null +++ b/openspec/changes/fix-pilot-fhir-conformance-defects/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-17 diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/design.md b/openspec/changes/fix-pilot-fhir-conformance-defects/design.md new file mode 100644 index 000000000..813d94a4f --- /dev/null +++ b/openspec/changes/fix-pilot-fhir-conformance-defects/design.md @@ -0,0 +1,116 @@ +## Context + +See `proposal.md` — Why, and the spec delta under `specs/location-composite-conversion/` for the behaviour being +contracted. + +`DatatypeConverter.toLocationFromComposite` holds the IG's component mapping in a local `int[] c` and then never +reads it — the loop indexes the composite with its own counter. The array and the method's Javadoc agree with the +IG's `datatype-pl-to-location` ConceptMap, so the mapping is already correct and only the access is wrong. +Everything else in the method (the `partOf` nesting, `mode`, the `operationalStatus` / `type` assignments) is +sound. + +The reviewed Nevada bundle is the worked example throughout: `RXA-11` is +`IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201`, an `LA2`, and it +converted to `IZGATEWAYART` labelled `bd`/Bed, `IZGATEWAY-AART` labelled `lvl`/Level, no Building at all, and +`description` = `330 C ST SW UNIT 7`. + +## Goals / Non-Goals + +**Goals:** + +- Make `Location.physicalType` a fact the sending system asserted, never one the converter inferred. +- Preserve the IG mapping as the readable source of truth in the code, rather than encoding it + implicitly in loop order. +- Read components 9 and beyond according to the datatype of the field being converted. + +**Non-Goals:** + +- Re-designing `Location` conversion beyond these two defects. The nesting behaviour, `mode`, address handling + and the `operationalStatus` / `type` assignments are unchanged. +- Producing `ImmunizationEvaluation`, or moving `59781-5`, `30982-3`, `30956-7` or `30973-2` anywhere. See + proposal, Out of Scope. +- Introducing the terminology package's injectable abstractions into this call site. It uses the static + `Systems` utility today; this change stays on that path so it does not collide with the in-flight + `vocabulary-management-integration` work. + +## Decisions + +### Index the composite by the mapping array, and let a component opt out of a code + +Replace the three parallel locals with class-level constants and index by the mapping array: + +```java +private static final int[] LOC_COMPONENTS = { 2, 1, 0, 7, 6, 3 }; +private static final String[] LOC_CODES = { "bd", "ro", null, "lvl", "bu", "si" }; +private static final String[] LOC_DISPLAYS = { "Bed", "Room", null, "Level", "Building", "Site" }; +``` + +The component order is Keith's original and is left alone: it runs Bed, Room, Point of Care, Floor, +Building, Facility, which is most specific to least, so the `partOf` chain it builds nests correctly. +The `si` display stays "Site", the code system's own display for that code, rather than being changed +to "Facility" to match the source component — the value set's display is the conventional choice and +changing it buys nothing. + +A `null` code means "produce the named `Location`, emit no `physicalType`". That is how Point of Care +is handled: FHIR R4's `location-physical-type` has no point-of-care concept, and the IG leaves that +cell unresolved. + +*Alternatives considered.* Renumbering the code arrays so the existing `i` indexing becomes correct +would work but discards the IG mapping as documentation — the next reader would have no way to see +which component each code came from. Defining our own point-of-care extension follows the IG's stated +intent, but the IG never finished defining it, so we would be inventing a canonical and repeating the +mistake this same review flagged in our use of the core `originalText` extension. Keeping `wa`/Ward is +rejected outright: it is the invented-data problem the review reported, merely relabelled. + +### Components 9 and beyond move out of the shared helper + +`toLocationFromComposite` is shared by `PL`, `LA1` and `LA2`, which agree only through component 8. +The helper currently sets `description` from component 9 and `identifier` from component 10 for all +three, which is right only for `PL`: + +- `LA1`-9 is an `AD` Address, and `LA2`-9 through -16 are the address components. So today an `LA1` or + `LA2` gets its address text stuffed into `description` as well as into `Location.address`, which + `AddressParser` already parses from component 9 onward. `LA1` has no component 10 at all. +- For `PL`, `DatatypeConverter.toLocation`'s own `case "PL"` then overwrites `description` with + component 10 — the `EI` identifier — so PL-9 Location Description never reaches FHIR, and the + identifier text lands in `description` beside the parsed `identifier`. + +So the `description` and `identifier` assignments move out of the helper and into the `PL` case, and the +redundant `setDescription` line in that case goes away. `LA1` and `LA2` keep only their address, which +they already get. This is a second defect in the same method, found while contracting its behaviour, and +it is fixed here because the spec for this capability has to state which component supplies +`description` either way. + +## Risks / Trade-offs + +- **`Location` output changes for every existing consumer, not just the pilot.** `PL`, `LA1` and `LA2` + are reached by any message with a person-location field. → The proposal marks this BREAKING, and the + version it ships under says so. The change is a correction toward the IG, + so no consumer has a conformant reason to depend on the old values. +- **`Normalizer` dedupes Locations on `physicalType` + `name` + `mode`, so the number of `Location` + resources in a bundle can change.** No test asserts on `Location` at all and there are no + expected-output fixtures, but a test that counts bundle entries would move. → Run the full suite as its + own task before writing new tests, so any movement is attributed to the fix rather than to new coverage. +- **`Location.description` changes for `PL`, `LA1` and `LA2` alike.** `PL` gains PL-9 where it used to + carry PL-10's identifier text; `LA1` and `LA2` lose a `description` that duplicated their address. → + Part of the same breaking `Location` correction, so it ships under the same version. + +## Migration Plan + +No data migration and no configuration change. The library is a build-time dependency, so the change +ships by version. + +The work is carried on `2.5.2-SNAPSHOT` for pilot testing and released as `2.6.0`, because the `Location` +corrections are breaking and the project states it follows SemVer. `izgw-transform` pins +`2.5.1-SNAPSHOT`, so it has to move to `2.5.2-SNAPSHOT` to pick this up. + +`RELEASE_NOTES.md` is not edited by hand in this repository — a GitHub Actions workflow writes it when a +release is cut — so no task here touches it. + +Rollback is a dependency-version revert in the consuming service. + +## Open Questions + +- Whether the `location-physical-type` gap for a point of care is worth raising with the v2-to-FHIR IG + authors, given their own ConceptMap leaves the cell unresolved. Answering it later does not change + what we emit now, which is no code. diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/proposal.md b/openspec/changes/fix-pilot-fhir-conformance-defects/proposal.md new file mode 100644 index 000000000..6dd0a2a5b --- /dev/null +++ b/openspec/changes/fix-pilot-fhir-conformance-defects/proposal.md @@ -0,0 +1,79 @@ +## Why + +Independent review of a real Z42 (`RSP^K11`, evaluated history + forecast) response from the Nevada IIS test system, +for the eHealth Exchange pilot, reported "extra data not returned in the Z42: physicalType: BD Bed, lvl Level". +That is a real defect, it is not specific to Z42, and it is pre-existing — it affects every message carrying a +person-location field. + +`DatatypeConverter.toLocationFromComposite` transcribes the v2-to-FHIR IG's `datatype-pl-to-location` ConceptMap +into `int[] c = { 2, 1, 0, 7, 6, 3 }`, and the method's own Javadoc restates the same mapping — but the loop reads +the composite with the loop counter instead of `c[i]`, so the array is never used. In the reviewed bundle that puts +`bd`/Bed on `IZGATEWAYART` (LA2-1 Point of Care) and `lvl`/Level on `IZGATEWAY-AART` (LA2-4 Facility), while +`IZ GATEWAY AART TEST` (LA2-7 Building) is never read at all. Both codes the reviewer saw were invented by the +converter. PL-5 and PL-6 are also emitted twice — once as a mislabelled nested `Location`, once correctly as +`operationalStatus` and `type`. + +The same method reads components 9 and 10 as Location Description and Comprehensive Location Identifier for all +three datatypes, which holds only for `PL`. `LA1`-9 is an Address and `LA2`-9 onward are the address components, so +an `LA1` or `LA2` duplicates its address text into `Location.description` — visible in the reviewed bundle as +`"description": "330 C ST SW UNIT 7"`. For `PL`, `toLocation`'s own `case "PL"` then overwrites `description` with +component 10, the identifier, so PL-9 Location Description never reaches FHIR at all. + +## What Changes + +- **BREAKING** `Location.physicalType` is corrected to the component mapping the IG specifies: + `bd` from PL-3, `ro` from PL-2, `lvl` from PL-8, `bu` from PL-7, `si` from PL-4. Existing consumers see + different `physicalType` codes and different `Location.name` values on the same input, because both are set + from the component now read correctly. PL-5 and PL-6 stop being emitted as nested `Location` resources and are + carried only as `operationalStatus` and `type`. +- PL-1 Point of Care has no standard `physicalType` code — the IG specifies an extension there rather than a + code from `location-physical-type`. The current `wa`/Ward is an approximation with no basis in the IG, so no + code is emitted for that component. +- **BREAKING** `Location.description` and `Location.identifier` are read per datatype: from PL-9 and PL-10 for a + `PL`, and from neither for an `LA1` or `LA2`, whose components 9 and beyond are the address they already + populate. A `PL` now carries PL-9 in `description` instead of PL-10's identifier text, and an `LA1`/`LA2` no + longer carries a `description` that duplicated its address. + +## Out of Scope + +`ImmunizationEvaluation` is **not** produced. The review asked for `59781-5^Dose Validity^LN` and +`30982-3^Reason for invalid dose^LN` on an `ImmunizationEvaluation`, and an earlier draft of this change planned +exactly that. Our FHIR lead ruled it out: the resource is maturity level 0 in R4, the HL7 v2-to-FHIR spec does not +map it, and it needs substantial work in the Public Health Workgroup. It will not be supported by v2-to-FHIR until +R6 progresses, and IZ Gateway targets R4 only. All `ImmunizationEvaluation` work — the resource, `doseStatus`, +`doseStatusReason`, `targetDisease` from `30956-7`, and +`ImmunizationRecommendation.recommendation.supportingImmunization` — is therefore dropped from this change rather +than deferred to a later one. + +Two review items need no converter change: + +- `59781-5` and `30982-3` are already carried in the converted bundle as `Observation` resources, with + `Observation.code` holding the LOINC code, the value in `Observation.value[x]`, `subject` referencing the + `Patient` and `partOf` referencing the group's `Immunization`. They are absent from the reviewed bundle because + the Transformation Service's FHIR searchset filter removes resources it has not marked, not because the + converter drops them. Making them visible is an `izgw-transform` filter decision. +- `30973-2^Dose Number in Series^LN` is present in the Nevada sample and already maps to + `Immunization.protocolApplied.doseNumber[x]` — the reviewed bundle shows `"doseNumberPositiveInt": 1` on both + history groups. + +## Capabilities + +### New Capabilities +- `location-composite-conversion`: conversion of the HL7 v2 `PL`, `LA1` and `LA2` composites to `Location`, + covering which component supplies `name`, the `physicalType` code per component, the `partOf` nesting of + multiple populated components, which components are carried as `operationalStatus` and `type` instead, and + which element components 9 and beyond supply for each of the three datatypes. No existing spec covers + `Location`. + +## Impact + +- `converter/DatatypeConverter.toLocationFromComposite` and its `case "PL"` caller — the indexing fix and the + per-datatype reading of components 9 and 10. Reached by every `PL`, `LA1` and `LA2` conversion, so the blast + radius is every message with a person-location field, not only immunization messages. +- New `Location` coverage. No test asserts on any part of `Location` today, so coverage is added rather than + adjusted. +- `Normalizer` dedupes Locations on `physicalType` + `name` + `mode`, so the number of `Location` resources in a + bundle can change for the same input. +- Consumers of the emitted bundle see changed `Location` content: different `physicalType` codes, a `Location` + per component that the message actually populated, and no `description` on an `LA1`/`LA2`. +- No new dependency, no configuration change, and no change to any immunization resource. diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md b/openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md new file mode 100644 index 000000000..895525116 --- /dev/null +++ b/openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md @@ -0,0 +1,144 @@ +## Purpose + +Defines how the HL7 V2 person-location composites (`PL`, `LA1` and `LA2`) become FHIR R4 `Location` +resources — which component names each `Location`, which physical-location code each component +carries, how several populated components nest into a containment chain, and which components are +not physical locations at all — so that a consumer can trust that a `Location.physicalType` it +receives was actually asserted by the sending system. It also fixes which components 9 and beyond +supply, since `PL`, `LA1` and `LA2` agree only through component 8. + +## ADDED Requirements + +### Requirement: Physical location components carry the code the v2-to-FHIR IG assigns them + +The converter SHALL derive `Location.physicalType` from the component that the HL7 v2-to-FHIR +Implementation Guide's `datatype-pl-to-location` ConceptMap assigns to that code, using the +`http://terminology.hl7.org/CodeSystem/location-physical-type` code system: + +| Component | `physicalType` code | +| --- | --- | +| `PL`/`LA1`/`LA2`-2 Room | `ro` | +| `PL`/`LA1`/`LA2`-3 Bed | `bd` | +| `PL`/`LA1`/`LA2`-4 Facility | `si` | +| `PL`/`LA1`/`LA2`-7 Building | `bu` | +| `PL`/`LA1`/`LA2`-8 Floor | `lvl` | + +The converter SHALL NOT emit a `physicalType` code for a component the message left empty, and +SHALL NOT emit a code the message does not support. A `Location` the converter produces from one of +these components SHALL take its `name` from that same component. + +#### Scenario: Facility and building are labelled from their own components + +- **WHEN** an `RXA-11` carries + `IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201` +- **THEN** a `Location` named `IZ GATEWAY AART TEST` carries `physicalType` `bu` (Building) +- **AND** a `Location` named `IZGATEWAY-AART` carries `physicalType` `si` + +#### Scenario: An empty component produces no Location + +- **WHEN** a person-location composite leaves Room, Bed and Floor empty +- **THEN** the converted bundle contains no `Location` carrying `physicalType` `ro`, `bd` or `lvl` + derived from that composite + +#### Scenario: Bed and Room are labelled from their own components + +- **WHEN** a person-location composite populates component 2 with `RM101` and component 3 with `B2` +- **THEN** a `Location` named `RM101` carries `physicalType` `ro` and a `Location` named `B2` carries + `physicalType` `bd` + +### Requirement: Point of Care carries no physical type code + +FHIR R4's `location-physical-type` code system has no concept for a point of care, and the +v2-to-FHIR IG leaves the code for `PL`/`LA1`/`LA2`-1 Point of Care unresolved. The converter SHALL +produce a `Location` named from the Point of Care component with no `physicalType` element at all, +rather than substituting an approximate code. + +#### Scenario: Point of Care is named but unlabelled + +- **WHEN** an `RXA-11` carries `IZGATEWAYART` in component 1 +- **THEN** the converted bundle contains a `Location` named `IZGATEWAYART` +- **AND** that `Location` has no `physicalType` element, empty or otherwise + +#### Scenario: No physical type code is invented for a point of care + +- **WHEN** a person-location composite populates only the Point of Care component +- **THEN** no `Location` derived from that composite carries `physicalType` `wa`, `bd` or any other + code from `location-physical-type` + +### Requirement: Multiple populated components nest from most specific to least + +When a person-location composite populates more than one physical-location component, the converter +SHALL produce one `Location` per populated component and SHALL relate them through `Location.partOf`, +running from the most specific component to the least specific in the order Bed, Room, Point of Care, +Floor, Building, Facility. Each `Location` the converter produces from these components SHALL have +`mode` `instance`. + +#### Scenario: Point of care nests inside building inside facility + +- **WHEN** an `RXA-11` populates Point of Care `IZGATEWAYART`, Facility `IZGATEWAY-AART` and Building + `IZ GATEWAY AART TEST` +- **THEN** the `Location` named `IZGATEWAYART` is `partOf` the `Location` named + `IZ GATEWAY AART TEST`, which is `partOf` the `Location` named `IZGATEWAY-AART` + +#### Scenario: A single populated component produces no containment chain + +- **WHEN** a person-location composite populates only the Facility component +- **THEN** exactly one `Location` is produced from that composite and it has no `partOf` + +### Requirement: Location status and person location type are not physical locations + +The `PL`/`LA1`/`LA2`-5 Location Status and `PL`/`LA1`/`LA2`-6 Person Location Type components describe +a location's state and kind, not a place within a containment hierarchy. The converter SHALL carry +Location Status on `Location.operationalStatus` and Person Location Type on `Location.type`, each +exactly once, and SHALL NOT produce a separate `Location` resource or a `physicalType` code from +either component. + +#### Scenario: Location status is not duplicated as a nested Location + +- **WHEN** a person-location composite populates the Location Status component +- **THEN** the value appears on `Location.operationalStatus` +- **AND** no additional `Location` resource is produced from that component + +#### Scenario: Person location type is not duplicated as a nested Location + +- **WHEN** a person-location composite populates the Person Location Type component +- **THEN** the value appears on `Location.type` +- **AND** no additional `Location` resource is produced from that component + +### Requirement: Components 9 and beyond are read according to the composite's own datatype + +`PL`, `LA1` and `LA2` agree on components 1 through 8 and diverge from component 9 onward. The +converter SHALL read those components according to the datatype of the field being converted, and +SHALL NOT carry an address component as `Location.description` or as `Location.identifier`: + +| Datatype | Component | Element | +| --- | --- | --- | +| `PL` | PL-9 Location Description | `Location.description` | +| `PL` | PL-10 Comprehensive Location Identifier | `Location.identifier` | +| `LA1` | LA1-9 Address (`AD`) | `Location.address` | +| `LA2` | LA2-9 Street Address through LA2-16 Other Geographic Designation | `Location.address` | + +`LA1` and `LA2` have no Location Description component and no Comprehensive Location Identifier +component, so a `Location` converted from either SHALL have no `description` and no `identifier` +derived from a component. The `Location` a `PL` converts to SHALL take `description` from PL-9 — +not from PL-10, whose value is the identifier. + +#### Scenario: A PL carries its description and identifier + +- **WHEN** a `PL` field carries `EMERGENCY ROOM ENTRANCE` in PL-9 and `4707` in PL-10 +- **THEN** the resulting `Location` has `description` `EMERGENCY ROOM ENTRANCE` +- **AND** the resulting `Location` has an `identifier` with value `4707` + +#### Scenario: An LA2 street address is an address, not a description + +- **WHEN** an `RXA-11` carries + `IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201` +- **THEN** the resulting `Location` has `address` with line `330 C ST SW UNIT 7`, city `UNKNOWN`, + state `VA` and postal code `20201` +- **AND** no `Location` produced from that field has a `description` +- **AND** no `Location` produced from that field has an `identifier` + +#### Scenario: Absent PL description and identifier are omitted + +- **WHEN** a `PL` field leaves PL-9 and PL-10 empty +- **THEN** the resulting `Location` has no `description` and no `identifier` diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/tasks.md b/openspec/changes/fix-pilot-fhir-conformance-defects/tasks.md new file mode 100644 index 000000000..952822369 --- /dev/null +++ b/openspec/changes/fix-pilot-fhir-conformance-defects/tasks.md @@ -0,0 +1,33 @@ +## 1. Baseline + +- [x] 1.1 Run `mvn clean test` on the unmodified branch and record the passing test count, so any later movement in `Location` counts is attributable to the fix rather than to new coverage — baseline: 24517 tests, 0 failures, 3 skipped, BUILD SUCCESS +- [x] 1.2 Confirm no existing test asserts on `Location.physicalType`, `Location.description` or `Location.identifier`, or counts bundle entries in a way the fix will move; note any that do — no test in `src/test` references `Location` at all + +## 2. Location physicalType fix + +- [x] 2.1 In `DatatypeConverter`, promote the three parallel locals in `toLocationFromComposite` to class-level constants `LOC_COMPONENTS`, `LOC_CODES`, `LOC_DISPLAYS`, keeping the existing component order (Bed, Room, Point of Care, Floor, Building, Facility) and the existing `si`/"Site" display +- [x] 2.2 Change the loop to read `ParserUtils.getComponent(comp, LOC_COMPONENTS[i])` instead of `getComponent(comp, i)` +- [x] 2.3 Set the Point of Care entry's code and display to `null`, and skip `setPhysicalType` when the code is `null`, so Point of Care yields a named `Location` with no `physicalType` element +- [x] 2.4 Update the method Javadoc so it documents the mapping as implemented and cites the IG's `datatype-pl-to-location` ConceptMap as its source +- [x] 2.5 Verify PL-5 and PL-6 are no longer emitted as nested `Location` resources and are still carried once each on `operationalStatus` and `type` +- [x] 2.6 Verify PL-7 Building and PL-8 Floor are now read, using the reviewed Nevada `RXA-11` as the worked example +- [x] 2.7 Move the `setDescription` and `addIdentifier` calls out of `toLocationFromComposite` into `toLocation`'s `case "PL"`, so only a `PL` reads components 9 and 10 +- [x] 2.8 In `case "PL"`, set `description` from component index 8 (PL-9) and `identifier` from index 9 (PL-10), replacing the existing `setDescription(ParserUtils.toString(comp, 9))` that overwrote the description with the identifier +- [x] 2.9 Verify an `LA1` and an `LA2` now produce no `description` and no `identifier` from components 9 and 10, and still produce the same `Location.address` as before + +## 3. Location tests + +- [x] 3.1 Add a test converting a person-location composite that populates Point of Care, Facility and Building, asserting the codes, the names, and the `partOf` chain order +- [x] 3.2 Add a test asserting a Point of Care `Location` has no `physicalType` element, empty or otherwise, and carries no code from `location-physical-type` +- [x] 3.3 Add a test asserting empty Room, Bed and Floor components produce no `Location` carrying `ro`, `bd` or `lvl` +- [x] 3.4 Add a test asserting Location Status and Person Location Type reach `operationalStatus` and `type` exactly once and produce no additional `Location` +- [x] 3.5 Add a test asserting a `PL` carries PL-9 in `description` and PL-10 in `identifier`, and that both are absent when those components are empty +- [x] 3.6 Add a test asserting an `LA2` `RXA-11` puts its street address, city, state and postal code on `Location.address` and produces no `description` and no `identifier` +- [x] 3.7 Add a test asserting an `LA1` produces its `Location.address` from LA1-9 and no `description` + +## 4. Verification and version + +- [x] 4.1 Run `mvn clean install` and compare the test count and results against the 1.1 baseline, accounting for every difference — 24529 tests, 0 failures, 3 skipped: baseline 24517 plus the 12 new Location tests +- [x] 4.2 Convert the reviewed Nevada capture and confirm each `RXA-11` yields `IZGATEWAYART` with no `physicalType`, `IZ GATEWAY AART TEST` with `bu`, and `IZGATEWAY-AART` with `si`, that no `bd` or `lvl` remains, and that no `Location` carries `330 C ST SW UNIT 7` as a `description` — confirmed, and the address stays on the Point of Care Location +- [x] 4.3 Settle the version the breaking `Location` corrections ship under — pom moved to `2.5.2-SNAPSHOT` for pilot testing, to be released as `2.6.0`. `RELEASE_NOTES.md` is written by the release workflow, not by hand, so it is not touched here +- [x] 4.4 Move `izgw-transform`'s `v2tofhir` dependency from `2.5.1-SNAPSHOT` to `2.5.2-SNAPSHOT` so the service picks this up (separate repo) — `izgw-transform/pom.xml:111` now pins `2.5.2-SNAPSHOT`, verified end to end by converting the Nevada Z42 through the service diff --git a/pom.xml b/pom.xml index db69e79b6..bd70017c1 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ 1.13.0 v2tofhir - 2.5.1-SNAPSHOT + 2.5.2-SNAPSHOT HL7 Version 2 to FHIR Conversion This is the v2tofhir Converter core code for the IZ Gateway Transformation Service http://github.com/IZGateway/v2tofhir diff --git a/src/main/java/gov/cdc/izgw/v2tofhir/converter/DatatypeConverter.java b/src/main/java/gov/cdc/izgw/v2tofhir/converter/DatatypeConverter.java index 32a42cc39..3e5b9ef23 100644 --- a/src/main/java/gov/cdc/izgw/v2tofhir/converter/DatatypeConverter.java +++ b/src/main/java/gov/cdc/izgw/v2tofhir/converter/DatatypeConverter.java @@ -1175,7 +1175,10 @@ public static Location toLocation(Type t) { case "PL": location.setMode(LocationMode.INSTANCE); toLocationFromComposite(location, comp); - location.setDescription(ParserUtils.toString(comp, 9)); + // Only a PL has these. LA1-9 is an Address and LA2-9 through LA2-16 are the address + // components, both of which become Location.address below. + location.setDescription(ParserUtils.toString(comp, 8)); // PL-9 Location Description + location.addIdentifier(toIdentifier(ParserUtils.getComponent(comp, 9))); // PL-10 break; case "LA1": location.setMode(LocationMode.INSTANCE); @@ -1228,29 +1231,49 @@ public static T markDeleted(T r } /** - * Convert the bulk of a PL, LA1 or LA2 to a location. These all look almost the - * same. PL/LA1/LA2.3 - Bed HD/IS 0304 PL/LA1/LA2.2 - Room HD/IS 0303 - * PL/LA1/LA2.1 - Point Of Care HD/IS 0302 PL/LA1/LA2.8 - Floor HD/IS 0308 - * PL/LA1/LA2.7 - Building HD/IS 0307 PL/LA1/LA2.4 - Facility HD/IS - * - * PL/LA1/LA2.5 - Location Status IS O - 0306 PL/LA1/LA2.6 - Person Location - * Type IS O - 0305 - * + * The PL/LA1/LA2 components that name a physical location, most specific first, so that the + * Location resources built from them nest correctly through Location.partOf. + * @see #LOC_CODES + */ + private static final int[] LOC_COMPONENTS = { 2, 1, 0, 7, 6, 3 }; + /** + * The location-physical-type code for each component in {@link #LOC_COMPONENTS}, transcribed from + * the HL7 V2-to-FHIR IG ConceptMap datatype-pl-to-location. A null code means the component names + * a Location that carries no physicalType: FHIR R4's location-physical-type code system has no + * concept for a point of care, and the IG leaves that cell unresolved. + */ + private static final String[] LOC_CODES = { "bd", "ro", null, "lvl", "bu", "si" }; + /** The display for each code in {@link #LOC_CODES}, as the code system gives it */ + private static final String[] LOC_DISPLAYS = { "Bed", "Room", null, "Level", "Building", "Site" }; + + /** + * Convert the bulk of a PL, LA1 or LA2 to a location. These all look almost the same through + * component 8, and the HL7 V2-to-FHIR IG ConceptMap datatype-pl-to-location gives the + * location-physical-type code for each one: + * + * PL/LA1/LA2-3 Bed = bd, PL/LA1/LA2-2 Room = ro, PL/LA1/LA2-1 Point of Care = no code, + * PL/LA1/LA2-8 Floor = lvl, PL/LA1/LA2-7 Building = bu, PL/LA1/LA2-4 Facility = si. + * + * Each populated component names its own Location, and they are chained through + * Location.partOf from the most specific component to the least. + * + * PL/LA1/LA2-5 Location Status (table 0306) and PL/LA1/LA2-6 Person Location Type (table 0305) + * describe the location rather than naming a place, so they become operationalStatus and type on + * the Location itself and never a Location of their own. + * + * Components 9 and up differ between the three datatypes, so the caller reads them. + * * @param location The location * @param comp The composite to convert */ private static void toLocationFromComposite(Location location, Composite comp) { - String[] d = { "Bed", "Room", "Ward", "Level", "Building", "Site" }; - String[] n = { "bd", "ro", "wa", "lvl", "bu", "si" }; - int[] c = { 2, 1, 0, 7, 6, 3 }; Location curl = location; - for (int i = 0; i < c.length; i++) { - Type t1 = ParserUtils.getComponent(comp, i); + for (int i = 0; i < LOC_COMPONENTS.length; i++) { + Type t1 = ParserUtils.getComponent(comp, LOC_COMPONENTS[i]); if (ParserUtils.isEmpty(t1)) { continue; } - CodeableConcept cc = new CodeableConcept().addCoding(new Coding(Systems.LOCATION_TYPE, n[i], d[i])); if (curl.hasName()) { Location partOf = new Location(); @@ -1259,14 +1282,15 @@ private static void toLocationFromComposite(Location location, Composite comp) { curl = partOf; } curl.setMode(LocationMode.INSTANCE); - curl.setPhysicalType(cc); + if (LOC_CODES[i] != null) { + curl.setPhysicalType( + new CodeableConcept().addCoding(new Coding(Systems.LOCATION_TYPE, LOC_CODES[i], LOC_DISPLAYS[i]))); + } curl.setName(ParserUtils.toString(t1)); ParserUtils.toReference(curl, null, "partof"); // Update reference } location.setOperationalStatus(toCoding(ParserUtils.getComponent(comp, 4), "0306")); location.addType(toCodeableConcept(ParserUtils.getComponent(comp, 5), "0305")); - location.setDescription(ParserUtils.toString(comp, 8)); - location.addIdentifier(toIdentifier(ParserUtils.getComponent(comp, 9))); } /** diff --git a/src/test/java/test/gov/cdc/izgateway/v2tofhir/LocationCompositeTests.java b/src/test/java/test/gov/cdc/izgateway/v2tofhir/LocationCompositeTests.java new file mode 100644 index 000000000..c4708102e --- /dev/null +++ b/src/test/java/test/gov/cdc/izgateway/v2tofhir/LocationCompositeTests.java @@ -0,0 +1,235 @@ +package test.gov.cdc.izgateway.v2tofhir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.hl7.fhir.r4.model.Location; +import org.hl7.fhir.r4.model.Location.LocationMode; +import org.junit.jupiter.api.Test; + +import ca.uhn.hl7v2.model.Segment; +import ca.uhn.hl7v2.model.Type; +import gov.cdc.izgw.v2tofhir.converter.DatatypeConverter; + +/** + * Tests for the conversion of the HL7 V2 person-location composites (PL, LA1 and LA2) to + * Location, covering which component names each Location, which location-physical-type code it + * carries, how several populated components nest through Location.partOf, and which element + * components 9 and beyond supply for each of the three datatypes. + * + * The physical-type codes come from the HL7 V2-to-FHIR IG ConceptMap datatype-pl-to-location: + * PL-3 Bed = bd, PL-2 Room = ro, PL-1 Point of Care = no code, PL-8 Floor = lvl, + * PL-7 Building = bu, PL-4 Facility = si. + * + * @author Audacious Inquiry + */ +class LocationCompositeTests extends TestBase { + private static final String PHYSICAL_TYPE = "http://terminology.hl7.org/CodeSystem/location-physical-type"; + /** The RXA-11 from the eHealth Exchange pilot response the conformance review was written against */ + private static final String REVIEWED_RXA_11 = + "IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201"; + + /** + * The reviewed RXA-11: Point of Care, Facility and Building are populated, so each names its own + * Location and they nest from the most specific component to the least. The review reported + * bd/Bed on the Point of Care and lvl/Level on the Facility, neither of which the message sent. + */ + @Test + void testReviewedRxa11IsLabelledFromItsOwnComponents() { + List chain = chainOf(toLa2(REVIEWED_RXA_11)); + assertEquals(3, chain.size(), "One Location per populated component"); + + assertEquals(Arrays.asList("IZGATEWAYART", "IZ GATEWAY AART TEST", "IZGATEWAY-AART"), namesOf(chain), + "Point of Care, then Building, then Facility"); + assertEquals(Arrays.asList(null, "bu", "si"), codesOf(chain)); + assertFalse(codesOf(chain).contains("bd"), "Nothing sent a Bed"); + assertFalse(codesOf(chain).contains("lvl"), "Nothing sent a Floor"); + for (Location location : chain) { + assertEquals(LocationMode.INSTANCE, location.getMode()); + } + } + + /** The same three components in a PL, which is where most person locations arrive. */ + @Test + void testPointOfCareFacilityAndBuilding() { + List chain = chainOf(toPl("IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST")); + assertEquals(Arrays.asList("IZGATEWAYART", "IZ GATEWAY AART TEST", "IZGATEWAY-AART"), namesOf(chain)); + assertEquals(Arrays.asList(null, "bu", "si"), codesOf(chain)); + } + + /** Point of Care has no code in location-physical-type, and none is invented for it. */ + @Test + void testPointOfCareCarriesNoPhysicalType() { + Location location = toLocation(toPl("IZGATEWAYART")); + assertEquals("IZGATEWAYART", location.getName()); + assertFalse(location.hasPhysicalType(), "No physicalType element, empty or otherwise"); + assertFalse(location.hasPartOf(), "A single populated component builds no containment chain"); + } + + /** Bed, Room and Floor carry their own codes, most specific first. */ + @Test + void testBedRoomAndFloor() { + List chain = chainOf(toPl("^RM101^B2^^^^^FL3")); + assertEquals(Arrays.asList("B2", "RM101", "FL3"), namesOf(chain)); + assertEquals(Arrays.asList("bd", "ro", "lvl"), codesOf(chain)); + } + + /** An empty component names nothing, so it produces no Location and no code. */ + @Test + void testEmptyComponentsProduceNoLocation() { + List chain = chainOf(toPl("IZGATEWAYART^^^IZGATEWAY-AART")); + assertEquals(2, chain.size()); + List codes = codesOf(chain); + assertFalse(codes.contains("ro"), "No Room was sent"); + assertFalse(codes.contains("bd"), "No Bed was sent"); + assertFalse(codes.contains("lvl"), "No Floor was sent"); + } + + /** + * PL-5 Location Status and PL-6 Person Location Type describe the location rather than naming a + * place, so each is carried once on the Location and neither produces one of its own. + */ + @Test + void testLocationStatusAndPersonLocationTypeAreNotLocations() { + Location location = toLocation(toPl("IZGATEWAYART^^^^A^E")); + assertEquals("A", location.getOperationalStatus().getCode(), "From PL-5"); + assertEquals(1, location.getType().size(), "PL-6 once"); + assertEquals("E", location.getTypeFirstRep().getCodingFirstRep().getCode(), "From PL-6"); + + List chain = chainOf(location); + assertEquals(1, chain.size(), "Only Point of Care names a Location"); + assertFalse(codesOf(chain).contains("A")); + assertFalse(codesOf(chain).contains("E")); + } + + /** Only a PL has a Location Description and a Comprehensive Location Identifier. */ + @Test + void testPlDescriptionAndIdentifier() { + Location location = toLocation(toPl("IZGATEWAYART^^^^^^^^EMERGENCY ROOM ENTRANCE^4707")); + assertEquals("EMERGENCY ROOM ENTRANCE", location.getDescription(), "From PL-9, not PL-10"); + assertEquals("4707", location.getIdentifierFirstRep().getValue(), "From PL-10"); + } + + /** Absent PL-9 and PL-10 produce no description and no identifier. */ + @Test + void testPlWithoutDescriptionOrIdentifier() { + Location location = toLocation(toPl("IZGATEWAYART^^^IZGATEWAY-AART")); + assertFalse(location.hasDescription()); + assertFalse(location.hasIdentifier()); + } + + /** + * LA2-9 through LA2-16 are the address, which is where they go. An LA2 has no Location + * Description component and no Comprehensive Location Identifier component, so it produces + * neither -- the review saw the street address of the reviewed RXA-11 as a description. + */ + @Test + void testLa2CarriesAnAddressAndNoDescription() { + Location location = toLocation(toLa2(REVIEWED_RXA_11)); + assertEquals("330 C ST SW UNIT 7", location.getAddress().getLine().get(0).getValue()); + assertEquals("UNKNOWN", location.getAddress().getCity()); + assertEquals("VA", location.getAddress().getState()); + assertEquals("20201", location.getAddress().getPostalCode()); + for (Location inChain : chainOf(location)) { + assertFalse(inChain.hasDescription(), "LA2-9 is a street address, not a description"); + assertFalse(inChain.hasIdentifier(), "LA2-10 is another designation, not an identifier"); + } + } + + /** LA1-9 is an Address, so it becomes Location.address and never a description. */ + @Test + void testLa1CarriesAnAddressAndNoDescription() { + Location location = toLocation(toLa1( + "IZGATEWAYART^^^IZGATEWAY-AART^^^^^123 MAIN ST&APT 2&SPRINGFIELD&IL&62701")); + assertEquals("SPRINGFIELD", location.getAddress().getCity()); + for (Location inChain : chainOf(location)) { + assertFalse(inChain.hasDescription(), "LA1-9 is an address, not a description"); + assertFalse(inChain.hasIdentifier(), "LA1 has no identifier component"); + } + } + + /** The physical type codes come from the standard code system. */ + @Test + void testPhysicalTypeUsesTheStandardCodeSystem() { + for (Location location : chainOf(toPl("^RM101^B2^FAC^^^BLDG^FL3"))) { + if (location.hasPhysicalType()) { + assertEquals(PHYSICAL_TYPE, location.getPhysicalType().getCodingFirstRep().getSystem()); + } + } + } + + /** Every named Location in a chain is an instance, not a kind */ + @Test + void testEveryLocationInAChainIsAnInstance() { + for (Location location : chainOf(toPl("POC^RM101^B2^FAC^^^BLDG^FL3"))) { + assertTrue(LocationMode.INSTANCE.equals(location.getMode()), "mode is instance"); + } + } + + private static Location toLocation(Type type) { + Location location = DatatypeConverter.toLocation(type); + assertNotNull(location, "The composite converts to a Location"); + return location; + } + + /** PL is PV1-3 Assigned Patient Location */ + private static Type toPl(String pl) { + return fieldOf("PV1|1|I|" + pl, 3); + } + + /** LA2 is RXA-11 Administered-at Location */ + private static Type toLa2(String la2) { + return fieldOf("RXA|0|1|20220810|20220810|09^Td (adult), adsorbed^CVX|0.5|mL^MilliLiter^UCUM|||" + + "01^Historical Information - Source Unspecified^NIP001|" + la2, 11); + } + + /** LA1 is RXE-8 Deliver-To Location */ + private static Type toLa1(String la1) { + return fieldOf("RXE||||||||" + la1, 8); + } + + private static Type fieldOf(String segment, int field) { + try { + Segment seg = parseSegment(segment); + Type type = seg.getField(field, 0); + assertNotNull(type); + return type; + } catch (Exception e) { + throw new AssertionError("Cannot parse " + segment, e); + } + } + + /** The Location and everything it is partOf, in order */ + private static List chainOf(Type type) { + return chainOf(toLocation(type)); + } + + private static List chainOf(Location location) { + List chain = new ArrayList<>(); + Location current = location; + while (current != null) { + chain.add(current); + current = current.hasPartOf() ? (Location) current.getPartOf().getResource() : null; + } + return chain; + } + + private static List namesOf(List chain) { + return chain.stream().map(Location::getName).toList(); + } + + /** The physicalType code of each Location in the chain, null where it carries none */ + private static List codesOf(List chain) { + List codes = new ArrayList<>(); + for (Location location : chain) { + codes.add(location.hasPhysicalType() ? location.getPhysicalType().getCodingFirstRep().getCode() : null); + } + return codes; + } +} From 0f2b25495bec84e1b46618d5bdc5eef8f4e25b2a Mon Sep 17 00:00:00 2001 From: Austin Moody Date: Mon, 17 Aug 2026 16:57:26 -0400 Subject: [PATCH 2/3] docs: state why Point of Care carries no physicalType precisely The design and the location-composite-conversion spec said `wa`/Ward was invented data. `wa` is a real code in FHIR R4's location-physical-type code system; what has no basis is assigning it to PL/LA1/LA2-1 Point of Care, which the v2-to-FHIR IG leaves unresolved by naming an extension rather than a code. Emitting it asserts a physical type the sending system never sent, which is the actual defect the eHealth Exchange review reported. Both files now lead with the IG's unresolved cell, then note that no concept in location-physical-type describes a point of care, and record that Location.physicalType is 0..1 so a Location without one is conformant. The spec scenario is retitled from "invented" to "substituted" to match. Wording only; no behavior, no code, no test changes. --- .../fix-pilot-fhir-conformance-defects/design.md | 9 ++++++--- .../specs/location-composite-conversion/spec.md | 13 ++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/design.md b/openspec/changes/fix-pilot-fhir-conformance-defects/design.md index 813d94a4f..e04daed8c 100644 --- a/openspec/changes/fix-pilot-fhir-conformance-defects/design.md +++ b/openspec/changes/fix-pilot-fhir-conformance-defects/design.md @@ -52,15 +52,18 @@ to "Facility" to match the source component — the value set's display is the c changing it buys nothing. A `null` code means "produce the named `Location`, emit no `physicalType`". That is how Point of Care -is handled: FHIR R4's `location-physical-type` has no point-of-care concept, and the IG leaves that -cell unresolved. +is handled: the IG leaves that cell unresolved — it names an extension rather than a code — and no +concept in FHIR R4's `location-physical-type` describes a point of care. `Location.physicalType` is +`0..1`, so a `Location` without one is conformant. *Alternatives considered.* Renumbering the code arrays so the existing `i` indexing becomes correct would work but discards the IG mapping as documentation — the next reader would have no way to see which component each code came from. Defining our own point-of-care extension follows the IG's stated intent, but the IG never finished defining it, so we would be inventing a canonical and repeating the mistake this same review flagged in our use of the core `originalText` extension. Keeping `wa`/Ward is -rejected outright: it is the invented-data problem the review reported, merely relabelled. +rejected outright: `wa` is a real `location-physical-type` code, but neither the message nor the IG +assigns it to Point of Care, so emitting it asserts a physical type the sender never sent — the same +invented-data problem the review reported, merely relabelled. ### Components 9 and beyond move out of the shared helper diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md b/openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md index 895525116..c9ce929e1 100644 --- a/openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md +++ b/openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md @@ -48,10 +48,13 @@ these components SHALL take its `name` from that same component. ### Requirement: Point of Care carries no physical type code -FHIR R4's `location-physical-type` code system has no concept for a point of care, and the -v2-to-FHIR IG leaves the code for `PL`/`LA1`/`LA2`-1 Point of Care unresolved. The converter SHALL -produce a `Location` named from the Point of Care component with no `physicalType` element at all, -rather than substituting an approximate code. +The v2-to-FHIR IG leaves the code for `PL`/`LA1`/`LA2`-1 Point of Care unresolved, naming an +extension rather than a code, and no concept in FHIR R4's `location-physical-type` code system +describes a point of care. `Location.physicalType` is `0..1`, so a `Location` without one is +conformant. The converter SHALL produce a `Location` named from the Point of Care component with no +`physicalType` element at all, rather than substituting an approximate code such as `wa`/Ward: that +code is valid in itself, but nothing in the message or the IG assigns it to this component, so +emitting it would assert a physical type the sending system never sent. #### Scenario: Point of Care is named but unlabelled @@ -59,7 +62,7 @@ rather than substituting an approximate code. - **THEN** the converted bundle contains a `Location` named `IZGATEWAYART` - **AND** that `Location` has no `physicalType` element, empty or otherwise -#### Scenario: No physical type code is invented for a point of care +#### Scenario: No physical type code is substituted for a point of care - **WHEN** a person-location composite populates only the Point of Care component - **THEN** no `Location` derived from that composite carries `physicalType` `wa`, `bd` or any other From 5c272e8124fed3a68dfb4fbc3bc2d86a43de45a5 Mon Sep 17 00:00:00 2001 From: Austin Moody Date: Mon, 17 Aug 2026 20:58:35 -0400 Subject: [PATCH 3/3] chore: archive fix-pilot-fhir-conformance-defects Move the change to openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects and promote its location-composite-conversion delta to a main spec. The spec contracts how PL, LA1 and LA2 become Location: which component names each Location, the physicalType code per component from the IG's datatype-pl-to-location ConceptMap, no code for Point of Care, the partOf nesting order, Location Status and Person Location Type as operationalStatus and type, and which element components 9 and beyond supply per datatype. All 22 tasks complete; 24529 tests pass. --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../location-composite-conversion/spec.md | 0 .../tasks.md | 0 .../location-composite-conversion/spec.md | 147 ++++++++++++++++++ 6 files changed, 147 insertions(+) rename openspec/changes/{fix-pilot-fhir-conformance-defects => archive/2026-08-17-fix-pilot-fhir-conformance-defects}/.openspec.yaml (100%) rename openspec/changes/{fix-pilot-fhir-conformance-defects => archive/2026-08-17-fix-pilot-fhir-conformance-defects}/design.md (100%) rename openspec/changes/{fix-pilot-fhir-conformance-defects => archive/2026-08-17-fix-pilot-fhir-conformance-defects}/proposal.md (100%) rename openspec/changes/{fix-pilot-fhir-conformance-defects => archive/2026-08-17-fix-pilot-fhir-conformance-defects}/specs/location-composite-conversion/spec.md (100%) rename openspec/changes/{fix-pilot-fhir-conformance-defects => archive/2026-08-17-fix-pilot-fhir-conformance-defects}/tasks.md (100%) create mode 100644 openspec/specs/location-composite-conversion/spec.md diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/.openspec.yaml b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/.openspec.yaml similarity index 100% rename from openspec/changes/fix-pilot-fhir-conformance-defects/.openspec.yaml rename to openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/.openspec.yaml diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/design.md b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/design.md similarity index 100% rename from openspec/changes/fix-pilot-fhir-conformance-defects/design.md rename to openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/design.md diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/proposal.md b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/proposal.md similarity index 100% rename from openspec/changes/fix-pilot-fhir-conformance-defects/proposal.md rename to openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/proposal.md diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md similarity index 100% rename from openspec/changes/fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md rename to openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/specs/location-composite-conversion/spec.md diff --git a/openspec/changes/fix-pilot-fhir-conformance-defects/tasks.md b/openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/tasks.md similarity index 100% rename from openspec/changes/fix-pilot-fhir-conformance-defects/tasks.md rename to openspec/changes/archive/2026-08-17-fix-pilot-fhir-conformance-defects/tasks.md diff --git a/openspec/specs/location-composite-conversion/spec.md b/openspec/specs/location-composite-conversion/spec.md new file mode 100644 index 000000000..487dfd29c --- /dev/null +++ b/openspec/specs/location-composite-conversion/spec.md @@ -0,0 +1,147 @@ +## Purpose + +Defines how the HL7 V2 person-location composites (`PL`, `LA1` and `LA2`) become FHIR R4 `Location` +resources — which component names each `Location`, which physical-location code each component +carries, how several populated components nest into a containment chain, and which components are +not physical locations at all — so that a consumer can trust that a `Location.physicalType` it +receives was actually asserted by the sending system. It also fixes which components 9 and beyond +supply, since `PL`, `LA1` and `LA2` agree only through component 8. + +## Requirements + +### Requirement: Physical location components carry the code the v2-to-FHIR IG assigns them + +The converter SHALL derive `Location.physicalType` from the component that the HL7 v2-to-FHIR +Implementation Guide's `datatype-pl-to-location` ConceptMap assigns to that code, using the +`http://terminology.hl7.org/CodeSystem/location-physical-type` code system: + +| Component | `physicalType` code | +| --- | --- | +| `PL`/`LA1`/`LA2`-2 Room | `ro` | +| `PL`/`LA1`/`LA2`-3 Bed | `bd` | +| `PL`/`LA1`/`LA2`-4 Facility | `si` | +| `PL`/`LA1`/`LA2`-7 Building | `bu` | +| `PL`/`LA1`/`LA2`-8 Floor | `lvl` | + +The converter SHALL NOT emit a `physicalType` code for a component the message left empty, and +SHALL NOT emit a code the message does not support. A `Location` the converter produces from one of +these components SHALL take its `name` from that same component. + +#### Scenario: Facility and building are labelled from their own components + +- **WHEN** an `RXA-11` carries + `IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201` +- **THEN** a `Location` named `IZ GATEWAY AART TEST` carries `physicalType` `bu` (Building) +- **AND** a `Location` named `IZGATEWAY-AART` carries `physicalType` `si` + +#### Scenario: An empty component produces no Location + +- **WHEN** a person-location composite leaves Room, Bed and Floor empty +- **THEN** the converted bundle contains no `Location` carrying `physicalType` `ro`, `bd` or `lvl` + derived from that composite + +#### Scenario: Bed and Room are labelled from their own components + +- **WHEN** a person-location composite populates component 2 with `RM101` and component 3 with `B2` +- **THEN** a `Location` named `RM101` carries `physicalType` `ro` and a `Location` named `B2` carries + `physicalType` `bd` + +### Requirement: Point of Care carries no physical type code + +The v2-to-FHIR IG leaves the code for `PL`/`LA1`/`LA2`-1 Point of Care unresolved, naming an +extension rather than a code, and no concept in FHIR R4's `location-physical-type` code system +describes a point of care. `Location.physicalType` is `0..1`, so a `Location` without one is +conformant. The converter SHALL produce a `Location` named from the Point of Care component with no +`physicalType` element at all, rather than substituting an approximate code such as `wa`/Ward: that +code is valid in itself, but nothing in the message or the IG assigns it to this component, so +emitting it would assert a physical type the sending system never sent. + +#### Scenario: Point of Care is named but unlabelled + +- **WHEN** an `RXA-11` carries `IZGATEWAYART` in component 1 +- **THEN** the converted bundle contains a `Location` named `IZGATEWAYART` +- **AND** that `Location` has no `physicalType` element, empty or otherwise + +#### Scenario: No physical type code is substituted for a point of care + +- **WHEN** a person-location composite populates only the Point of Care component +- **THEN** no `Location` derived from that composite carries `physicalType` `wa`, `bd` or any other + code from `location-physical-type` + +### Requirement: Multiple populated components nest from most specific to least + +When a person-location composite populates more than one physical-location component, the converter +SHALL produce one `Location` per populated component and SHALL relate them through `Location.partOf`, +running from the most specific component to the least specific in the order Bed, Room, Point of Care, +Floor, Building, Facility. Each `Location` the converter produces from these components SHALL have +`mode` `instance`. + +#### Scenario: Point of care nests inside building inside facility + +- **WHEN** an `RXA-11` populates Point of Care `IZGATEWAYART`, Facility `IZGATEWAY-AART` and Building + `IZ GATEWAY AART TEST` +- **THEN** the `Location` named `IZGATEWAYART` is `partOf` the `Location` named + `IZ GATEWAY AART TEST`, which is `partOf` the `Location` named `IZGATEWAY-AART` + +#### Scenario: A single populated component produces no containment chain + +- **WHEN** a person-location composite populates only the Facility component +- **THEN** exactly one `Location` is produced from that composite and it has no `partOf` + +### Requirement: Location status and person location type are not physical locations + +The `PL`/`LA1`/`LA2`-5 Location Status and `PL`/`LA1`/`LA2`-6 Person Location Type components describe +a location's state and kind, not a place within a containment hierarchy. The converter SHALL carry +Location Status on `Location.operationalStatus` and Person Location Type on `Location.type`, each +exactly once, and SHALL NOT produce a separate `Location` resource or a `physicalType` code from +either component. + +#### Scenario: Location status is not duplicated as a nested Location + +- **WHEN** a person-location composite populates the Location Status component +- **THEN** the value appears on `Location.operationalStatus` +- **AND** no additional `Location` resource is produced from that component + +#### Scenario: Person location type is not duplicated as a nested Location + +- **WHEN** a person-location composite populates the Person Location Type component +- **THEN** the value appears on `Location.type` +- **AND** no additional `Location` resource is produced from that component + +### Requirement: Components 9 and beyond are read according to the composite's own datatype + +`PL`, `LA1` and `LA2` agree on components 1 through 8 and diverge from component 9 onward. The +converter SHALL read those components according to the datatype of the field being converted, and +SHALL NOT carry an address component as `Location.description` or as `Location.identifier`: + +| Datatype | Component | Element | +| --- | --- | --- | +| `PL` | PL-9 Location Description | `Location.description` | +| `PL` | PL-10 Comprehensive Location Identifier | `Location.identifier` | +| `LA1` | LA1-9 Address (`AD`) | `Location.address` | +| `LA2` | LA2-9 Street Address through LA2-16 Other Geographic Designation | `Location.address` | + +`LA1` and `LA2` have no Location Description component and no Comprehensive Location Identifier +component, so a `Location` converted from either SHALL have no `description` and no `identifier` +derived from a component. The `Location` a `PL` converts to SHALL take `description` from PL-9 — +not from PL-10, whose value is the identifier. + +#### Scenario: A PL carries its description and identifier + +- **WHEN** a `PL` field carries `EMERGENCY ROOM ENTRANCE` in PL-9 and `4707` in PL-10 +- **THEN** the resulting `Location` has `description` `EMERGENCY ROOM ENTRANCE` +- **AND** the resulting `Location` has an `identifier` with value `4707` + +#### Scenario: An LA2 street address is an address, not a description + +- **WHEN** an `RXA-11` carries + `IZGATEWAYART^^^IZGATEWAY-AART^^^IZ GATEWAY AART TEST^^330 C ST SW UNIT 7^^UNKNOWN^VA^20201` +- **THEN** the resulting `Location` has `address` with line `330 C ST SW UNIT 7`, city `UNKNOWN`, + state `VA` and postal code `20201` +- **AND** no `Location` produced from that field has a `description` +- **AND** no `Location` produced from that field has an `identifier` + +#### Scenario: Absent PL description and identifier are omitted + +- **WHEN** a `PL` field leaves PL-9 and PL-10 empty +- **THEN** the resulting `Location` has no `description` and no `identifier`