@cosyte/x12 0.0.15 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,162 @@ model.
16
16
 
17
17
  ## Data / decode boundaries
18
18
 
19
+ - **🩺 `parseTA1`'s five decoded fields are POST-`?`-unescape as of this release, and an EMPTY
20
+ TA1-02 / TA1-03 / TA1-04 / TA1-05 is REFUSED on emit. Both are behaviour changes**
21
+ (`X12-TA1-RESIDUALS`). They are one slice because they are the two ends of the same disagreement:
22
+ this package's emit half releases a TA1 element and its read half decoded the escape rather than
23
+ the value.
24
+
25
+ **The read half.** `X12-TA1-EMIT-NOT-RELEASE-AWARE` made `buildTA1` release all five caller
26
+ elements, and `parseTA1` kept reading `elements` verbatim, so a round trip through this package's
27
+ own halves was not an identity for any value carrying a delimiter or the release character.
28
+ Measured at `0.0.15`, over `parseX12` + `parseTA1` of what `buildTA1` had just emitted:
29
+
30
+ ```text
31
+ interchangeControlNumber raw emitted parseTA1 read dot-path read
32
+ "00000001?" TA1*00000001??*260601*1200*A*000 "00000001??" "00000001?"
33
+ "0000*0001" TA1*0000?*0001*260601*1200*A*000 "0000?*0001" "0000*0001"
34
+ "0000~0001" TA1*0000?~0001*260601*1200*A*000 "0000?~0001" "0000~0001"
35
+ "0000:0001" TA1*0000?:0001*260601*1200*A*000 "0000?:0001" "0000:0001"
36
+ "0000^0001" TA1*0000?^0001*260601*1200*A*000 "0000?^0001" "0000^0001"
37
+ ```
38
+
39
+ Every row `warnings: []`. TA1-01 is the reassociation key, so the left column is a key that
40
+ matches no ISA-13. The right column is the same element. The grounding is that
41
+ disagreement and nothing else - no clause anyone here has read settles what a TA1 element may
42
+ contain.
43
+
44
+ **What that costs, stated rather than argued away.** A consumer who was applying `unescapeRelease`
45
+ to `parseTA1`'s output themselves - the remedy this file used to prescribe - now has the library
46
+ doing it, so **drop the hand-rolled call**. And a value with no `?` in it is unchanged on both
47
+ surfaces, which is every conformant TA1. **`raw` is untouched and is still the verbatim byte
48
+ surface**: read `ta1.raw.elements` when you want the bytes.
49
+
50
+ **The emit half.** `escapeRelease` early-returns on `""` and `buildTA1` carried a required-field
51
+ guard for TA1-01 alone, so the other four slots emitted an absent element with `warnings: []`:
52
+ `interchangeDate: ""` gave `TA1*000000001**1200*A*000`, `interchangeTime: ""` gave
53
+ `TA1*000000001*260601**A*000`, `ackCode: ""` gave `TA1*000000001*260601*1200**000` and
54
+ `noteCode: ""` gave `TA1*000000001*260601*1200*R*`. **The item filed two of those four.** Each now
55
+ draws `AckBuildError` / `X12_ACK_INVALID_SPEC` naming the slot and the spec property and never the
56
+ value. **No new error code was minted and no warning code moved.** The grounding is again inside
57
+ the package: `BuildTA1Spec` declares all five properties as required `string`s, `""` is what
58
+ defeats that at run time, and the in-package answer to an empty required element is uniformly
59
+ refusal (`patientControlNumber`, `claimId`, `maintenanceTypeCode`, `requestCategoryCode`, the
60
+ 277's `categoryCode`, and TA1-01 itself).
61
+ - **🛑 It does NOT trim, at any of the five slots.** A whitespace-only element still builds -
62
+ `TA1*000000001*260601*1200*R* ` is still emitted for `noteCode: " "`. Trimming is a
63
+ normalisation rule and no source consulted for this package states one, exactly as at TA1-01.
64
+ - **🛑 It does not narrow what a NON-empty element may contain, and the read half stays lenient.**
65
+ An out-of-enum `ackCode: "X"` still builds and still reads back as `R` through the documented
66
+ fail-safe narrow - the same answer an empty one gave. This guard bounds ABSENCE on the emit side
67
+ and nothing else.
68
+ - **🛑 Every guard that stood before it keeps its precedence.** `enforceAcceptIsClean` still runs
69
+ first, so an `ackCode: "A"` with an empty note still reports `X12_TA1_ACCEPT_WITH_NOTE`; TA1-01
70
+ still draws the control-number refusal; and every wrong-TYPED element still draws the escape
71
+ helper's refusal, because all five escapes run before any emptiness test. **No spec that was
72
+ refused before this slice is refused differently by it.**
73
+ - **No census of other builders' required elements is published here.** This slice measured TA1.
74
+
75
+ - **🩺 `implementationConventionReference` is POST-`?`-unescape as of this release, in every typed
76
+ reader that publishes it, and that is a behaviour change on documents whose `ST-03` carries a
77
+ release escape** (`X12-ST03-READ-NOT-RELEASE-AWARE`). `tx.st.elements` is the ST segment as framed:
78
+ post-element-split and PRE-unescape. Five public
79
+ readers were handing one of those strings straight back on the model - `get837Claims`,
80
+ `get277Status`, `get277CADisposition`, `get278Request` and `get278Response` - so a sender that
81
+ escaped a delimiter inside `ST-03` got the escape rather than the value it stated. `parse999` has
82
+ always decoded the identically-named `AK2-03`, and every dot-path read already unescaped; that
83
+ disagreement inside this package is the whole grounding, and no TR3 clause is claimed. The cells
84
+ that were run, on the ST-03 element text a sender framed: `A??B` now publishes `A?B`, `A?*B`
85
+ publishes `A*B`, `A?:B` publishes `A:B`, `A?~B` publishes `A~B`, `A?^B` publishes `A^B`. Every one
86
+ of those published the framed bytes before.
87
+ - **🛑 What decides an outcome did NOT move, deliberately.** The 837 variant lookup, the 277 /
88
+ 277CA `transactionType` discriminator and `get277CADisposition`'s admission gate all still key on
89
+ the RAW element text, so no document changes variant, discriminator or admission because of this
90
+ entry. That matters because the two can differ: with `componentSeparator: "X"` - a letter is an
91
+ admissible delimiter - an `ST-03` framed as `005010?X222A1` decodes to `005010X222A1`, an
92
+ identifier the variant table holds. Keying on the decoded text would make the declaration beat
93
+ the `SVx` fallback there, which on a document whose only service segment is an `SV2` stops that
94
+ line decoding and raises `X12_837_SERVICE_LINE_NOT_DECODED`. That is a change to how an already
95
+ published document decodes a service line rather than a decode fix, and it is **open**, not
96
+ taken here. **The difference is one-way: no document that resolved or was admitted before stops
97
+ doing so**, because no identifier any of the three tests is keyed on contains a delimiter or the
98
+ release character, so raw text equal to one decodes to itself.
99
+ - **🛑 The published reference can name a guide this reader did NOT resolve to, and nothing warns
100
+ about the divergence.** On the `componentSeparator: "X"` document above,
101
+ `submission.implementationConventionReference` reads `005010X222A1`, which the variant table
102
+ holds, while `submission.variant` is `I` from the `SVx` fallback. **"Nothing warns" is NOT the
103
+ boundary of it:** on the same delimiters a body with no `SVx` publishes `005010X222A1` with
104
+ `variant: "unknown"` and raises `X12_837_UNKNOWN_VARIANT`, and a body naming more than one
105
+ variant publishes it with `variant: "P"` and raises `X12_837_AMBIGUOUS_VARIANT` - each time the
106
+ code that fired says `ST-03` named no identifier this reader recognises while the model field
107
+ holds one it does. **`X12_837_UNKNOWN_VARIANT`'s closing pointer at the model is therefore
108
+ deleted.** **Through `0.0.15` the published value WAS the keyed value, so the model could not
109
+ disagree with itself. Gate on `variant` / `transactionType`, never on the published reference.**
110
+ - **🛑 It introduces no normalisation and no new warning.** Nothing is trimmed, case-folded or
111
+ prefix-matched; a whitespace-only `ST-03` is still published untrimmed. A dangling `?` at the end
112
+ of the element still raises no `X12_DANGLING_RELEASE_CHAR` on these readers: the sink is a no-op,
113
+ which is what `getSegmentValue` defaults to and what `parseTA1` and `parse999` do, so every other
114
+ element these readers decode drops it too. That residual is **unchanged and open.**
115
+ - **Each reader's own empty / absent mapping is unchanged.** `walk278` still collapses `""` to
116
+ `undefined`; `get837Claims` and `walk277` still publish `""`. Decoding cannot reach those
117
+ branches differently, because no non-empty element decodes to `""`.
118
+ - **`tx.st.elements` is untouched and is still the verbatim framed surface.** If you were applying
119
+ `unescapeRelease` to `submission.implementationConventionReference` yourself, drop that call.
120
+
121
+ - **🩺 An EMPTY control number is REFUSED on emit as of this release, where it used to be
122
+ FABRICATED, and that is a behaviour change for any caller passing one**
123
+ (`X12-EMPTY-CONTROL-NUMBER-FABRICATED`). Every builder that assembles an ISA zero-pads its control
124
+ number to the nine characters ASC X12 .5 fixes ISA-13 at. `padControl("1", 9)` answering
125
+ `"000000001"` is the point of that; `padControl("", 9)` answering `"000000000"` was not, and
126
+ nothing stood in front of it, so `interchangeControlNumber: ""` produced a frozen, well-formed
127
+ interchange carrying a nine-digit control number nobody supplied, with `warnings: []` and ISA-13
128
+ reconciling perfectly against IEA-02. A control number is how an interchange is reconciled and
129
+ acknowledged, so a fabricated one does not fail: it succeeds against the wrong thing.
130
+
131
+ The other control numbers took the same input and were silent in a different way. They reach the
132
+ wire through the escape helper, which early-returns on `""`, so the required element was lost.
133
+ **The bytes are not the same in every builder**: `buildInterchange` and `build999` join without
134
+ trimming, so the element goes out empty, while the seven domain builders share a segment helper
135
+ that drops a trailing empty element, so the trailer loses it outright. Measured at `0.0.15`,
136
+ through `buildInterchange` and `build834`:
137
+
138
+ ```text
139
+ buildInterchange interchangeControlNumber: "" ISA*…*00501*000000000*0*P*:~ … ~IEA*1*000000000~
140
+ buildInterchange groupControlNumber: "" GS*HC*…*1200**X*005010X222A2~ … ~GE*1*~
141
+ buildInterchange transactionSetControlNumber: ST*837**005010X222A2~ … ~SE*3*~
142
+ build834 groupControlNumber: "" GS*BE*…*1200**X*005010X220A1~ … ~GE*1~
143
+ build834 transactionSetControlNumber: ST*834**005010X220A1~ … ~SE*21~
144
+ ```
145
+
146
+ Every one of those emitted `warnings: []`, which is the property that holds across both families:
147
+ no diagnostic on any channel separated an absent control number from a supplied one. The acknowledgment builders carried the same class at the slots
148
+ where they **echo** the document being acknowledged, which is the whole reason a sender can match
149
+ an ack to what they sent: `build999` emitted `AK1*HC**005010X222A2~` and `AK2*837*~`, and
150
+ `buildTA1` emitted `TA1**260601*1200*A*000`. Every one of these now draws that builder's own typed,
151
+ code-tagged refusal (`X12_BUILD_INVALID_SPEC` and its siblings; `X12_ACK_INVALID_SPEC` on the ack
152
+ path) naming the slot and the spec property, before anything is emitted. **No new error code was
153
+ minted and no warning code moved.** What changes is that a build that used to return a document now
154
+ throws, and that a spec wrong in two ways can now report a different one of the two: see the
155
+ precedence bullet below, which is the qualifier a draft of this entry left out.
156
+ - **🛑 The guard does NOT trim, and a whitespace-only control number is
157
+ still accepted**: `interchangeControlNumber: " "` still emits ISA-13 as `00000000 `, and
158
+ `buildTA1` does no padding at all, so it emits whatever whitespace it was handed, verbatim. This
159
+ is a real residual rather than an oversight. Trimming
160
+ would be a normalisation rule, no source consulted for this package states one, and every
161
+ empty-required-element guard this one mirrors (`patientControlNumber`, `claimId`,
162
+ `maintenanceTypeCode`, `requestCategoryCode`, the 277's `categoryCode`) is byte-strict for the
163
+ same reason. **Validate at your own boundary if your partner can send you blanks.**
164
+ - **A SHORT control number still zero-pads.** The guard is not "ISA-13 must be nine characters":
165
+ `interchangeControlNumber: "1"` still emits `000000001`, which is what `padControl` is for.
166
+ - **🛑 Every guard sits at the envelope-assembly site, so every guard that runs BEFORE it keeps its
167
+ precedence, and that is the whole claim.** `build835`'s balance equation, `build999`'s AK9
168
+ counts, `buildTA1`'s accept-must-mean-accept check, the 837's and 277's hierarchy checks and
169
+ `build834`'s unknown-maintenance-type refusal all still fire ahead of this one. **What it does
170
+ NOT preserve is an ordering against a defect detected LATER**, during body assembly, which now
171
+ reports the control-number refusal instead: `build999` with an empty `interchangeControlNumber`
172
+ and six AK9 syntax error codes threw `X12_ACK_COUNT_MISMATCH` at `0.0.15` and throws
173
+ `X12_ACK_INVALID_SPEC` now. If you branch on a specific builder error code, that pairing moved.
174
+
19
175
  - **🩺 Which `ST-03` implementation-convention references resolve to an 837 variant CHANGED in this
20
176
  release, and some already-published files therefore decode differently** (`X12-VARIANT-ICR-UNGROUNDED`).
21
177
  Through `0.0.13` `get837Claims` recognised exactly three references: `005010X222A2`, `005010X223A3`
@@ -133,27 +289,36 @@ model.
133
289
  here** - what follows is the routes measured to REACH the regression direction, not a closed
134
290
  account of what bypasses the escaper.
135
291
 
136
- **`buildInterchange` does not escape GS-04, GS-05 or GS-07** (`groupDate`, `groupTime`,
137
- `responsibleAgencyCode`). A `groupDate` of `"2026060?"` emits
292
+ **`buildInterchange` did not escape GS-04, GS-05 or GS-07** (`groupDate`, `groupTime`,
293
+ `responsibleAgencyCode`) at this release. A `groupDate` of `"2026060?"` emitted
138
294
  `GS*HC*SENDER*RECEIVER*2026060?*1200*1*X*005010X222A1`, which its own return value read as nine
139
- elements with GS-08 intact through `0.0.14` and reads as **eight** here, GS-08 gone, plus
140
- `X12_CONTROL_NUMBER_MISMATCH`.
295
+ elements with GS-08 intact through `0.0.14` and read as **eight** at `0.0.15`, GS-08 gone, plus
296
+ `X12_CONTROL_NUMBER_MISMATCH`. **Closed by `X12-INTERCHANGE-GS-EMIT-NOT-RELEASE-AWARE`** - that
297
+ entry is at the top of this section and it is the one that describes the current tree.
141
298
 
142
299
  - **An envelope element ending in a literal `?` is a dangling release character and is NOT warned.**
143
300
  `X12_DANGLING_RELEASE_CHAR` fires only for an odd run of `?` at the very END of a segment, so a
144
301
  mid-segment one reaches no check. That is true of body elements too and is unchanged here.
145
302
  - **Values are still RAW, pre-`?`-unescape**, exactly as `X12Segment.elements` has always
146
- documented: `gs.elements[2]` on the first row reads `"SEND?*ER"`, not `"SEND*ER"`. Read through
147
- `getSegmentValue` if you want the logical value. `elements.join(separator)` therefore still
303
+ documented: `gs.elements[2]` on the first row reads `"SEND?*ER"`, not `"SEND*ER"`. For the
304
+ logical value, `unescapeRelease` the element string. `GsSegment` carries no `id`, so
305
+ `getSegmentValue` does not accept a `gs` (`TS2345`); adding an `id` and taking the dot-path is
306
+ the other route, and **the two answer different questions**: a bare dot-path splits on the
307
+ repetition separator and answers repetition 0, so a GS-07 of `"A^B"` reads back `"A"` through it
308
+ and `"A^B"` through `unescapeRelease`, `warnings: []`.
309
+ `elements.join(separator)` therefore still
148
310
  reproduces the segment byte for byte, which is what `serializeX12` relies on when it substitutes
149
311
  a recomputed `SE-01` / `GE-01` / `IEA-01` into a control segment.
150
312
  - **🩺 The ISA is deliberately exempt and stays positional.** ASC X12 .5 makes the ISA fixed-width,
151
313
  which is what lets the delimiters be recovered from it before anything is parsed, so a `?` in an
152
314
  ISA element is content and never an escape. `buildInterchange` states the same rule from the emit
153
315
  side: it pads each ISA element and never escapes one.
154
- - **A degenerate delimiter set whose element separator IS `?` is unchanged.** `?` cannot both
155
- separate and escape, so the splitter falls back to the literal split, the same guard the
156
- segment-terminator scanner already carried.
316
+ - **A degenerate delimiter set whose element separator IS `?` is unchanged by THIS entry.** `?`
317
+ cannot both separate and escape, so `splitElements` falls back to the literal split, the same
318
+ guard the segment-terminator scanner already carried. **Read that as the ENVELOPE splitter only:
319
+ this sentence used to say "the splitter", and `decodeSegment` did NOT carry the guard**, so a
320
+ degenerate interchange framed its envelope correctly and collapsed every BODY segment. The entry
321
+ below closes that half.
157
322
  - **🩺 This does NOT make an UNESCAPED delimiter safe, and nothing here claims it does.** A bare
158
323
  active delimiter in an envelope element still ends that element, because that is what a delimiter
159
324
  is; only the sender escaping it is now honoured. `build837`'s
@@ -163,6 +328,185 @@ model.
163
328
  any envelope field and will still emit all of them, though it now reports back what it wrote -
164
329
  at `0.0.14` it released GS-02 on emit and then answered GS-08 as `"X"` from its own return value.
165
330
 
331
+ - **🩺 A BODY segment in an interchange whose ELEMENT SEPARATOR is `?` now frames its elements,
332
+ where it used to come back as ONE element with an id of `(non-spec)`**
333
+ (`X12-BODY-DEGENERATE-RELEASE-SEPARATOR`). `detectDelimiters` reads the element separator
334
+ positionally out of ISA byte 4 and rejects only control characters, whitespace and a non-distinct
335
+ set, so a sender may declare `?` there, and `buildInterchange` accepted `elementSeparator: "?"`
336
+ from a caller when this was measured (it refuses now - see the emit-side entry below).
337
+ `src/parser/envelope.ts` guarded that degenerate set in both of its own splitters;
338
+ `decodeSegment`, which every BODY segment plus the `ST`, the `SE` and every retained orphan goes
339
+ through, did not. It split with the release-aware splitter, where a `?` consumes the byte after it,
340
+ so no split ever happened. Measured through `parseX12` at `0.0.15`:
341
+
342
+ ```text
343
+ ST?837?0001?005010X222A1 id "(non-spec)", 1 element
344
+ NM1?85?2?ACME CLINIC?????XX?1234567893 id "(non-spec)", 1 element
345
+ SE?3?0001 id "(non-spec)", 1 element
346
+ warnings: []
347
+ ```
348
+
349
+ - **🩺 The envelope framed correctly the whole time, which is what made it silent.** One group,
350
+ one transaction, `GE-01`, `IEA-01`, `GS-06`/`GE-02` and `ST-02`/`SE-02` all reconciling, an empty
351
+ warning array - and a transaction body no reader could see, because every reader in this package
352
+ dispatches on `seg.id`. A consumer got an empty claim list from a well-formed document.
353
+ - **🛑 It changes how an already-published document decodes, deliberately, and the tiebreak is
354
+ CONSISTENCY rather than a spec clause** - the same call the envelope-splitter entry above made.
355
+ 005010 does not transmit a release character at all, so nothing in it says what a `?` means once
356
+ a sender has declared `?` as structure. **What is NOT the same as that entry: the class is not
357
+ symmetric.** A one-element segment with an id of `(non-spec)` is not a second reading of
358
+ `NM1?85?2?ACME CLINIC`; there is no direction in which the old framing was the right one.
359
+ - **No warning code is added and nothing moves onto a new code.** One is SUBTRACTED, in one place:
360
+ `X12_DANGLING_RELEASE_CHAR` fired on any degenerate segment ending in an empty last element,
361
+ because the check keys on a trailing `?`. With `?` as the separator that trailing byte is an
362
+ empty element, not an unpaired escape, so `PER?IC?NAME?TE?5551234?` is silent now.
363
+ - **🛑 The guard is per ROLE, and it is a READ-side guard. A `?` REPETITION or COMPONENT separator
364
+ still does not split, and that is deliberate and measured.** `escapeRelease` writes `??` for a
365
+ literal `?` whatever role `?` was declared in, so documents this library emitted through `0.0.15`
366
+ carry `CLM*PATIENT??ACCT*150.00` and `getSegmentValue(clm, "01")` reads `"PATIENT?ACCT"` back out
367
+ of them. Splitting those two roles literally would re-frame that as two empty components, so it
368
+ would stop reading a value this library itself wrote. **That reason survives the emit-side
369
+ refusal below - those documents exist**, which is why the read side did not move with it.
370
+ - **🟢 CLOSED, in the slice after this one, and WIDER than it was filed
371
+ (`X12-EMIT-DEGENERATE-RELEASE-DELIMITER`): every builder REFUSES a delimiter set in which any of
372
+ the four roles is `?`.** The entry as filed named the element separator and one mechanism - a
373
+ caller VALUE the escape cannot protect, because the protecting `?` is itself the separator. Two
374
+ things were measured wrong about that:
375
+ - **The class is FOUR roles, not three.** The segment terminator does the same thing: a released
376
+ byte inside a value ends the segment early, so a phantom segment appears mid-transaction.
377
+ - **There is a SECOND mechanism, and it needs no caller value at all.** A builder joins
378
+ composites with the component separator and repetitions with the repetition separator, so
379
+ where either is `?` the library's own structural join is emitted as an escape. `build837P` on
380
+ `componentSeparator: "?"` emitted `SV1-01-2` (the procedure code) and `HI-01-2` (the diagnosis
381
+ code) fused into the preceding component **on every document, no trigger byte in any value**,
382
+ `warnings: []`. That is the sharper of the two: a claim is adjudicated on those codes.
383
+
384
+ A value-level mitigation was refused for a measured reason rather than a stylistic one: it
385
+ cannot reach the second mechanism, and _"keep `?` out of your values"_ had already been refuted
386
+ in this arc for protecting nobody. It refuses specs that built at `0.0.15`, some of which
387
+ round-tripped through this library's own parser - **that round trip is not the bar**, because
388
+ ISA-11 and ISA-16 transmit the declared set and a conformant receiver splits on it. Pinned in
389
+ `test/builder-degenerate-release-delimiter.test.ts` and, per builder, in each build suite.
390
+
391
+ - **🩺 A delimiter that is not SHAPED like one is refused on emit too, by every builder.** Each of
392
+ the four roles must be a **string of exactly one visible character**, and the four must be
393
+ **mutually distinct**. That is not a rule invented for emit: it is the predicate
394
+ `detectDelimiters` already applies to an inbound ISA, where failing it is the Tier-3 fatal
395
+ `X12_INVALID_DELIMITERS`. A builder composing a document its own parser refuses to read was
396
+ disagreeing with itself. Nothing is trimmed, coerced or substituted - the set is refused.
397
+
398
+ Three things it closed, and they are not one defect. A **multi-character** delimiter built with
399
+ `warnings: []`: a `segmentTerminator` of `"~~"` put phantom segments on the model that `SE-01`
400
+ never counted, and a `componentSeparator` of `":~"` read back through a well-formed ISA while the
401
+ builder's own terminator became an uncounted empty segment. **No claim is made about which roles
402
+ were affected**; declare one character per role. A **non-string** delimiter was coerced by the join but not by the
403
+ escape, so the document framed on a byte no element value was protected from: an 837 with
404
+ `componentSeparator: 1` read `SV1-01-2` back as `992` rather than the procedure code `99213`,
405
+ `warnings: []`. And **`buildTA1` had no net at all** - it is the one builder with no trailing
406
+ `parseX12`, so EVERY role and EVERY shape was silent there: `elementSeparator: ""` returned
407
+ `TA10000000012606011200A000`, the reassociation key and the disposition fused into one blob, and
408
+ `elementSeparator: "||"` returned `TA1||000000001||260601||1200||A||000`, which inside an ISA
409
+ reads back with `TA1-01` empty and `ackCode: "R"` - an Accept emitted as a Reject.
410
+
411
+ **🛑 Two things change for a caller.** A spec that failed at base with an `X12ParseError` /
412
+ `X12_INVALID_DELIMITERS` escaping out of the `build*` call now refuses earlier with that
413
+ builder's own typed error and its existing code, so a consumer catching the parse class stops
414
+ catching and one catching the build class starts; no code is minted. And a
415
+ `segmentTerminator` of `"~\r\n"` - asking for line-broken output - **built with `warnings: []`
416
+ before and is refused now** (no count of such shapes is published; that is not the only one).
417
+ The check also runs **before** the control-number guards, so a spec that is mis-shaped _and_
418
+ carries an empty control number now reports the delimiter refusal rather than the control-number
419
+ one, on the same code. A message moves; no code does. It never did what it looked like it did: `parseX12` tolerates CR/LF
420
+ between segments, so the model recorded `~` and `serializeX12` emitted no line breaks. Reading a
421
+ file that is written that way is unaffected; only declaring it on emit is.
422
+
423
+ - **🩺 `PRE-EXISTING` and NOT closed by that refusal: it is a UTF-16 CODE-UNIT rule, not a byte
424
+ rule.** A character that is one code unit but several bytes on the wire satisfies it and still
425
+ displaces every ISA position after it, so `componentSeparator: "\u00a7"` builds with
426
+ `warnings: []` and a byte-oriented receiver reads ISA-16 as `0xC2` and the terminator as `0xA7`.
427
+ The smart quote `"\u2019"` a companion-guide PDF gives you instead of `'` does the same.
428
+ Disclosed rather than guarded: the read side counts code units too (`charAt`), so moving one side
429
+ alone would put emit and read back out of step, which is exactly what that refusal exists to
430
+ prevent. **Declare delimiters from the basic single-byte set.**
431
+
432
+ - **🩺 PRE-EXISTING and NOT closed here: on a degenerate set a `?~` still swallows the segment
433
+ terminator.** `findUnescapedTerminator` guards its own role only, so with `?` as the element
434
+ separator a segment that ends in an EMPTY LAST ELEMENT puts a `?` immediately before the
435
+ terminator and the scanner reads it as an escape: `PER?IC?NAME?TE?5551234?EX?~SE?3?0001~` frames
436
+ as ONE segment and raises `X12_MISSING_SE`. This slice does not touch framing. But **the READ of
437
+ that merged blob did move, so do not take "framing is untouched" as "nothing about this residual
438
+ moved"**: at `0.0.15` the merge produced one `(non-spec)` element no walker looked at, and here
439
+ it frames, so `~SE` and the SE's own control number land in `PER`'s communication-number slots.
440
+ `X12_MISSING_SE` still fires, so it is not silent. Both are pinned in
441
+ `test/parser-segment-degenerate-release-separator.test.ts` so they cannot move unnoticed.
442
+ - **Values are still RAW, pre-`?`-unescape**, and `elements.join(separator)` still reproduces the
443
+ segment byte for byte, so `serializeX12`'s count substitution and the byte-exact round trip are
444
+ unaffected. Both are pinned.
445
+ - **The ISA is untouched and stays positional**, for the reason the envelope-splitter entry above
446
+ gives: it is fixed-width, which is what lets the delimiters be recovered from it at all.
447
+
448
+ - **🩺 `buildInterchange` now RELEASES GS-04, GS-05 and GS-07, so the interchange it hands back
449
+ reports the values you passed, and the bytes it writes for such a value CHANGED**
450
+ (`X12-INTERCHANGE-GS-EMIT-NOT-RELEASE-AWARE`). Through `0.0.15` it mapped its release escaper over
451
+ GS-01, GS-02, GS-03, GS-06 and GS-08 and wrote `groupDate` (GS-04), `groupTime` (GS-05) and
452
+ `responsibleAgencyCode` (GS-07) raw. It returns `parseX12` of the bytes it just wrote, so a value
453
+ carrying an active delimiter in one of those three took a slot of its own and shifted every element
454
+ after it down one, inside a single call. Measured on one group with
455
+ `versionRelease: "005010X222A2"` and `groupControlNumber: "1"`:
456
+
457
+ | spec field | read GS-06 | read GS-08 | warnings |
458
+ | ------------------------------ | ---------- | ---------- | ------------------------------------------------------- |
459
+ | `groupDate: "2026*0601"` | `"1200"` | `"X"` | `X12_CONTROL_NUMBER_MISMATCH` |
460
+ | `groupTime: "12*00"` | `"00"` | `"X"` | `X12_CONTROL_NUMBER_MISMATCH` |
461
+ | `responsibleAgencyCode: "X*Y"` | `"1"` | `"Y"` | none |
462
+ | `groupTime: "12~00"` | absent | absent | `X12_UNEXPECTED_SEGMENT`, `X12_CONTROL_NUMBER_MISMATCH` |
463
+ | `groupDate: "20260601?"` | `"X"` | absent | `X12_CONTROL_NUMBER_MISMATCH` |
464
+
465
+ **🩺 The `responsibleAgencyCode` row is the one to know about, because nothing was raised on any
466
+ channel.** GS-06 kept its own slot, so it still reconciled against GE-02 and no control-number
467
+ warning fired; what moved was GS-08, the version / release / industry identifier code. All five
468
+ rows now read the values the caller passed, with an empty warning array.
469
+ - **🛑 It changes bytes, and the bound is the same one the two entries below carry.** A value
470
+ containing none of the four delimiters and no `?` is emitted byte-for-byte as before, which is
471
+ every conformant GS-04 / GS-05 / GS-07. **Read the property rather than a direction list:** the
472
+ interchange the call returns now reports the values you passed, where before it reported whatever
473
+ the shift left in each slot. **What is narrower here than in the two entries below: no reader
474
+ moved.** An inbound document from a trading partner decodes exactly as it did at `0.0.15`; what
475
+ changed is what this library emits.
476
+ - **Read the delimiter set by ROLE, never by byte.** `InterchangeSpec` lets you declare all four,
477
+ so which BYTES shift is a property of the set you declared: with `elementSeparator: "|"` it was a
478
+ GS-07 of `"X|Y"` that took GS-08's slot and `"X*Y"` that was inert. **Only the element separator
479
+ and the segment terminator ever shifted the segment's own framing, plus a `?` immediately before
480
+ the element separator.** The **repetition** and **component** separators moved the dot-path
481
+ reader instead, and releasing them is a **gain** there: the
482
+ composite read `"07-1"` answered `"X"` for `"X:Y"`. **The measured cost is a mid-string `?`,
483
+ and only on the surfaces documented as raw** - `gs.elements[4]` reads `"2026??0601"` where it
484
+ read `"2026?0601"`, while the dot-path read of that value unescapes and is unchanged. No total is
485
+ published: that is what was measured, not a closed account.
486
+ - **A caller who was pre-releasing these values themselves is now escaping twice.** `"2026?*0601"`
487
+ in gives `2026???*0601` out and a dot-path read of `"2026?*0601"` where it read `"2026*0601"`.
488
+ Drop the hand-rolled escape.
489
+ - **A wrong-typed GS element now names its slot.** The type check runs over the unescaped parts, so
490
+ a numeric `groupDate` still refuses with `buildInterchange: "GS"-04 must be a string` rather than
491
+ degrading to the builder-named message the escaper alone would give. The five slots that already
492
+ escaped gained the slot name with it. **`null` and `undefined` in these three fields are ABSENT,
493
+ not refused** - each resolves through a default before either guard sees it.
494
+ - **A LITERAL segment id this library writes is never escaped, and that is a rule rather than an
495
+ omission.** `esc` releases against the delimiter set the CALLER declared, and a
496
+ `componentSeparator` of `"S"` is admissible, so escaping element 0 would turn the literal `"GS"`
497
+ into `G?S` and the group header would stop being a `GS`. `GE`, `ST`, `SE` and `IEA` already
498
+ followed that rule. **Read "literal" strictly, and this one is `PRE-EXISTING` rather than
499
+ anything this release changed:** a `SegmentSpec` body segment is `[segmentId, ...elements]`
500
+ supplied wholesale, `buildInterchange` has released that caller-supplied id since before this
501
+ release, and `SegmentSpec`'s JSDoc says it is emitted verbatim. The two disagree. It is noisy
502
+ rather than silent - a `?`-prefixed id is rejected by the envelope walker as an orphan with
503
+ `X12_UNEXPECTED_SEGMENT` - and which side is wrong is a decision, so it is recorded rather than
504
+ changed here.
505
+ - **What this does NOT close.** `buildInterchange`'s IEA-02 does not go through the escaper: it is
506
+ padded and has to stay byte-equal to the fixed-width ISA-13 it reconciles against, so that is a
507
+ decision of its own. The ISA fixed-width slots are still outside both guards. And an unescaped
508
+ active delimiter is still not safe anywhere, because that is what a delimiter is.
509
+
166
510
  - **🩺 `buildTA1` now RELEASES its five caller-supplied elements, so an Accept acknowledgment this
167
511
  library emits no longer reads back as a Reject, and the bytes it writes for such a value CHANGED**
168
512
  (`X12-TA1-EMIT-NOT-RELEASE-AWARE`). Through `0.0.14` `buildTA1` joined the five values with the
@@ -211,23 +555,26 @@ model.
211
555
  `"0000^0001"`, silently truncating the reassociation key to the first repetition, and answers
212
556
  `"0000^0001"` now; the composite read `"01-1"` answered `"0000"` for `"0000:0001"` and answers
213
557
  the whole value now. **The measured pure cost is a mid-string `?`, and only on the surfaces
214
- documented as raw**: `raw`, `elements` and `parseTA1`'s fields read `"0000??0001"` where they
215
- read `"0000?0001"`, while every dot-path read unescapes and answered `"0000?0001"` on both.
558
+ documented as raw**: `raw` and `elements` read `"0000??0001"` where they read `"0000?0001"`,
559
+ while every dot-path read unescapes and answered `"0000?0001"` on both.
216
560
  No total is published: that is what was measured, not a closed account. (`getSegmentValue`
217
561
  takes an `X12Segment` and `Ta1Segment` carries no `id`, so add one to read a TA1 through it.)
562
+ **🩺 A clause naming `parseTA1`'s fields as a third such surface stood here and is DELETED, not
563
+ reworded** - `X12-TA1-RESIDUALS` made those fields post-unescape, so it is measured false.
218
564
  - **A caller who was hand-rolling the escape** (the remedy this file named while the defect was
219
565
  open) regresses on both kinds of surface: `"00000001??"` in, `TA1*00000001????*…` out, and
220
566
  `getSegmentValue` answering `"00000001??"` where it answered `"00000001?"`. The framing and the
221
567
  disposition stay correct, but **drop the hand-rolled escape.**
222
- - **An EMPTY control number is not refused and never was.** `escapeRelease` early-returns on `""`
223
- and `buildTA1` carries no required-field guard, so `interchangeControlNumber: ""` emits
224
- `TA1**260601*1200*A*000` with `warnings: []`, here and at every earlier release. Only a
225
- NON-string refuses. Unchanged and tracked as its own item.
226
- - **🩺 The READ half did not move. `parseTA1` still reads elements RAW, pre-`?`-unescape**, exactly
227
- as `X12Segment.elements` has always documented, so a control number of `"00000001?"` now reads
228
- back as `"00000001??"` rather than as `"00000001?*260601"`. The disposition is correct where it
229
- was inverted; apply `unescapeRelease` if you need the value rather than the bytes. Unescaping on
230
- the read side would move every TA1 a consumer already reads and is not done here.
568
+ - **An EMPTY control number was not refused by this slice, and is refused by the NEXT one.**
569
+ `escapeRelease` early-returns on `""` and `buildTA1` carried no required-field guard, so
570
+ `interchangeControlNumber: ""` emitted `TA1**260601*1200*A*000` with `warnings: []`, here and at
571
+ every earlier release. `X12-EMPTY-CONTROL-NUMBER-FABRICATED` closed it across every builder; see
572
+ the entry at the top of this file, including the whitespace-only residual it leaves open.
573
+ - **🩺 The READ half did not move IN THIS SLICE, and `X12-TA1-RESIDUALS` is the one that moved
574
+ it** - see the entry at the top of this file. A control number of `"00000001?"` read back as
575
+ `"00000001?*260601"` before this slice and as `"00000001??"` after it; the clause that stood
576
+ here telling callers to apply `unescapeRelease` themselves is **deleted, not reworded**, because
577
+ the library now does it.
231
578
  - **The release is scoped to the delimiter set the CALLER states.** `BuildTA1Options` gained
232
579
  `repetitionSeparator` / `componentSeparator` / `segmentTerminator` beside the existing
233
580
  `elementSeparator`, the same four `build999` already takes, and they exist for escaping and
@@ -245,8 +592,9 @@ model.
245
592
  **No existing refusal moves code:** `ackCode` `"A"` with a non-`000` note still reports
246
593
  `X12_TA1_ACCEPT_WITH_NOTE` and still runs first.
247
594
  - **What this does NOT close.** `buildTA1` still uses no segment joiner, so its refusal names the
248
- builder and never the slot; `buildInterchange` still does not escape GS-04 / GS-05 / GS-07; and
249
- an unescaped active delimiter is still not safe anywhere, because that is what a delimiter is.
595
+ builder and never the slot; `buildInterchange` did not escape GS-04 / GS-05 / GS-07 at this
596
+ release, which `X12-INTERCHANGE-GS-EMIT-NOT-RELEASE-AWARE` then closed; and an unescaped active
597
+ delimiter is still not safe anywhere, because that is what a delimiter is.
250
598
 
251
599
  - **🩺 BREAKING for `build277` callers: a 277 service line now REQUIRES `unitsOfService`, because
252
600
  SVC-07 is a required element in `005010X212` and this library was not emitting it at all**
@@ -1016,14 +1364,10 @@ N-char spec limit` refusal, one per emitting module, where the branch fires **be
1016
1364
  the array guard's primitive arm. If you were reading a value back out of one of those messages, that
1017
1365
  is a behaviour change.
1018
1366
 
1019
- **Two things that sentence does NOT say, both deliberate.** The array guard still reports the
1367
+ **What that sentence does NOT say, and it is deliberate.** The array guard still reports the
1020
1368
  `length` and the class tag of a forged array-like, bounded through the same renderer: those describe
1021
1369
  the SHAPE you forged rather than the contents of a document element, and they are the whole
1022
- diagnostic for `{ length: "9".repeat(120000) }`. And only the segment-join guard names the SLOT
1023
- alongside the type (`build999: "AK9"-01 must be a string, ...`), because it holds the whole segment;
1024
- the escape-helper and `X12Decimal` guards name the **builder**, so on those two the value used to
1025
- stand in for a locator and now nothing does. That is a real diagnostic cost, and it is written down
1026
- rather than hidden.
1370
+ diagnostic for `{ length: "9".repeat(120000) }`.
1027
1371
 
1028
1372
  - **`defineProfile()` refusals are bounded on the same terms, since `0.0.6`.** `X12ProfileError.message`
1029
1373
  used to interpolate your profile name, quirk id, effect, fixture path and expected-warning codes
package/README.md CHANGED
@@ -47,7 +47,7 @@ That's the pitch: no schema upload, no spec knowledge. The parser accepts vendor
47
47
  - **Postel's Law**: a lenient parser (deviations → warnings with a stable code + positional context) and a conservative serializer. `serializeX12` is byte-faithful **for the segments on the model** by default: each comes back verbatim, including element padding, composites, and `?`-release escapes. **`serialize(parse(s)) === s` is not guaranteed in general.** Six constructs are known not to survive: line breaks between segments (any run of CR / LF between segments is absorbed at parse, so a pretty-printed or double-spaced file emits compact), a doubled terminator outside a transaction, a missing final terminator (the emit supplies one), post-IEA `trailingBytes` (re-joined, not verbatim), a TA1 that followed a functional group (emitted right after the ISA, so it is **reordered**, though nothing is lost), and a segment whose first element is empty outside a transaction (skipped entirely, with no warning at all). A **segment outside a transaction is not on that list**: it is reported as `X12_UNEXPECTED_SEGMENT`, kept on the model at `ix.orphanSegments` with a structural `anchor`, and re-emitted at that anchor, so the segment, its value and its warning all survive the round trip - placement is by the anchor and never by `segmentIndex`, which indexes the input stream the emit does not follow. The last five break the round trip on inputs with no line breaks at all, so do not treat "no line breaks" as sufficient; five of the six are silent, so a clean warnings list does not mean byte-exact either. Measured across this repo's 56 fixtures: every emit is a fixed point and re-parses to an identical model with an identical warning stream, the 14 with no line breaks return byte-identical, and the other 42 differ by line breaks and nothing else. See [Line endings between segments](./docs-content/spec-notes-envelope.md). `{ specClean: true }` reconciles the envelope, and `{ specClean: true, recomputeCounts: true }` also emits the corrected counts (`recomputeCounts` does nothing on its own). A mismatch is always warned, never silently corrected. Only **4 structural failures** are ever fatal.
48
48
  - **Money is exact, and never invented**: every monetary/percent/quantity field decodes as `X12Decimal` (string-backed, BigInt arithmetic), and the library **never `parseFloat`s** an EDI amount. **No slot holds an amount this library did not decode.** Where a reader used to substitute `X12Decimal.ZERO` the slot is now `X12Decimal | undefined` and reads `undefined`, so "the sender stated zero" and "the sender stated nothing" are different readings; through `0.0.12` they were the same `0`. That is a rule about the substitution, not a census of the model: some rows are dropped whole instead, which is a different and pre-existing shape, and no total is published here. Read [KNOWN-LIMITATIONS.md](./KNOWN-LIMITATIONS.md).
49
49
  - **Safety-critical fidelity**: TRN reassociation traces, 835 balance invariants, 837 HL hierarchy integrity, 834 maintenance types, and 278 certification actions are preserved **verbatim** and never inferred; ambiguity yields a warning or a typed refusal, never a confident wrong answer.
50
- - **PHI-disciplined**: synthetic-only fixtures, a PHI commit-gate, and a warning `message` that is a **lookup into a frozen registry**, not something built from your document. No warning factory in the library takes a value parameter, so no element can reach a diagnostic: the code and `position` say what and where, and the bytes stay on the model. `ALL_WARNING_MESSAGES` is exported so you can assert it. **Builder refusals are a different, and deliberately weaker, surface:** twenty-four refusal sites across ten builder modules name a value you passed in, so you can see which control number, count or code was refused. **What they will never name is a `claimId`, a member id, a member name, a trace or a diagnosis code** - a guarantee that held for the refusal templates and, until the release after `0.0.10`, did not hold for the shared type guards underneath them: a JSON-driven caller who sent a number where the types say string got `a number ("900412345678")` back, bounded but not redacted, from a guard standing on every element of every builder. Those guards report the type now and never the value. Only the segment-join guard names the slot as well; the `esc` and `X12Decimal` guards name the builder, which is a diagnostic cost stated rather than hidden. The array guard's primitive arm is redacted with them, but it still reports the `length` and the class tag of a forged array-like, bounded, because those describe the shape you forged rather than the contents of a document element. Each goes through `renderCallerValue`, capping the rendered **fragment** at `BUILD_REFUSAL_VALUE_MAX_RENDERED` (90 characters); all three names are exported so you can assert the ceiling. The whole `message` is that plus the site's own fixed text, so it is bounded by a constant but a larger one: a 120,000-character control number gave a 120,066-character `X12BuildError.message` before this and gives 150 now. That is robustness, not redaction: the value is one you supplied and bounding it hides nothing from you. It is also not escaped, and on the **ack** path it is not always strictly your own, since `build999`'s AK2-02 and `buildTA1` echo an inbound document's control numbers by design. Log `err.code`, not `err.message`, from a builder. **`defineProfile()` is bounded on the same terms since `0.0.6`** (twelve refusal sites, twenty-three caller values; the worst message measured **360,181 characters** before and **431** now, both at the `fixture` refusal, which names three caller values; the ceiling for that site is 443 and the suite asserts every site under 500; `X12ProfileError.profileName` is deliberately left unbounded so it still matches the name you passed). **And a forged non-array in a builder spec now refuses instead of hanging:** every indexed loop takes its bound from a checked array, so `{ length: "9".repeat(120000) }` draws a typed refusal rather than coercing to `Infinity` and looping forever. A few `for…of` reads still throw an untyped `TypeError` instead; both are in [KNOWN-LIMITATIONS.md](./KNOWN-LIMITATIONS.md). **The one deliberate exception:** `X12ParseError.snippet` on the four Tier-3 structural fatals is a bounded (≤ 64 character) copy of the start of the input, so on real traffic it can carry PHI. The library does not redact it. Redact at your call site, or log `err.code` and `err.position` instead. See [Keeping PHI out of logs](./docs-content/troubleshooting.md).
50
+ - **PHI-disciplined**: synthetic-only fixtures, a PHI commit-gate, and a warning `message` that is a **lookup into a frozen registry**, not something built from your document. No warning factory in the library takes a value parameter, so no element can reach a diagnostic: the code and `position` say what and where, and the bytes stay on the model. `ALL_WARNING_MESSAGES` is exported so you can assert it. **Builder refusals are a different, and deliberately weaker, surface:** twenty-four refusal sites across ten builder modules name a value you passed in, so you can see which control number, count or code was refused. **What they will never name is a `claimId`, a member id, a member name, a trace or a diagnosis code** - a guarantee that held for the refusal templates and, until the release after `0.0.10`, did not hold for the shared type guards underneath them: a JSON-driven caller who sent a number where the types say string got `a number ("900412345678")` back, bounded but not redacted, from a guard standing on every element of every builder. Those guards report the type now and never the value. The array guard's primitive arm is redacted with them, but it still reports the `length` and the class tag of a forged array-like, bounded, because those describe the shape you forged rather than the contents of a document element. Each goes through `renderCallerValue`, capping the rendered **fragment** at `BUILD_REFUSAL_VALUE_MAX_RENDERED` (90 characters); all three names are exported so you can assert the ceiling. The whole `message` is that plus the site's own fixed text, so it is bounded by a constant but a larger one: a 120,000-character control number gave a 120,066-character `X12BuildError.message` before this and gives 150 now. That is robustness, not redaction: the value is one you supplied and bounding it hides nothing from you. It is also not escaped, and on the **ack** path it is not always strictly your own, since `build999`'s AK2-02 and `buildTA1` echo an inbound document's control numbers by design. Log `err.code`, not `err.message`, from a builder. **`defineProfile()` is bounded on the same terms since `0.0.6`** (twelve refusal sites, twenty-three caller values; the worst message measured **360,181 characters** before and **431** now, both at the `fixture` refusal, which names three caller values; the ceiling for that site is 443 and the suite asserts every site under 500; `X12ProfileError.profileName` is deliberately left unbounded so it still matches the name you passed). **And a forged non-array in a builder spec now refuses instead of hanging:** every indexed loop takes its bound from a checked array, so `{ length: "9".repeat(120000) }` draws a typed refusal rather than coercing to `Infinity` and looping forever. A few `for…of` reads still throw an untyped `TypeError` instead; both are in [KNOWN-LIMITATIONS.md](./KNOWN-LIMITATIONS.md). **The one deliberate exception:** `X12ParseError.snippet` on the four Tier-3 structural fatals is a bounded (≤ 64 character) copy of the start of the input, so on real traffic it can carry PHI. The library does not redact it. Redact at your call site, or log `err.code` and `err.position` instead. See [Keeping PHI out of logs](./docs-content/troubleshooting.md).
51
51
 
52
52
  See the [**Cookbook**](./docs-content/cookbook.md) for task-oriented recipes (post an 835, route 277CA rejections, round-trip a 271, walk an 837, read a 999) and [**KNOWN-LIMITATIONS.md**](./KNOWN-LIMITATIONS.md) for the honest do-not-over-trust list.
53
53