Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/brave-pandas-shout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cosyte/transform": patch
---

Add a narrow reverse path, FHIR to HL7 v2: `toV2Patient(patient, trigger)` emits a complete `ADT` message carrying a `PID`, and `toV2Observation(observation, trigger)` a complete `ORU` message carrying an `OBX` (roadmap §Phase 7, shipped for two of the three scoped shapes).

Each takes the FHIR resource **plus the v2 trigger the message should carry**, and returns the same `{ value, issues }` envelope the forward direction uses, where `value` is a complete `@cosyte/hl7` message. The trigger is required and is never inferred: no FHIR resource carries an HL7 v2 message trigger, so a missing, empty or non-string one returns no message and a `TRANSFORM_MISSING_TRIGGER` diagnostic without calling the builder at all, and a trigger that is not a bare token (whitespace, or a delimiter that would split MSH-9) returns `TRANSFORM_VALUE_NOT_REPRESENTABLE` rather than being trimmed into something else.

**This direction is lossy by design and is not a round-trip.** The IG maps v2 to FHIR and publishes no map the other way, so every row here is the inverse of a published row, and an inverse is only usable where the forward row is one-to-one. `invertCodeMap` enforces exactly that, and the many-to-one rows are refused with `TRANSFORM_CODE_NOT_INVERTIBLE` rather than resolved to their likeliest source code: `gender` `other`, name use `official` and `temp`, address use `work`, every `Address.type`, and `Observation.status` `entered-in-error`. An element with no v2 field in this map is flagged `TRANSFORM_NO_V2_TARGET`, a value v2 cannot carry unchanged is left out with `TRANSFORM_VALUE_NOT_REPRESENTABLE`, and a coding system with no v2 mnemonic is flagged `TRANSFORM_CODE_SYSTEM_NOT_V2` rather than written under a borrowed table. Nothing asserts that a message transformed to FHIR and back equals the original; the property suite verifies only that every emitted message parses back under `parseHL7` without a fatal error and carries the caller's trigger verbatim in MSH-9.

Seven issue codes are added (`TRANSFORM_MISSING_TRIGGER`, `TRANSFORM_UNSUPPORTED_RESOURCE`, `TRANSFORM_RESOURCE_MALFORMED`, `TRANSFORM_NO_V2_TARGET`, `TRANSFORM_VALUE_NOT_REPRESENTABLE`, `TRANSFORM_CODE_NOT_INVERTIBLE`, `TRANSFORM_CODE_SYSTEM_NOT_V2`), additions only: no existing `ISSUE_CODES` or `FATAL_CODES` key is renamed or removed. They are `ISSUE_CODES` entries because they are returned rather than thrown, which is the structural line between the two registries in this package.

The third scoped shape, a `Patient` + `Encounter` visit-carrying ADT, is deferred with a dated rationale in `documentation/decisions/0003`: the vendored parser exports no ADT assembly entry point, and hand-assembling that message structure here would invert the tier split ADR 0001 draws.
11 changes: 11 additions & 0 deletions .changeset/quiet-owls-declare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cosyte/transform": patch
---

Declare, rather than merely leave, what the reverse (FHIR to v2) direction cannot supply. Two issue codes are added, additions only: no existing `ISSUE_CODES` or `FATAL_CODES` key is renamed or removed, and no emitted segment content changes.

`TRANSFORM_V2_REQUIRED_FIELD_ABSENT` is raised once per v2-required field that ends up absent from an emitted segment because the FHIR resource carried no source this map could ground it from. Until now that absence was silent: a `Patient` with neither `identifier` nor `name` emitted an `ADT` whose `PID` carried neither PID-3 (Patient Identifier List) nor PID-5 (Patient Name), and an `Observation` with no `status` emitted an `ORU` whose `OBX` carried no OBX-11 (Observation Result Status), both with an empty `issues` array. The field is still left absent, exactly as before, because a placeholder written to satisfy v2 structure would be a fabricated clinical value; what changes is that the receiver is no longer the first to find out.

`TRANSFORM_NO_V2_MESSAGE_EMITTED` is raised when a conversion produces no message at all, because nothing in the resource grounded a single field of the target segment. That case previously returned `{ value: undefined, issues: [] }`, which a caller could not tell apart from a successful empty conversion. It is distinct from the refusals that name their own cause (an absent trigger, an unsupported resource type, a structurally malformed resource), each of which still returns its own code.

Both codes carry an `error` severity, a v2 location and a FHIR path, and no value, in keeping with the value-free diagnostic contract. Callers that branch on severity will now see these two, which is the intent: an emitted message missing a v2-required field is not conformant, and the diagnostic channel is where that is said.
22 changes: 19 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,26 @@ as a trap is clinical-safety content.

## Status

- **Phases 1–6 shipped**: datatype converters + diagnostic channel, ADT/ORU/ORM-OML/RXO/VXU/SIU/MDM
message graphs, and the IG value-ConceptMap translation layer. Phases **7 (FHIR→v2)** and
**8 (profiles)** and deeper terminology are deferred. Full per-phase inventory:
- **Phases 1-6 shipped**: datatype converters + diagnostic channel, ADT/ORU/ORM-OML/RXO/VXU/SIU/MDM
message graphs, and the IG value-ConceptMap translation layer. Full per-phase inventory:
`documentation/agent-notes.md#shipped-phase-history-phases-16`.
- **Phase 7 (FHIR→v2) shipped NARROWLY, and the narrowness is the point**: `toV2Patient` and
`toV2Observation` emit a **complete** v2 message (`ADT^<trigger>` + PID, `ORU^<trigger>` + OBX)
from the subset of the IG segment maps whose **inverse is one-to-one**. The **trigger is a required
argument on every entry point** and is never inferred: no FHIR resource carries one.
**▶ THE IG PUBLISHES NO FHIR-TO-V2 MAP**, so a many-to-one forward row has no usable inverse and is
refused, never resolved to its most likely source code; and **round-trip is asserted only as
"parses back", never as "equals"**. **▶ AND ABSENT IS NOT THE SAME AS SILENT**: a v2-REQUIRED field
the resource gives no source for (PID-3, PID-5, OBX-11) stays absent and RAISES
`TRANSFORM_V2_REQUIRED_FIELD_ABSENT`, and a conversion that grounds no field at all raises
`TRANSFORM_NO_V2_MESSAGE_EMITTED` instead of returning an empty success. **The usage cells behind
those rows are asserted, NOT extracted** (the pass that wrote them had no network egress), so
re-extract before trusting or widening them. The `Patient` + `Encounter` visit-carrying ADT is
**deferred, not dropped**: the vendored parser exports no ADT assembly entry point (measured, zero
occurrences in its `dist/`), and hand-assembling PID + PV1 here would invert the tier split. Every
measurement, the refusal set, and the deferral:
`documentation/agent-notes.md#the-reverse-direction-and-what-it-does-not-claim`. Phase **8
(profiles)** and deeper terminology remain deferred.
- **Never quote a version here.** This line read "not yet published to npm" for several releases
after first publish, which is part of why a `VERSION` constant stuck at `"0.0.0"` shipped unnoticed.
Derive it: `npm view @cosyte/transform version`.
Expand Down
54 changes: 52 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh
> via `toFhir(msg)`, and **terminology value translation** of coded fields: route/site,
> appointment type, order priority, and substitution are now value-translated through their IG
> `mappedVia` ConceptMaps via `toFhirCodeableConceptVia`, fail-safe on any code the IG leaves unmapped.
> The v2→FHIR direction is feature-complete for the IG-covered message set; deeper
> terminology, profiles, and the reverse FHIR → v2 direction are not implemented.
> The v2→FHIR direction is feature-complete for the IG-covered message set. It also ships a
> **narrow reverse path**, FHIR → v2: `toV2Patient(patient, trigger)` and
> `toV2Observation(observation, trigger)` emit a complete v2 message carrying a `PID` or an `OBX`.
> Deeper terminology, profiles, and any wider FHIR → v2 conversion are not implemented.

## Install

Expand Down Expand Up @@ -134,6 +136,54 @@ its `(unmapped)` group is flagged, never coerced to a neighbour. Fields whose IG
(RXR-4 method, SCH-7 reason) stay structural, because SNOMED is not bundled (BYO ConceptMap), and fields the
IG ships no value map for (TXA-2 document type, RXA-5 vaccine code) are carried as-is, never invented.

## Emit v2 back out, narrowly

Two entry points go the other way, FHIR → v2. Each takes the FHIR resource **plus the v2 trigger you
want the message to carry**, and returns the same `{ value, issues }` envelope, where `value` is a
complete `@cosyte/hl7` message:

| Function | in | out |
| --------------------------------------- | ------------------ | ------------------------------------------ |
| `toV2Patient(patient, trigger)` | FHIR `Patient` | a v2 `ADT^<trigger>` message with a `PID` |
| `toV2Observation(observation, trigger)` | FHIR `Observation` | a v2 `ORU^<trigger>` message with an `OBX` |

```ts
import { parseResource } from "@cosyte/fhir";
import { toV2Patient } from "@cosyte/transform";

const { resource } = parseResource(patientJson);
const { value, issues } = toV2Patient(resource, "A28", {
assigningAuthorities: { "urn:oid:1.2.840.114350": "HOSP" },
envelope: { sendingApp: "EHR", sendingFacility: "MAIN" },
});
// value.toString() -> "MSH|^~\\&|EHR|MAIN|...|ADT^A28|...\rPID|||MRN1||Public^Jane\r"
```

**The trigger is required and is never inferred.** No FHIR resource carries an HL7 v2 message
trigger, so there is nothing to derive one from: supply it, or the call returns no message and one
`TRANSFORM_MISSING_TRIGGER` diagnostic, without building anything.

**This direction is lossy by design, and it is not a round-trip.** The published mapping guide runs
v2 → FHIR, and several of its rows are many-to-one, so their inverse is ambiguous and is **refused**:
`gender` `other`, name use `official` and `temp`, address use `work`, every `Address.type`, and
`Observation.status` `entered-in-error` each leave their v2 field absent with a
`TRANSFORM_CODE_NOT_INVERTIBLE` diagnostic rather than picking one of the v2 codes that could have
produced them. An element with no v2 field in this narrow map is flagged
(`TRANSFORM_NO_V2_TARGET`), a value v2 cannot carry unchanged is flagged and left out
(`TRANSFORM_VALUE_NOT_REPRESENTABLE`), and a coding system with no v2 mnemonic is flagged rather than
written under a borrowed table (`TRANSFORM_CODE_SYSTEM_NOT_V2`). Nothing here reconstructs the
message a resource came from, and nothing claims to.

**What v2 requires but your resource does not carry is left absent, and said out loud.** A `PID`
needs PID-3 (Patient Identifier List) and PID-5 (Patient Name); an `OBX` needs OBX-11 (Observation
Result Status). A resource that gives no source for one of them still gets a message with that field
absent, never a placeholder invented to satisfy v2 structure, and one
`TRANSFORM_V2_REQUIRED_FIELD_ABSENT` diagnostic per field, carrying the v2 location and the FHIR path
it would have come from. A resource that grounds no field of the target segment at all returns no
message and one `TRANSFORM_NO_V2_MESSAGE_EMITTED`, so an empty-handed conversion is never mistaken
for a successful one. Both are `error` severity: an emitted message missing a field v2 requires is
not conformant, and this is where you find that out rather than at the receiver.

## License

MIT © Cosyte
68 changes: 68 additions & 0 deletions docs-content/guides-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,74 @@ toFhirDateTime(parseDtm("20260721143000"), { assumeTimezoneOffsetMinutes: -300 }
// => { value: "2026-07-21T14:30:00-05:00", issues: [ TRANSFORM_TIMESTAMP_NO_TIMEZONE ] }
```

## Emit a v2 message from a FHIR resource

The reverse path is narrow on purpose: a `Patient` becomes an `ADT`-shaped message carrying a `PID`,
an `Observation` becomes an `ORU`-shaped message carrying an `OBX`. **You supply the trigger.** No
FHIR resource carries an HL7 v2 message trigger, so there is nothing to infer one from, and an
absent one returns no message plus a `TRANSFORM_MISSING_TRIGGER` diagnostic.

```ts runnable
import { toV2Patient, ISSUE_CODES } from "@cosyte/transform";
import { parseResource } from "@cosyte/fhir";

const { resource } = parseResource('{"resourceType":"Patient","gender":"female"}');

const emitted = toV2Patient(resource, "A28");
emitted.value?.toString().includes("ADT^A28"); // => true

const refused = toV2Patient(resource, "");
refused.value; // => undefined
refused.issues[0]?.code === ISSUE_CODES.TRANSFORM_MISSING_TRIGGER; // => true
```

The direction is **lossy by design and not a round-trip**. The mapping guide runs v2 to FHIR, and
several of its rows are many-to-one, so their inverse is ambiguous: `gender` `other` could have come
from three different v2 codes, so it is refused rather than resolved to one of them.

```ts runnable
import { toV2Patient, ISSUE_CODES } from "@cosyte/transform";
import { parseResource } from "@cosyte/fhir";

const { resource } = parseResource('{"resourceType":"Patient","gender":"other"}');
const { issues } = toV2Patient(resource, "A28");

issues[0]?.code === ISSUE_CODES.TRANSFORM_CODE_NOT_INVERTIBLE; // => true
issues[0]?.v2Location; // => "PID.8"
```

The same rule covers what v2 requires and your resource does not carry. A `PID` needs PID-3 (Patient
Identifier List) and PID-5 (Patient Name); an `OBX` needs OBX-11 (Observation Result Status). None of
them has a safe default, so the field is left absent rather than padded with an invented value, and
its absence is reported: check the issues before you send the message.

```ts runnable
import { toV2Patient, ISSUE_CODES } from "@cosyte/transform";
import { parseResource } from "@cosyte/fhir";

const { resource } = parseResource('{"resourceType":"Patient","identifier":[{"value":"MRN1"}]}');
const { value, issues } = toV2Patient(resource, "A28");

// The message is emitted, with the required name field absent rather than fabricated.
value?.toString().includes("PID|||MRN1"); // => true
issues[0]?.code === ISSUE_CODES.TRANSFORM_V2_REQUIRED_FIELD_ABSENT; // => true
issues[0]?.v2Location; // => "PID.5"
```

If nothing in the resource grounds a single field of the target segment, there is no message at all,
and that is reported too rather than returned as an empty success.

```ts runnable
import { toV2Patient, ISSUE_CODES } from "@cosyte/transform";
import { parseResource } from "@cosyte/fhir";

const { resource } = parseResource('{"resourceType":"Patient"}');
const { value, issues } = toV2Patient(resource, "A28");

value; // => undefined
issues[0]?.code === ISSUE_CODES.TRANSFORM_NO_V2_MESSAGE_EMITTED; // => true
```

## Planned guides

Not yet written: assembling a full `Patient`/`Encounter`/`Observation` graph from a message,
Expand Down
6 changes: 4 additions & 2 deletions docs-content/intro.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ grounded on the official **HL7 Version 2 to FHIR** Implementation Guide (`hl7.fh
> Immunization**, **SIU_S12 → Appointment**, and **MDM_T02 → DocumentReference**, plus
> **terminology value translation** of coded fields: route/site, appointment type, order
> priority, and substitution translated through their IG `mappedVia` ConceptMaps. The
> v2→FHIR direction is feature-complete for the IG-covered message set; deeper terminology, profiles,
> and the reverse FHIR → v2 direction are not implemented.
> v2→FHIR direction is feature-complete for the IG-covered message set. A **narrow reverse path**
> also ships, FHIR → v2: `toV2Patient(patient, trigger)` and `toV2Observation(observation, trigger)`
> emit a complete v2 message carrying a `PID` or an `OBX`, lossy by design and never a round-trip.
> Deeper terminology, profiles, and any wider FHIR → v2 conversion are not implemented.

## The fail-safe promise

Expand Down
22 changes: 20 additions & 2 deletions docs-content/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,26 @@ resource values; those carry PHI.
ORU^R01 → DiagnosticReport + Observation, ORM_O01 / OML_O21 → ServiceRequest and
RXO → MedicationRequest, and the thin IG singles VXU_V04 → Immunization, SIU_S12 →
Appointment, and MDM_T02 → DocumentReference. The v2→FHIR direction is
feature-complete for the IG-covered message set; terminology depth, profiles, and the reverse
FHIR → v2 direction are not implemented.
feature-complete for the IG-covered message set; terminology depth and profiles are not
implemented.
- **Reverse (FHIR → v2) scope: two shapes, deliberately.** `toV2Patient` emits an `ADT`-shaped
message carrying a `PID`, `toV2Observation` an `ORU`-shaped message carrying an `OBX`. Both
require the caller to pass the v2 trigger (no resource carries one, so it is never inferred: a
missing one returns no message and a `TRANSFORM_MISSING_TRIGGER` diagnostic). The direction is
**lossy by design and not a round-trip**: a mapping row whose inverse is ambiguous is refused
(`TRANSFORM_CODE_NOT_INVERTIBLE`), an element with no v2 field in this map is flagged
(`TRANSFORM_NO_V2_TARGET`), and a value v2 cannot carry unchanged is left out
(`TRANSFORM_VALUE_NOT_REPRESENTABLE`). Emitting a `Patient` **and** an `Encounter` together as a
visit-carrying ADT is not implemented.
- **An emitted message can be missing a field v2 requires, and it tells you so.** PID-3 (Patient
Identifier List), PID-5 (Patient Name) and OBX-11 (Observation Result Status) are required fields
with no safe default: a resource that carries no source for one leaves it absent, never a
fabricated placeholder, and raises one `TRANSFORM_V2_REQUIRED_FIELD_ABSENT` per field naming the v2
location and the FHIR path it would have come from. Supply the missing element on the resource, or
repair the message before you send it. If nothing in the resource grounds any field of the target
segment, there is no message to repair: the call returns `value: undefined` and one
`TRANSFORM_NO_V2_MESSAGE_EMITTED`, which is how an empty-handed conversion is told apart from a
successful one.
- **Thin-IG-single scope**: each family covers the single trigger the IG maps and the
resource-internal fields; references to resources this tier does not yet build (Immunization
performer/manufacturer/location, Appointment practitioner/location participants, DocumentReference
Expand Down
Loading
Loading