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
Open
S0013-transform-advance: a narrow FHIR to v2 reverse path, with the trigger always caller-supplied#38NSchatz wants to merge 2 commits into
NSchatz wants to merge 2 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Spec:
work/specs/S0013-transform-advance/spec.md(umbrella), tiersensitive.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
Patientwith neitheridentifiernornameemitted anADTwhosePIDcarried neither PID-3 nor PID-5 with an emptyissuesarray; anObservationwith nostatusemitted anOBXwith 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.flagUnmappediterates 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.tspreviously asserted an emptyissuesarray 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 aboveRequiredV2Fieldsays 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-300106, PID-500108), 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/reverse97.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
toV2Patient(patient, trigger, options?)PatientADT^<trigger>message carrying aPIDtoV2Observation(observation, trigger, options?)ObservationORU^<trigger>message carrying anOBXBoth return the existing
{ value, issues }envelope, with an@cosyte/hl7message as the value. Each is built withbuildMessage({ 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
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_TRIGGERbefore any builder call; a string that is not a bare trigger (whitespace, or a delimiter that would split MSH-9 into further components) returnsTRANSFORM_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.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.
invertCodeMapkeeps a target only when exactly one v2 code produces it; the many-to-one rows are refused withTRANSFORM_CODE_NOT_INVERTIBLErather than resolved to their likeliest source: genderother, name useofficialandtemp, address usework, everyAddress.type, andObservation.statusentered-in-error. The property suite asserts that every emitted message parses back underparseHL7without 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.The refusal codes are
ISSUE_CODES, notFATAL_CODES. The split between the registries is structural in this package: aFatalCodeis the type carried by a thrown error,TransformIssue.codeis typedIssueCode, andISSUE_REGISTRYis exhaustive overISSUE_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.What v2 requires and the resource cannot supply is declared, never defaulted. An OBX-11 defaulted to
Fwould 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+Encountervisit-carrying ADT is not implemented. The@cosyte/hl7this repository builds and tests against exports no ADT assembly entry point (measured on the installed package: zero occurrences in bothdist/index.d.tsanddist/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: cleanpnpm test:coverage: 504 passed / 29 files;src/reverseat 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 nophi-scan-overrides.mdbypass: every fixture reuses tokens already declared syntheticpnpm check:no-emdash,pnpm check:no-internal-refs,pnpm check:agent-notes: cleanpnpm build+pnpm attw: cleanFixture 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.tsfuzzes 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-overviewwith four runnable examples,troubleshooting),CLAUDE.mdStatus anddocumentation/agent-notes.mdall 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.