Skip to content

S0013-transform-advance: a narrow FHIR to v2 reverse path, with the trigger always caller-supplied - #38

Open
NSchatz wants to merge 2 commits into
mainfrom
sdd/S0013-transform-advance
Open

S0013-transform-advance: a narrow FHIR to v2 reverse path, with the trigger always caller-supplied#38
NSchatz wants to merge 2 commits into
mainfrom
sdd/S0013-transform-advance

Conversation

@NSchatz

@NSchatz NSchatz commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Spec: work/specs/S0013-transform-advance/spec.md (umbrella), tier sensitive.

Ships the reverse direction (FHIR to HL7 v2) for two of the three shapes the phase scoped, and defers the third with a dated rationale.

Follow-up commit: absent is the right wire, silent is not (e0847a3)

The impl gate refuted the first commit on one blocking finding, and this second commit is the fix for exactly it. The first commit satisfied half of the phase's own rule: a v2-required field the FHIR resource gave no source for was left absent, which is correct, and raised nothing, which is not. A Patient with neither identifier nor name emitted an ADT whose PID carried neither PID-3 nor PID-5 with an empty issues array; an Observation with no status emitted an OBX with no OBX-11 the same way; and a resource that grounded no field at all returned { value: undefined, issues: [] }, indistinguishable from a successful empty conversion. flagUnmapped iterates the elements a resource carries, so a wholly absent element could never raise anything.

Two issue codes are added, additions only (no key in either registry renamed or removed):

  • TRANSFORM_V2_REQUIRED_FIELD_ABSENT, one per required field that ends up absent from an emitted segment, carrying the v2 location and the FHIR path it would have been sourced from.
  • TRANSFORM_NO_V2_MESSAGE_EMITTED, when nothing grounded a single field of the target segment and no message is built. Distinct from the refusals that name their own cause (absent trigger, unsupported resource, malformed resource), each of which still returns its own code.

This is diagnostics only: no emitted segment content changes. Both reverse suites now pin the exact wire byte for byte for a shape whose required field is absent, so no placeholder, empty component, reordering or extra field can ride along under this contract. test/reverse/patient.test.ts previously asserted an empty issues array on a resource with no name, which pinned the silence; that assertion is retired and asserts the PID-5 diagnostic instead.

Each shape declares its required rows (PID_REQUIRED, OBX_REQUIRED). The usage cells behind those rows are asserted, not extracted, and the banner above RequiredV2Field says so: this pass had no network egress and could not open the Chapter 3 3.4.2 or Chapter 7 7.4.2 attribute tables. The rule the field-number corroboration left behind is therefore kept literally: an item number appears only where this repository has already extracted one (PID-3 00106, PID-5 00108), and the two OBX rows carry none. OBX-2, OBX-4 and OBX-5 are conditional rather than required and are deliberately not declared.

Gates re-run on the follow-up commit, all green, nothing loosened: typecheck, lint, format:check, test (504 passed / 29 files), test:coverage (src/reverse 97.8% statements, 93.02% branches, 100% functions, 97.81% lines; per-directory gate is 90), phi-scan (no hits, still no allow-list entry and no override), check:no-emdash, check:no-internal-refs, check:agent-notes, build + attw.

What ships

entry point in out
toV2Patient(patient, trigger, options?) FHIR Patient a complete ADT^<trigger> message carrying a PID
toV2Observation(observation, trigger, options?) FHIR Observation a complete ORU^<trigger> message carrying an OBX

Both return the existing { value, issues } envelope, with an @cosyte/hl7 message as the value. Each is built with buildMessage({ type: "<code>^<trigger>" }).addSegment(...), so the output is always a whole MSH-led message, never a bare segment (a bare segment is not parseable HL7).

The load-bearing decisions

  1. The trigger is a required argument and is never inferred. No FHIR resource carries an HL7 v2 message trigger. Missing, empty or non-string returns TRANSFORM_MISSING_TRIGGER before any builder call; a string that is not a bare trigger (whitespace, or a delimiter that would split MSH-9 into further components) returns TRANSFORM_VALUE_NOT_REPRESENTABLE, because it could not be carried into MSH-9.2 verbatim and trimming it would emit something the caller did not ask for. Both refusals were found by the fuzz suite.

  2. Lossy by construction, and 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. invertCodeMap keeps a target only when exactly one v2 code produces it; the many-to-one rows are refused with TRANSFORM_CODE_NOT_INVERTIBLE rather than resolved to their likeliest source: gender other, name use official and temp, address use work, every Address.type, and Observation.status entered-in-error. The property suite asserts that every emitted message parses back under parseHL7 without a fatal error and carries the caller's trigger verbatim in MSH-9. It never asserts equality with any original, and no shipped text claims one.

  3. The refusal codes are ISSUE_CODES, not FATAL_CODES. The split between the registries is structural in this package: a FatalCode is the type carried by a thrown error, TransformIssue.code is typed IssueCode, and ISSUE_REGISTRY is exhaustive over ISSUE_CODES. Nothing here throws, so every refusal is a returned issue. Nine codes are added across the two commits, additions only; no existing key in either registry is renamed or removed.

  4. What v2 requires and the resource cannot supply is declared, never defaulted. An OBX-11 defaulted to F would report a result as final that the sender never called final, which is the confidently wrong value this library exists to refuse. The field stays absent and the diagnostic channel carries the fact.

Deferred, with a written rationale

The Patient + Encounter visit-carrying ADT is not implemented. The @cosyte/hl7 this repository builds and tests against exports no ADT assembly entry point (measured on the installed package: zero occurrences in both dist/index.d.ts and dist/index.mjs), and hand-assembling that message structure inside the transformation tier would invert the tier split ADR 0001 draws. Rationale, and what to re-measure before picking it up: documentation/decisions/0003-*.md.

Evidence

All gates green on the branch, nothing loosened or bypassed:

  • pnpm typecheck, pnpm lint (--max-warnings=0), pnpm format:check: clean
  • pnpm test:coverage: 504 passed / 29 files; src/reverse at 97.8% statements, 93.02% branches, 100% functions (per-directory gate is 90)
  • pnpm phi-scan: no hits, with no allow-list entry added and no phi-scan-overrides.md bypass: every fixture reuses tokens already declared synthetic
  • pnpm check:no-emdash, pnpm check:no-internal-refs, pnpm check:agent-notes: clean
  • pnpm build + pnpm attw: clean

Fixture suites cover the happy path, both trigger refusals, the unsupported-resource and structurally-malformed paths for each shape, every flagged non-representable case, each v2-required field absent on its own and together, the nothing-mappable case on both entry points, and a byte-for-byte wire pin per shape. test/reverse/property.test.ts fuzzes both entry points (300 runs each) for never-throw, registered and value-free diagnostics, parses-back, and trigger-verbatim.

Docs

Changeset, README.md, docs-content/ (intro, guides-overview with four runnable examples, troubleshooting), CLAUDE.md Status and documentation/agent-notes.md all name the new surface, its narrow scope, its lossy-by-design limits, the required trigger, and the two new declarations. No internal identifiers on any public-facing surface.

…ed (S0013-transform-advance)

Adds the reverse direction, deliberately narrow: `toV2Patient(patient, trigger)`
emits a complete `ADT` message carrying a PID, `toV2Observation(observation,
trigger)` a complete `ORU` message carrying an OBX. Both return the existing
`{ value, issues }` envelope, with an `@cosyte/hl7` message as the value.

The trigger is a required argument on both entry points and is never inferred:
no FHIR resource carries an HL7 v2 message trigger. Missing, empty or non-string
returns TRANSFORM_MISSING_TRIGGER with no builder call at all; a string that is
not a bare trigger returns TRANSFORM_VALUE_NOT_REPRESENTABLE rather than being
trimmed into something the caller did not ask for.

Lossy by construction and not a round-trip. The IG maps v2 to FHIR and publishes
no map the other way, so every row here is an inverse, and `invertCodeMap` keeps
only the rows exactly one v2 code produces. The many-to-one rows are refused with
TRANSFORM_CODE_NOT_INVERTIBLE: gender `other`, name use `official` and `temp`,
address use `work`, every `Address.type`, and status `entered-in-error`. The
property suite asserts every emitted message parses back under `parseHL7` without
a fatal error and carries the trigger verbatim in MSH-9; nothing asserts equality
with any original.

Seven ISSUE_CODES entries are added, additions only. They are issue codes rather
than fatal codes because they are returned, never thrown, which is the structural
line the diagnostics module draws between the two registries.

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 (measured: zero occurrences in its dist), and
hand-assembling that message structure here would invert the tier split ADR 0001
draws.
…to emit (S0013-transform-advance)

The reverse direction satisfied half of its own rule. A v2-required field the
FHIR resource gave no source for was left ABSENT, which is right, and raised
NOTHING, which is not: a Patient with neither identifier nor name emitted an ADT
whose PID had no PID-3 and no PID-5 with an empty issues array, an Observation
with no status emitted an OBX with no OBX-11 the same way, and a resource that
grounded no field at all returned value undefined with issues [], which a caller
cannot tell apart from a successful empty conversion. flagUnmapped iterates the
elements a resource CARRIES, so a wholly absent element could never raise a
thing.

Two issue codes, additions only, no key renamed or removed:

- TRANSFORM_V2_REQUIRED_FIELD_ABSENT, once per required field that ends up
  absent from an emitted segment, carrying the v2 location and the FHIR path it
  would have come from.
- TRANSFORM_NO_V2_MESSAGE_EMITTED, when nothing grounded a single field and no
  message is built. Distinct from the refusals that name their own cause
  (absent trigger, unsupported resource, malformed resource), which still return
  theirs.

DIAGNOSTICS ONLY: no emitted segment content changes. Both suites pin the exact
wire byte for byte for a shape whose required field is absent, so no placeholder,
empty component, reordering or extra field can ride along under this contract.
test/reverse/patient.test.ts asserted issues was empty on a resource with no
name, which pinned the silence; that assertion is retired and now asserts the
PID-5 diagnostic instead.

Each shape declares its required rows (PID_REQUIRED, OBX_REQUIRED). The usage
cells behind them are ASSERTED, NOT EXTRACTED, and the banner above
RequiredV2Field says so: this pass had no network egress and could not open
Chapter 3 3.4.2 or Chapter 7 7.4.2. So the rule the field-number corroboration
left behind is kept literally, an item number appears only where this repository
already extracted one (PID-3 00106, PID-5 00108) and the OBX rows carry none.
OBX-2, OBX-4 and OBX-5 are conditional rather than required and are deliberately
not declared.

Gates: typecheck, lint, format:check, test (504), test:coverage (reverse 97.8 /
93.02 / 100 / 97.81, gate 90), phi-scan, check:no-emdash,
check:no-internal-refs, check:agent-notes, build + attw, all green. No gate
loosened.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant