@cosyte/x12 0.0.10 → 0.0.11

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.
@@ -0,0 +1,804 @@
1
+ # Known limitations & non-goals
2
+
3
+ `@cosyte/x12` is built to be **correct and honest about its edges** rather than to claim more than it
4
+ delivers. Misreading a payer's remittance, a claim's diagnosis, or a member's coverage can cause real
5
+ financial or clinical harm, so this is the deliberate "do not over-trust" list. Everything here is a
6
+ documented, intentional boundary, not a bug. The lenient parser never silently drops or garbles a
7
+ **decoded value**: where a limitation applies, the raw value is preserved (often with a warning), it
8
+ is simply not further decoded. Three things it does discard are worth reading before you rely on a
9
+ round trip, and all three are **silent**: **line breaks between segments**, **a doubled segment
10
+ terminator** outside a transaction, and **a segment whose first element is empty** outside a
11
+ transaction. All three are in the first two entries below. A **segment outside a transaction** used
12
+ to belong on that list and no longer does: it is warned, kept on the model at `ix.orphanSegments`,
13
+ **and re-emitted at the structural anchor the walker recorded**, so it survives a round trip. The
14
+ empty-first-element case is now the sharpest of the three: no warning, and no copy anywhere on the
15
+ model.
16
+
17
+ ## Data / decode boundaries
18
+
19
+ - **🩺 BREAKING for `build277` callers: a 277 service line now REQUIRES `unitsOfService`, because
20
+ SVC-07 is a required element in `005010X212` and this library was not emitting it at all**
21
+ (`X12-277-SVC07-NOT-DECODED`). Through the release before this one `get277Status` read SVC-01
22
+ through SVC-04 and stopped, and `build277` emitted exactly those four, so **every X212 277 this
23
+ library produced with a Loop 2220 service line was short a required element** and every 277 it read
24
+ silently discarded the submitted units. Both sides are fixed: `X12ServiceLineStatus.unitsOfService`
25
+ and `Build277ServiceLineSpec.unitsOfService` carry SVC-07 (X12 element 380, Quantity).
26
+
27
+ **The two TR3s disagree about the usage and so do the two builders, on purpose.** In `005010X212`
28
+ the element is usage **R**, so `build277` now throws `ClaimStatus277BuildError` with code
29
+ `X12_277_BUILD_INVALID_SPEC` for a service line that omits it. In `005010X214` it is usage **S**,
30
+ so `build277CA` accepts the identical spec and simply omits the element. Usage read from the pyx12
31
+ 005010 maps (`277.5010.X212.xml`, `277.5010.X214.xml`), which are outside this repository; the two
32
+ TR3s also name it differently, "Units of Service Count" in X212 and "Original Units of Service
33
+ Count" in X214, and one model field carries both.
34
+
35
+ **The count is never defaulted, in either direction.** A quantity the caller did not supply is a
36
+ quantity nobody sent, and a units figure is one a payer reprices against; supplying a `1` would be
37
+ inventing data on the wire. If you are emitting X212 277s today, this is a compile-time-invisible,
38
+ run-time refusal on the first build after upgrading, and that is the intended shape.
39
+
40
+ **SVC-05 is deliberately still unread on the 277, and that is not the same gap.** It is usage **N**
41
+ in both 277 TR3s. On the **835** SVC-05 is the Units of Service **Paid** Count and is read; reading
42
+ it on a 277 "for symmetry" would put a quantity on the model that no 277 sender ever wrote. Same
43
+ element number, different TR3.
44
+
45
+ **🩺 Read the scope literally: ONE element's usage was fixed, and an emitted service line is NOT
46
+ thereby conformant.** This was not a 277 usage audit, and **no census of what remains is published
47
+ here**, on purpose: other required elements of the same `SVC` are still unguarded, and finding
48
+ another is expected rather than a new defect. To name two, `SVC-01` (the composite procedure
49
+ identifier) and `SVC-02` (Line Item Charge Amount) are both usage **R** in X212 and both optional
50
+ on `Build277ServiceLineSpec`, so a spec supplying only `unitsOfService` still emits
51
+ `SVC*******1~` with no refusal. **The missing guard is what is pre-existing, not that byte
52
+ string** - at base the same spec emitted a bare `SVC~`, because the SVC-07 slot did not exist. `SVC-03` (Line Item Payment
53
+ Amount) is usage **R** in X212 and usage **N** in X214 and is optional in both builders. And the
54
+ read side is unchanged and still lenient: an X212 277 arriving with no SVC-07 raises **no
55
+ warning**, because that would need a new Tier-2 registry code and the defect this slice closes is
56
+ on the emit side. All of it is pre-existing and reproduces at `e3cdf49`; widening the guard would
57
+ have turned this into that audit, which is its own item.
58
+
59
+ - **🩺 An unparseable decimal still lands on the model as a stand-in; what changed is that it warns.**
60
+ A decimal element that is present and that this library cannot decode as a decimal (`1,234.56`,
61
+ `$450.00`, `N/A`) yields no value, and the reader has to put something in its place: a slot typed
62
+ `X12Decimal` gets `X12Decimal.ZERO`, an optional slot gets `undefined`, and some rows are dropped
63
+ whole. **Every one of those outcomes now emits `X12_UNPARSEABLE_DECIMAL` at the failing
64
+ `position.elementIndex`, because the warning is a property of the READ rather than of what the
65
+ reader then does with it. No outcome is otherwise changed.** No list of the outcomes is published
66
+ here, on purpose: a first draft enumerated three and a review measured a fourth, and the rule is
67
+ what holds, not the census. So a consumer that reads only the model, and never looks at
68
+ `.warnings`, sees exactly what it saw before: a `0` where the payer sent something unreadable.
69
+ **That is the residual, stated plainly.** Closing it further means changing every `X12Decimal`
70
+ model slot to `X12Decimal | undefined`, which is a breaking model change and is deliberately not in
71
+ this slice. Gate on the warning, or read `readElementDecimal` yourself.
72
+
73
+ Three scoping facts that are easy to get wrong in the other direction:
74
+ - **An ABSENT element does not warn.** "Missing means zero" is the documented convention of the
75
+ slots that use `elementDecimalOrZero` and is unchanged.
76
+ - **🩺 That does NOT make an unwarned `0` trustworthy in general, and this is the one inversion to
77
+ refuse.** The warning is a property of a decimal READ, not a property of a model slot. A slot
78
+ the reader never read at all cannot warn, and it still holds whatever the accumulator was seeded
79
+ with. **What this guarantees is narrower and exact: an unwarned `0` AT AN ELEMENT A READER
80
+ DECODED is a zero the sender sent or omitted.** The known slot of the other kind, an 837 service
81
+ line whose `SVx` never decoded, is covered by a warning of its own (next entry) rather than by
82
+ this one. **No census of never-read slots is published**, on purpose: the rule is what holds.
83
+ - **The public helpers are silent without a sink.** `elementDecimal` / `elementDecimalOrZero` take
84
+ an optional 4th `X12DecimalWarningSink`. Every reader inside this library passes one, and a
85
+ source scan in `test/parser-decimal-silent-defaults.test.ts` keeps it that way. **A consumer
86
+ calling them directly and omitting it gets the old silent behaviour**, which is deliberate: it
87
+ keeps the existing signature working. `readElementDecimal` is the sink-free way to get the
88
+ distinction in-band.
89
+ - **The scan is a syntactic tripwire, not a proof.** It counts the arguments of every
90
+ `elementDecimal` / `elementDecimalOrZero` call under `src/transactions/` after stripping
91
+ comments. It says nothing about a decimal decoded some other way, and no exhaustive census of
92
+ such routes is published here, on purpose.
93
+
94
+ - **🩺 An 837 service line whose `SVx` never decoded still ships with `charge` and `units` at `0`;
95
+ what changed is that it warns.** `get837Claims` resolves ONE variant for the submission, from the
96
+ caller's `type` option, else ST-03's implementation-convention reference, else the first `SVx`
97
+ segment present. A Loop 2400 line is then decoded only by the `SV1` / `SV2` / `SV3` that matches
98
+ that variant. When none arrives, because the line carries an `SVx` for a different variant (an
99
+ ST-03 of `005010X222A2` on a file whose lines are `SV2`, or a `{ type: "P" }` over the same) or
100
+ because it carries no `SVx` at all, **nothing on the service segment is read**: the line's
101
+ `charge` and `units` hold the accumulator's seeded `X12Decimal.ZERO`, and its procedure code,
102
+ modifiers, unit of measure and place of service are equally undecoded. **That line now emits
103
+ `X12_837_SERVICE_LINE_NOT_DECODED`, anchored at the `LX` that opened it.** Read the values off
104
+ `tx.segments[…].raw` and decide which of the two disagreeing signals the sender meant.
105
+ - **Ignoring the foreign `SVx` is deliberate and is not the limitation.** `SV1-02` and `SV2-03`
106
+ are both the line charge, so decoding an `SV2` into a Professional line would mis-read money.
107
+ Refusing to read is the safe half; doing it silently was the defect.
108
+ - **The model is unchanged.** `charge` and `units` are still typed `X12Decimal`, so the stand-in
109
+ `0` is still what a consumer that never reads `.warnings` sees. Making them
110
+ `X12Decimal | undefined` is a breaking model change and is deliberately not in this slice.
111
+ - **The line is still retained, and so are the bytes.** Nothing is dropped: the service line is
112
+ on the claim, and every segment, decoded or not, stays verbatim on `tx.segments`.
113
+ - **Neither cause is attributed to the sender.** A `type` option that disagrees with a perfectly
114
+ conformant document produces the same warning as a document that disagrees with itself, and the
115
+ library does not decide which side is wrong. A line with no `SVx` at all is short a Loop 2400
116
+ segment the 837 TR3s require, but that judgement is yours to make from the bytes.
117
+ - **An unresolvable variant is a different case, and it no longer goes unreported.** With no
118
+ `type`, an ST-03 that resolves to no known variant **and no `SVx` anywhere in the transaction to
119
+ fall back on**, no service line is opened and no `0` is fabricated on any line slot. That raises
120
+ `X12_837_UNKNOWN_VARIANT` for the submission, and now also
121
+ `X12_837_SERVICE_LINE_DROPPED` at each `LX` that consequently opened nothing - so the missing
122
+ lines are reported per line and not inferred from a count. The earlier warning here, that the
123
+ lookup was a plain object literal and its absence therefore proved nothing, is **withdrawn: that
124
+ hole is closed** (see the next entry). Checking `submission.variant` against the set you expect
125
+ is still worth doing, as a cheap assertion rather than as a defence.
126
+ **🩺 Its anchor moved, so a consumer joining on `position.segmentIndex` gets a different
127
+ segment than it did on `0.0.10`.** `X12_837_UNKNOWN_VARIANT` now points at the **`ST`**
128
+ (`tx.segments[0]`), which carries the ST-03 the resolution reads. Through `0.0.10` it pointed at
129
+ `tx.segments[1]`, the **`BHT`** - a segment with no part in variant resolution. No
130
+ `elementIndex` is set, deliberately: one of the two routes into this warning is an ST-03 that is
131
+ absent entirely, and on that route the `ST` has no element 3 to name.
132
+
133
+ - **🩺 An 837 `LX` that opens no Loop 2400 at all raises `X12_837_SERVICE_LINE_DROPPED`, the 25th
134
+ Tier-2 warning code.** Distinct from `X12_837_SERVICE_LINE_NOT_DECODED` above, where the line IS
135
+ on the model and only its service segment went unread: here the line reaches **no claim's**
136
+ `serviceLines`, so its charge, units, procedure code and modifiers are read into nothing. Two
137
+ causes: no Loop 2300 (`CLM`) is open at that `LX`, or the submission's variant is not one of
138
+ `P` / `I` / `D`. Nothing is fabricated to stand in and no claim is synthesized; the segments stay
139
+ verbatim on `tx.segments`. **Do not read an empty `serviceLines` as "the claim had no service
140
+ lines" without checking the warning channel.** Three bounds on that code, each measured:
141
+ - **It does not travel with `X12_837_UNKNOWN_VARIANT`.** A caller-supplied `type` outside
142
+ `"P" | "I" | "D"` - which only a JavaScript or `JSON.parse`d caller can pass - reaches the second
143
+ cause with no unknown-variant warning at all. Read `submission.variant`, not the other code.
144
+ - **What becomes of a `DTP` / `AMT` / `NTE` / `REF` / `N3` / `N4` / `PER` after a dropped `LX`
145
+ depends on the route, and is not simply "absent" on either. Two drafts stated it unqualified,
146
+ in opposite directions, and both were wrong.**
147
+ With a `CLM` open (the variant route), the line service date, amount and note land among the
148
+ **claim-level** ones, indistinguishable from them, and a trailing `REF` lands among the
149
+ claim-level references. With **no** `CLM` open, **all seven are discarded**: nothing following
150
+ that `LX` attaches to the party named BEFORE it. The `DTP` / `AMT` / `NTE` already were
151
+ discarded on that route at `0.0.10`; the `REF` / `N3` / `N4` / `PER` are what changed. **Read
152
+ the segments off `tx.segments[…].raw` rather than inferring either outcome.**
153
+ **🩺 CHANGED, and the change moves values off the model that `0.0.10` put on it.** Through
154
+ `0.0.10` - the current release as this was written, so a consumer on it has the old behaviour -
155
+ the no-claim route did not discard these. Measured, on a payer: a line-item control number
156
+ surfaced in a _later_ claim's `payer.references`, and an `N3` / `N4` / `PER` gave that payer a
157
+ street address and a contact it never had. **Never read that as all four kinds on every party** -
158
+ this reader does not surface every one of those kinds on every party, and a `PER` on a patient
159
+ or a pay-to address reached the model on no release. **No per-kind, per-party map is published
160
+ in this document, deliberately**, and the `X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX` entry
161
+ below does not carry one either: it says outright that whether such a segment would have
162
+ attached is a separate question it does not answer. `tx.segments` is what settles it for a
163
+ given document. **If you read `0.0.10` or earlier and relied on an
164
+ entity's `address`, `contacts` or `references`, those slots could carry line-level values from a
165
+ dropped Loop 2400.**
166
+ - **🩺 That change is a TRADE, and its cost is that a conformant entity segment can now be
167
+ dropped.** The TR3s nest Loop 2400 inside Loop 2300 and say nothing about an `LX` elsewhere, so
168
+ which party a segment following a **stray** `LX` belongs to is not derivable from the spec in
169
+ either direction. Where the `LX` was injected into an **entity** loop, the `N3` / `N4` / `REF` /
170
+ `PER` after it really were that entity's, and they are now discarded: measured, a payer that
171
+ kept its `PO BOX` address, its `2U` secondary id and its contact at `0.0.10` comes back with
172
+ `address: undefined`, `references: []` and `contacts: []`. The direction was chosen on this
173
+ library's own invariant, not on a clause: a mis-attribution puts a value on an object the sender
174
+ never put it on, indistinguishable from real data, whereas the bytes of a discarded segment are
175
+ still on `tx.segments`.
176
+ **🩺 The discard is REPORTED, by a code of its own, at the segment itself.** Each such
177
+ `N3` / `N4` / `PER` / `REF` raises `X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX`, so the loss is
178
+ on the warning channel once per discarded segment. `X12_837_SERVICE_LINE_DROPPED` at that `LX`
179
+ reports the **service line** and not the entity's address, id or contact, so it never named this
180
+ loss and does not now. **The channel is still not a complete account of the document** -
181
+ `tx.segments` is - and the loss itself is unchanged: reporting it did not restore it. Both are
182
+ pinned by tests. **Read the new code's bound in its own entry below before restating it:** it is
183
+ not a general "this segment reached no party" report.
184
+ - **An `SVx` with no `LX` at all raises `X12_837_SERVICE_SEGMENT_WITHOUT_LX`, the 26th Tier-2
185
+ code, anchored at the service segment itself.** Through `0.0.10` it was dropped in SILENCE:
186
+ both codes above are anchored at the `LX`, so a service segment that never had one had nothing
187
+ to anchor to and reported on no channel. See the next entry for what that code says and what it
188
+ still does not. **The general caveat stands even so: the warning channel is a report of the
189
+ losses this library knows how to name, and `tx.segments` is the only complete account of the
190
+ bytes.**
191
+
192
+ - **🩺 An `SV1` / `SV2` / `SV3` that arrives with no Loop 2400 open raises
193
+ `X12_837_SERVICE_SEGMENT_WITHOUT_LX`, anchored at the service segment itself.** The third code in
194
+ this family, and the one that needed a different anchor: the other two both name an `LX`, and this
195
+ case is defined by there being no line open to name one. **Read that condition literally.** It is
196
+ **not** "the file contains no `LX`" - an `LX` in an earlier claim is still an `LX`, and
197
+ `CLM*1~ LX*1~ SV1~ CLM*2~ SV1~` raises this code on the second `SV1` while the first claim keeps
198
+ its decoded line. Nothing the segment carries is read - not its charge, its
199
+ units, its procedure code, its modifiers, its unit of measure or its place of service - no line
200
+ reaches any claim's `serviceLines`, and nothing is fabricated to stand in. **Through `0.0.10` -
201
+ the release published as this was written, so a consumer who has not upgraded still has it - that
202
+ was the whole of the report: a charge, a quantity and a procedure code left the typed model with
203
+ `warnings: []` and the claim read as one that simply had no service lines.** Five bounds, each
204
+ measured:
205
+ - **The three codes never report the SAME service segment**, because the other two are raised at
206
+ an `LX` and this one only where no line is open. A document with several claims can still carry
207
+ all three, on three distinct segments; that case is a committed test.
208
+ - **An `LX` that opened nothing suppresses it for the segments inside that dropped loop**, which
209
+ is deliberate: the loss is already named, once, at the `LX`. The suppression is scoped to the
210
+ dropped loop and is cleared by the next flush, so a later orphan in the same transaction is
211
+ still reported. Both halves have a red negative control.
212
+ - **It reports once per service segment, not once per loop.** Two orphan `SVx` segments raise two
213
+ warnings at two positions, so a consumer can name both rather than infer a count from one.
214
+ - **The segment is NOT decoded into any line.** `SV1-02` and `SV2-03` are both the line charge, so
215
+ reading a service segment into a line the walker never opened mis-reads money. Refusing to read
216
+ is the safe half; doing it silently was the defect. **This does not change the `SV1-02` case
217
+ above:** an absent `SV1-02` on a line that DID open still reads a confident `0`, which closes
218
+ only with the deferred `X12Decimal | undefined` model change.
219
+ - **🩺 IT SAYS NOTHING ABOUT THE VARIANT, AND A FIRST DRAFT OF THIS BOUND CLAIMED IT DID.** Variant
220
+ resolution runs before the walk. A caller-supplied `type` option wins first; absent one, and
221
+ where `ST-03` names none of the three known implementation conventions, it **falls back to the
222
+ first `SVx` segment id anywhere in the transaction body - orphans included**. So a stray `SV2`
223
+ under an `ST-03` of `005010X222A1` re-types the whole submission as Institutional, and every
224
+ conformant `SV1` line in it then reads `charge` `0`, `units` `0` and an `undefined` procedure
225
+ code - `undefined`, not `""`, which on such a line is the `revenueCode`. Passing
226
+ `{ type: "P" }` reads the same document correctly. Measured, both trees: `PRE-EXISTING`,
227
+ identical at `0.0.10`, **not** introduced or changed by this code, and **not** narrowed here - excluding
228
+ orphans from the fallback would change how existing documents decode and is its own slice. It
229
+ is warned rather than silent (`X12_837_SERVICE_LINE_NOT_DECODED` at each `LX`), and
230
+ `submission.variant` is the field that tells you.
231
+
232
+ - **🩺 An `N3` / `N4` / `PER` / `REF` discarded after a stray `LX` raises
233
+ `X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX`, anchored at the discarded segment itself.** This is
234
+ the code that names the trade recorded in the entry above: where an `LX` with **no `CLM` open**
235
+ landed inside an entity loop, it closes that loop, so those four segment kinds reach no party on
236
+ the model. Releases through `0.0.10` attached such a segment to whichever party the last `NM1`
237
+ left active **wherever this reader surfaces that segment kind on that party at all** - never
238
+ read that as all four kinds on every party, because a `PER` on a patient or a pay-to address
239
+ reached the model on no release. Discarding them instead is what stopped a line-level value
240
+ surfacing as a property of a party the sender never put it on, and this code is what stops that
241
+ discard being silent. **It reports that the segment reached no party, NOT that it would have
242
+ reached one**, so it can fire where nothing this library's own reset lost. The
243
+ anchor is the segment and not the `LX`, because the loss is per segment: two `N3`s are two
244
+ warnings at two positions, so a consumer can name both. Four bounds, each a committed test:
245
+ - **🩺 It is NOT a general "this entity segment reached no party" report, and it must never be
246
+ restated as one.** It reports one discarded after such an `LX`, and only until the next
247
+ `NM1` / `HL` / `CLM` opens a loop. A party named after that `LX` is outside this code's
248
+ scope again and its trailing segments are silent: the scope is a scope, not a latch.
249
+ **Whether they ATTACH is a separate question this code does not answer**, and for some party
250
+ and segment kinds the answer is no on every release - a `PER` reaches neither a patient nor a
251
+ pay-to address on any of them. **This code reports that a segment reached NO party, never that
252
+ it would otherwise have reached one.**
253
+ - **Every other route to an unattached `N3` / `N4` / `PER` / `REF` is exactly as silent as it was
254
+ before this code existed**, and that is deliberate rather than an oversight: no entity loop open
255
+ at the stray `LX` at all (nothing was lost by this library's doing, since those segments reached
256
+ no party at `0.0.10` either), an `NM1` this reader cannot route, an intervening `HL` or `CLM`,
257
+ and the other dropped-`LX` route where a claim **is** open. Widening it to those is a guard
258
+ change and would be its own decision.
259
+ - **The `DTP` / `AMT` / `NTE` of the seven trailing kinds are NOT reported by it.** They are
260
+ discarded on that route too, and were at `0.0.10` as well, because they never attach to a party
261
+ on any route. This code says nothing about them; `X12_837_SERVICE_LINE_DROPPED` at the `LX` is
262
+ what reports that loop's loss.
263
+ - **It does not restore anything.** The segments are still discarded, and are still verbatim on
264
+ `tx.segments`, which remains the only complete account of the document. Which party a segment
265
+ following a **stray** `LX` belongs to is not derivable from the TR3s in either direction, so the
266
+ reader refuses to attribute it rather than guessing.
267
+
268
+ - **🩺 Through `0.0.9`, a lookup keyed by document bytes could be defeated by a key inherited from
269
+ `Object.prototype`, and the affected code paths reported nothing.** The bundled code lists, the
270
+ 837's variant resolution, and the 837's HL parent-level map were built as plain object literals,
271
+ which inherit `Object.prototype`. An inbound value matching **any own property of
272
+ `Object.prototype`** therefore resolved TRUTHY. That set is engine- and version-dependent and is
273
+ deliberately not enumerated here: on the Node 22 this package targets it is twelve members, and a
274
+ draft of this entry listed eight. `Object.freeze` did not help: it seals the own properties and
275
+ changes nothing about the prototype chain. Concretely, at `0.0.9`:
276
+ - An ST-03 of `constructor` made `submission.variant` a **function**, suppressed
277
+ `X12_837_UNKNOWN_VARIANT`, and took **every Loop 2400 off the model** with `warnings: []`.
278
+ - `lookupCarc("constructor")` answered a `CodeListEntry` whose `description`, typed `string`, was
279
+ a function, and suppressed `X12_UNKNOWN_CARC`. The same held for the other bundled lists.
280
+ - An HI qualifier of `constructor` suppressed `X12_UNKNOWN_HI_QUALIFIER` while still landing the
281
+ code with `codeSystem: "unknown"`.
282
+ - An HL-03 of `constructor` raised `X12_HL_PARENT_LEVEL_INVALID` against a level the walker has
283
+ no expectation for - a structural violation the document never made.
284
+ - `isClaimAdjustmentGroupCode("constructor")` answered `true` and narrowed the type. It used the
285
+ `in` operator, which **walks the prototype chain**; `in` is the safe-looking form and is not
286
+ the safe form.
287
+
288
+ Every one of these now behaves exactly as it does for any other unrecognized value. The 271 / 277
289
+ / 278 readers were never exposed: their `EXPECTED_PARENT_LEVEL` tables have the same literal shape
290
+ but are read only through `src/transactions/shared/hl.ts`, which has always guarded with
291
+ `hasOwnProperty`. **What is not claimed:** there is no source-level scan enforcing this shape. A
292
+ syntactic scan cannot tell a table keyed by document bytes from one keyed by a library-owned
293
+ discriminant, and the message registries in `src/parser/warnings.ts` are legitimately the latter,
294
+ so such a scan would need a per-table allowlist. The defence is behavioural instead: the suite
295
+ derives its key list from `Object.getOwnPropertyNames(Object.prototype)` at run time, so a future
296
+ engine adding an inherited member widens it without anyone editing a list.
297
+
298
+ - **🩺 BREAKING, in the release after `0.0.9`: the 835 Loop 2110 SVC element map was off by one, in
299
+ BOTH directions, and is corrected.** Through `0.0.9` `get835` read the revenue code from **SVC-05**
300
+ and the paid units from **SVC-07**, and `build835` wrote them to the same two places while leaving
301
+ SVC-04 empty. The correct map, which the repo's own 277 modules already used:
302
+
303
+ | Element | X12 element | Meaning |
304
+ | ------- | ---------------------------------- | ----------------------------------- |
305
+ | SVC-04 | 234, Product/Service ID (a string) | NUBC revenue code |
306
+ | SVC-05 | 380, Quantity | Units of Service **Paid** Count |
307
+ | SVC-06 | C003 composite | original / submitted procedure |
308
+ | SVC-07 | 380, Quantity | **Original** Units of Service Count |
309
+
310
+ **What changes for you.** On parse, `serviceLine.revenueCode` now comes from SVC-04 and is
311
+ `undefined` on a professional line; through `0.0.9` it returned whatever sat in SVC-05, which on a
312
+ conformant 835 is the paid-unit count - measured across all six committed remit fixtures plus the
313
+ golden, **8 of 8 service lines returned `revenueCode: "1"`, which is not a valid NUBC revenue
314
+ code**, while `paidUnitsOfService` came back `undefined`. `serviceLine.paidUnitsOfService` now
315
+ comes from SVC-05; through `0.0.9` it read SVC-07, which is the _submitted_ count, so where a payer
316
+ sent both the field named "paid" carried the original. **`serviceLine.originalUnitsOfService` is
317
+ new** and carries SVC-07; without it the corrected map would have made SVC-07 unread, turning a
318
+ fixed mis-read into a fresh silent drop. On emit, the same three fields move to the same three
319
+ places: `build835` with a revenue code `0300` and 2 paid units emitted
320
+ `SVC*HC:99213*600.00*550.00**0300*HC:99212*2` at `0.0.9` and emits
321
+ `SVC*HC:99213*600.00*550.00*0300*2*HC:99212` now. **The old bytes put a revenue code in a Quantity
322
+ element**, so a conformant receiver read `0300` as 300 units of service.
323
+
324
+ **If you compensated for the old behaviour** - reading `paidUnitsOfService` off `revenueCode`, or
325
+ writing the revenue code into `paidUnitsOfService` - that workaround must be removed. Code that
326
+ only round-tripped through this library saw nothing wrong, because both halves were wrong together.
327
+
328
+ **Sources, and what was NOT read. The TR3 005010X221A1 itself is a paid X12 document; nobody here
329
+ has read it.** Sources 1, 2 and 4 below are free to check and are linked; source 3 is a paid X12
330
+ document, named rather than linked, and every fact taken from it is independently carried by
331
+ source 1 anyway. Read the qualifiers literally, because an unsourced assertion about this table
332
+ baked into a code comment is exactly what shipped the defect:
333
+ 1. **The full X221A1 element table**, from [pyx12](https://github.com/azoner/pyx12)'s
334
+ machine-readable map `pyx12/map/835.5010.X221.A1.xml` - an independent open-source
335
+ implementation of this same guide: `SVC04 / 234 / "National Uniform Billing Committee Revenue
336
+ Code"`, `SVC05 / 380 / "Units of Service Paid Count"`, `SVC06 / C003`,
337
+ `SVC07 / 380 / "Original Units of Service Count"`. **This is the source for SVC-04**, and the
338
+ only one here that carries the whole map.
339
+ 2. **X12's own [RFI #2163](https://x12.org/resources/requests-for-interpretation/rfi-2163-835-svc05-vs-837-svd05)**,
340
+ which names "the SVC05 'Units of Service Paid Count/Quantity' in the 835 guide" and states that
341
+ "a default has been included for SVC05 in guide 005010X221A1". Primary, from X12, naming the
342
+ guide - but it speaks only to SVC-05.
343
+ 3. **The base X12 005010 SVC element dictionary**: SVC-04 is element 234, a string, and SVC-05 and
344
+ SVC-07 are both element 380, Quantity. This does not say what SVC-04 carries, but it **rules
345
+ out a revenue code at SVC-05** on type alone.
346
+ 4. **Published payer companion guides implementing X221A1** ([Florida Health Care
347
+ Plans](https://www.fhcp.com/documents/edi-forms/Florida-Health-Care-Plans-835.pdf) p.44 gives
348
+ all three names; [South Dakota
349
+ Medicaid](https://dss.sd.gov/docs/medicaid/providers/billingmanuals/HIPAA/835_Healthcare_Payment.pdf)
350
+ p.9 gives SVC-04 and SVC-05).
351
+
352
+ **Agreement inside this repo is NOT one of the sources.** The 277 modules use SVC-04 and every
353
+ committed 835 fixture is written to the map above, which is corroborating and was what surfaced
354
+ the defect - but checking a spec claim against this repo's own implementation only proves the two
355
+ agree, which is precisely how the wrong map survived.
356
+
357
+ **An absent SVC-05 is NOT defaulted to one.** X221A1 is reported to assume the value is one when
358
+ not present - that is source 2's Description, quoted secondhand, not a clause read from the TR3.
359
+ This reader leaves `paidUnitsOfService` `undefined` regardless, because fabricating a count the
360
+ sender did not send is inventing data.
361
+
362
+ **`undefined` on either quantity still means "not decoded", not "absent".** The element may have
363
+ been missing, or present and unparseable as a decimal; the two share one `undefined` on the model.
364
+ They no longer share a silence: as of `X12-QUANTITY-SILENT-DEFAULTS` the unparseable case raises
365
+ `X12_UNPARSEABLE_DECIMAL` at that `position.elementIndex` and the absent case raises nothing, so a
366
+ warning at the element is what tells them apart. The verbatim element is still on `tx.segments`.
367
+
368
+ **If you archived 835s this library EMITTED at `0.0.9` or earlier, they are non-conformant on the
369
+ wire and should be re-emitted.** Their revenue code sits in SVC-05, so this library now reads it
370
+ back as a paid quantity (`0300` becomes 300 units) and reports no revenue code, silently and with
371
+ no warning. Re-emitting from the model with this release produces conformant bytes.
372
+
373
+ **The 277's SVC-07 is a different element in a different TR3 and is now decoded and emitted in its
374
+ own right** (`X12-277-SVC07-NOT-DECODED`, below). Do not read either field off the other.
375
+
376
+ - **Bundled code-list snapshots are pre-launch initial subsets, not the full WPC-published lists.**
377
+ CARC, RARC, Claim-Status-Category (CSCC), Claim-Status (CSC), service-type, CLP-status, and
378
+ maintenance-type ship as versioned data artifacts sized to the parser's Tier-1/Tier-2 fixtures plus
379
+ the long-tail codes most workflows branch on. An inbound code **outside** a snapshot still parses:
380
+ the verbatim code is preserved on the model and an `X12_UNKNOWN_*` warning is raised. Only the
381
+ human-readable **description** is absent (`undefined`). A stale or partial snapshot therefore yields
382
+ a missing description, **never a wrong code**. Run `pnpm refresh:code-lists` to audit snapshot
383
+ freshness; regenerating the full lists (`--fetch`) is a redistribution-terms-gated release step (see
384
+ below), not a runtime fetch.
385
+
386
+ - **`serialize(parse(s)) === s` is NOT guaranteed.** `serializeX12` rebuilds the interchange from the
387
+ model, so every segment the parser recorded comes back verbatim (element padding, composites, and
388
+ `?`-release escapes included), in the order the model holds it. Six constructs are known not to
389
+ survive:
390
+ 1. **Line breaks between segments.** Most senders write one after each terminator; the parser
391
+ absorbs any run of CR / LF bytes between segments, so a pretty-printed, double-spaced, or
392
+ mixed-ending file all emit the same compact form. **Silent.**
393
+ 2. **A doubled segment terminator** outside a transaction. It delimits a zero-length segment
394
+ carrying no elements, so there is nothing to retain. **Silent.**
395
+ 3. **A missing final segment terminator.** The emit supplies one. **Silent.**
396
+ 4. **Post-IEA `trailingBytes`**, re-joined from segment slices rather than preserved verbatim.
397
+ 5. **TA1 position.** A TA1 that appeared **after** a functional group is collected onto
398
+ `ix.ta1Segments` and emitted immediately after the ISA, so the emit **reorders** it. **Silent**,
399
+ and unlike the others nothing is lost: the model and the warning stream both round-trip
400
+ identically. It is also the only construct that moves something _else_: a segment outside a
401
+ transaction is placed correctly relative to the groups, but not relative to a TA1 hoisted past
402
+ it. This library takes no position on where ASC X12 requires a TA1 to sit.
403
+ 6. **A segment whose first element is empty (`*A*B~`), outside a transaction.** It has no id for
404
+ the envelope walker to dispatch on, so it is skipped: absent from the model, absent from the
405
+ emit, and it does not even raise `X12_UNEXPECTED_SEGMENT`. **Silent**, and the only case here
406
+ that loses a value with no diagnostic whatsoever. Inside an open transaction the same segment
407
+ is kept and re-emitted normally, so this is specific to the outside-a-transaction position.
408
+
409
+ **Cases 2 to 6 break the round trip on inputs containing no line breaks**, so "my file is compact"
410
+ is not sufficient grounds to expect byte equality. **Five of the six (1, 2, 3, 5, 6) produce no
411
+ warning at all**, so a clean `ix.warnings` is not evidence that a round trip will be byte-exact;
412
+ only case 4 warns.
413
+
414
+ **A segment outside a transaction is no longer on this list.** It used to be, in both halves: the
415
+ segment was discarded from the model, and the emit could not reproduce what the model did not
416
+ hold. It is now retained on `ix.orphanSegments` **and** re-emitted, so the bytes, the value and the
417
+ `X12_UNEXPECTED_SEGMENT` warning all survive. Placement is by the **structural anchor** on
418
+ `X12OrphanSegment.anchor` - which group, which transaction, which offset inside it - and never by
419
+ `segmentIndex`. That distinction is the entire correctness argument, because `segmentIndex` indexes
420
+ the **input** stream while the emit is not in input order (see case 5, and the zero-length segment
421
+ in case 2 occupies an input index that is never emitted). Measured on a two-group interchange with
422
+ a TA1 after the first group, an earlier attempt at _positional_ re-emit landed a stray segment
423
+ inside an 835's `ST..SE` body between `CLP` and `SE`, with no warning at all on the re-parse, and a
424
+ stray `SE` closed the transaction early and corrupted SE-01. An anchor names a slot in the typed
425
+ tree, which is invariant under both reorderings. **Use `segmentIndex` to join an orphan to its
426
+ warning, never to place it.** An anchor is a position into `groups` / `transactions` /
427
+ `rawSegments` as they stand on the interchange you pass in, so reshaping those by hand invalidates
428
+ it; an anchor that resolves to nothing is emitted at interchange level before the `IEA` rather
429
+ than dropped.
430
+
431
+ **One interaction with spec-clean mode is worth knowing.** A `TA1` that arrived between an `ST`
432
+ and its `SE` is lifted off `tx.rawSegments` by the walker but re-emitted where it came from, so it
433
+ IS a segment of that transaction set for SE-01 purposes ("segments included in the transaction
434
+ set, including ST and SE", X12.6). `{ specClean: true }` therefore counts it, and
435
+ `{ recomputeCounts: true }` writes the count that matches the emitted bytes. Counting the model
436
+ alone would shrink a correct SE-01 by one per such segment, which is a count corruption rather
437
+ than a correction. An orphan emitted before the `ST` or after the `SE` is outside the range and is
438
+ not counted. GE-01 and IEA-01 are unaffected: an orphan is never a `GS` and never opens a
439
+ transaction set.
440
+
441
+ What is **measured** for the orphan round trip: a stray segment inserted at every position of a
442
+ two-group, three-transaction interchange, over five segment ids - `ZZ`, `SE`, `GE`, `ST` and `TA1`,
443
+ covering all five orphan `context` values and all three anchor kinds. **50 of the 50 insertions
444
+ that produce an orphan round-trip byte-exactly** on a base with no envelope-level TA1. On the same
445
+ base _with_ one, all **54** differ from their source - and all 54 are byte-identical once the TA1
446
+ is removed from both sides, so the only thing that moved is the TA1 itself, which moves on that
447
+ base with no orphan present at all. Across all 104, the transaction bodies, `orphanSegments` (raw,
448
+ context and anchor), `ta1Segments`, `trailingBytes` and the warning multiset are unchanged by the
449
+ round trip, and every emit is a fixed point.
450
+
451
+ What is **measured** across the 56 committed fixtures: every emit is a fixed point (serializing it
452
+ again is a byte-level no-op) and re-parses to an identical model with an identical warning stream;
453
+ the 14 fixtures with no line breaks return byte-identical; and the other 42 differ from their source
454
+ by **line breaks and nothing else**, with no element value lost, altered, reordered, or re-escaped.
455
+ Two caveats on that corpus, both of which limit how far the sweep can be pushed: it contains **no
456
+ instance of cases 2 to 6** (zero fixtures produce an `orphanSegments` entry, which is why the
457
+ orphan sweep above is constructed rather than drawn from it), and **13 of the 14
458
+ byte-identical fixtures are `golden/*.edi`**, which
459
+ are serializer output by construction, leaving `envelope/no-trailing-crlf.edi` as the only
460
+ independent witness. Preserving the original framing byte-for-byte would need the model to carry
461
+ per-segment framing and TA1 position it does not have today; that is a tracked model change, not a
462
+ behaviour to assume.
463
+
464
+ - **A segment that falls outside a transaction is not decoded into the typed tree, but it is neither
465
+ dropped from the model nor dropped from the emit.** The envelope walker binds body segments
466
+ to an ST..SE transaction and has nowhere in `groups` to put one that arrives outside: a stray
467
+ segment between `GE` and `IEA`, a body segment between an `SE` and its group's `GE`, a body segment
468
+ between `GS` and the first `ST`, an `ST` with no open group, an `SE` or `GE` that closes nothing,
469
+ or a `TA1` inside an open group. Each raises `X12_UNEXPECTED_SEGMENT` **and** is retained verbatim
470
+ on `ix.orphanSegments`, whose `segmentIndex` is the join key back to the warning's
471
+ `position.segmentIndex`, and whose `anchor` is the structural slot `serializeX12` puts it back at.
472
+
473
+ **One of those is not an "outside a transaction" case at all.** `TA1` is envelope-level by spec, so
474
+ a `TA1` inside an open group goes to `ix.orphanSegments` even when it arrived **between an `ST` and
475
+ its `SE`**, and it is lifted out of that transaction's `segments` / `rawSegments`. So for a document
476
+ containing such a `TA1`, `ix.groups` is not the whole typed model. This is long-standing behaviour,
477
+ unchanged here except that the segment is now retained instead of discarded.
478
+
479
+ **One limitation remains, and it is decoding, not retention or re-emission.** No `get*` reader will
480
+ see an orphan: retention and placement are not promotion into the typed tree, and a segment the
481
+ envelope grammar could not place is not one a transaction reader should walk. Read these segments
482
+ from `ix.orphanSegments`. That array is empty for a well-formed
483
+ interchange, so a non-empty one is itself the signal that the sender's framing did not match the
484
+ envelope grammar. **It carries the sender's bytes verbatim, so treat it as PHI**: unlike
485
+ `ix.warnings`, whose messages come from a frozen registry and whose metadata is positional, an
486
+ orphan is document content. Log `context` and `segmentIndex`, not the whole entry.
487
+
488
+ Two related shapes are **not** captured here, both long-standing: a doubled segment terminator
489
+ (a zero-length segment with no elements, so there is nothing to retain) and a segment whose first
490
+ element is empty (`*A*B~`), which has no id for the walker to dispatch on and is skipped with no
491
+ warning.
492
+
493
+ This entry previously documented real silent data loss: the segment was discarded from the model
494
+ entirely, and because a blank line between segments exceeded the old one-CR-plus-one-LF tolerance,
495
+ a uniformly double-spaced file lost its **entire interchange body** and returned `groups: []`. Both
496
+ of those are fixed. The tolerance now absorbs any run of CR / LF bytes between segments, so all 15
497
+ CR/LF sequences of length 0 to 3 frame identically (the old bound admitted 4 of those 15).
498
+
499
+ - **837 claim-/line-level provider addresses (Loop 2310 / 2420 `N3`/`N4`) are not surfaced.** The
500
+ provider **identities** (`NM1`) round-trip, but the street-address lines do not decode onto the
501
+ model. Read them from the raw segments if you need them.
502
+
503
+ - **`get834Enrollments` streams members but still parses the whole file up front.** It yields one
504
+ decoded member per `INS` loop (so a consumer holds one member at a time), but the underlying
505
+ interchange is fully parsed into `tx.segments` before iteration begins. It is not a byte-streaming
506
+ reader for arbitrarily large files.
507
+
508
+ - **A builder refusal message shows at most 63 characters of a value you passed in, and it is bounded
509
+ but not escaped.** Twenty-four sites across ten builder modules interpolate such a value into the
510
+ thrown message. Every one routes through `renderCallerValue`, so the rendered **fragment** never exceeds
511
+ `BUILD_REFUSAL_VALUE_MAX_RENDERED` (**90** characters: 63 of your value, two quotes, an ellipsis, and
512
+ the ` (N characters)` suffix at its widest). Both constants and the function are exported, so you can
513
+ assert the ceiling rather than take it on trust.
514
+
515
+ **The ceiling is on the fragment, not on the message.** A refusal message is that fragment plus the
516
+ site's own fixed template text, which differs per site, so every message is bounded by a constant but
517
+ not by _that_ constant. Measured: a 120,000-character control number produced a **120,066-character**
518
+ `X12BuildError.message` from `buildInterchange` before this change and produces a **150-character**
519
+ one now. Do not read 90 as a message length.
520
+
521
+ Over-long values are the point of nine of the twenty-four sites (the `control number "…" exceeds the
522
+ N-char spec limit` refusal, one per emitting module, where the branch fires **because** the value is
523
+ over-long). Seven more had no length gate at all: `build999`'s ST-02 trace twice, `buildInterchange`'s
524
+ transaction-set id, `build837`'s service-line variant, `build834`'s INS-03 and HD-01 maintenance
525
+ types, and `buildTA1`'s note code. The last seven are all in `build999` and were found by adversarial
526
+ review rather than by the original census. Four are the AK9-02 / AK9-03 / AK9-04 counts, typed
527
+ `number` and so missed by a census of string-typed fields, though a spec built from `JSON.parse` can
528
+ still carry a string there (measured at 120,063 characters). Three read `.length` off a
529
+ caller-supplied array, which a forged `{ length: … }` drove to 120,152 characters.
530
+
531
+ **What this is and is not.** These are values **you** passed in, so bounding them redacts nothing:
532
+ you already hold the value, and if you put patient data in a control number the refusal will show up
533
+ to 63 characters of it. What the bound buys is that `Error.message` from a builder has a fixed
534
+ ceiling instead of growing with your input, which matters for log lines, crash reports, and JSON
535
+ error envelopes. The surviving characters are **not escaped** either: they are whatever you supplied,
536
+ including a newline or a segment terminator, so a refusal message is bounded but not guaranteed to be
537
+ a single log line.
538
+
539
+ **The sites that applies to are the ones whose own template names the field:** a control number, an
540
+ X12 control code, a count. A refusal will not show you a `claimId`, a member id, a member name, a
541
+ trace or a diagnosis code. That was true of the templates and **not** of the shared type guards
542
+ underneath them until the release after `0.0.10`: those describe a wrong-typed element, they stand
543
+ on **every element of every builder**, and they used to render the offending primitive
544
+ (`a number ("900412345678")` for a `JSON.parse`d `patientControlNumber`) bounded to 90 characters
545
+ and not redacted. A guard that cannot tell which slot it is standing on cannot decide that echoing
546
+ is safe, so the string, segment and decimal guards report the TYPE and never the value, and so does
547
+ the array guard's primitive arm. If you were reading a value back out of one of those messages, that
548
+ is a behaviour change.
549
+
550
+ **Two things that sentence does NOT say, both deliberate.** The array guard still reports the
551
+ `length` and the class tag of a forged array-like, bounded through the same renderer: those describe
552
+ the SHAPE you forged rather than the contents of a document element, and they are the whole
553
+ diagnostic for `{ length: "9".repeat(120000) }`. And only the segment-join guard names the SLOT
554
+ alongside the type (`build999: "AK9"-01 must be a string, ...`), because it holds the whole segment;
555
+ the escape-helper and `X12Decimal` guards name the **builder**, so on those two the value used to
556
+ stand in for a locator and now nothing does. That is a real diagnostic cost, and it is written down
557
+ rather than hidden.
558
+
559
+ - **`defineProfile()` refusals are bounded on the same terms, since `0.0.6`.** `X12ProfileError.message`
560
+ used to interpolate your profile name, quirk id, effect, fixture path and expected-warning codes
561
+ verbatim. Measured before the fix, the worst message was **360,181 characters**, at the `fixture`
562
+ refusal, which names three caller values (the profile name, the quirk id, and the `JSON.stringify`d
563
+ path). It reaches that size because the quirk-id pattern carries no length bound (its comment
564
+ claimed "2-64 characters"; the regex never said so, and the comment was corrected to the code rather
565
+ than the grammar tightened). Twelve refusal sites hold twenty-three such holes between them; all
566
+ twenty-three now route through `renderCallerValue` or, where the value's **type** is what is wrong,
567
+ `renderCallerJson`, which keeps `null` distinguishable from `"null"` and bounds the JSON text. The
568
+ same `fixture` refusal now measures **431 characters**.
569
+
570
+ **431 is a measurement at a 120,000-character value, not a maximum.** The ` (N characters)` suffix
571
+ widens with the decimal width of the length, so the same refusal measures 434 at 1,000,000 and 437
572
+ at 10,000,000 characters. That site's ceiling, derived from its fixed text plus its three fragment
573
+ ceilings, is **443**, and the suite asserts every one of the twelve under 500.
574
+
575
+ Everything the builder paragraph says about scope applies here unchanged: it redacts nothing (you
576
+ passed the value in), the surviving characters are **not escaped**, and the bound is on UTF-16 code
577
+ units rather than bytes.
578
+
579
+ **`X12ProfileError.profileName` is deliberately NOT bounded.** It exists so you can pinpoint which
580
+ of your definitions failed, and truncating it would stop it matching the name you passed. It is your
581
+ own string and you still hold it. If you log a caught `X12ProfileError`, log `err.message` (bounded)
582
+ rather than the whole error object.
583
+
584
+ - **A builder used to emit a NUMBER as an EMPTY element, silently. FIXED in `0.0.9`: it now
585
+ REFUSES, and does not coerce.** The escape helper every builder ran values through read
586
+ `value.length`, which is `undefined` for a number, so it returned the empty string. The types say
587
+ `string`, so TypeScript callers could not reach it; a JavaScript or JSON-driven caller could, and
588
+ one that reads a spec off `JSON.parse` did.
589
+
590
+ Measured at `0.0.8` on an otherwise valid `build835` spec whose `patientControlNumber` was a number
591
+ rather than a string:
592
+
593
+ ```
594
+ CLP**1*500.00*450.00*50.00*MB*ICN-9001*11::1 ix.warnings.length === 0
595
+ ```
596
+
597
+ CLP-01 is required by TR3 005010X221A1 Loop 2100 and is the key that reassociates the remittance
598
+ back to the 837's CLM-01. It was dropped, **no warning was raised, no refusal was thrown**, and the
599
+ builder returned a frozen interchange that looked successful. The `patientControlNumber === ""`
600
+ guard did not catch a number, because the value was not yet a string when it was checked. The same
601
+ one-line mechanism reached every `esc()`-rendered slot in all nine builders, including the 837's
602
+ `CLM-01`, the other end of that same reassociation link.
603
+
604
+ **From `0.0.9` a non-string reaching that escape helper draws that builder's own typed, code-tagged
605
+ refusal** (`X12_835_BUILD_INVALID_SPEC` and its eight siblings) before anything is emitted. It
606
+ covers `number`, `boolean`, `null`, `undefined`, arrays, objects, functions, symbols and bigints.
607
+
608
+ **The library deliberately does NOT coerce, and you should know why before you reach for
609
+ `String(value)`.** A JSON payload that carried `"0012345"` as a number has already lost the leading
610
+ zeros; coercing would emit `12345`, a well-formed identifier that is **not the one you sent**, and a
611
+ remittance that reassociates to the wrong claim is worse than one that fails to reassociate at all.
612
+ `String(1e21)` is `"1e+21"`, `String(NaN)` is `"NaN"` and `String(0.1 + 0.2)` is
613
+ `"0.30000000000000004"`. None are valid in an `AN`, `ID` or `Nn` element. **Convert at your own
614
+ boundary, where you can still see whether the leading zeros mattered.**
615
+
616
+ **THE TYPE CHECK IS NOW STRUCTURAL, AND THE DELIMITER ESCAPE IS NOT.** `0.0.9` guarded values
617
+ routed through the escape helper, and not every element position went through it - two drafts of
618
+ this entry published a counted enumeration of the gaps and **both were measured incomplete**.
619
+ Rather than count a third time, the library moved the check to the one place every element must
620
+ pass: **the segment join**. So the statement is now a property instead of a list:
621
+
622
+ > **No non-string value reaches an element of a segment emitted through a builder's segment
623
+ > joiner.** A number, `null`, `undefined`, a boolean or an object in an element slot draws that
624
+ > builder's own typed, code-tagged refusal, naming the slot the way the spec does
625
+ > (`build999: "AK9"-01 must be a string, …`).
626
+
627
+ The slot in that locator is itself admitted only when it matches the X12 segment-id grammar (two or
628
+ three uppercase alphanumerics opening with a letter), because `buildInterchange` takes
629
+ `[segmentId, ...elements]` from you wholesale. Free text in element 0 degrades to `element N` rather
630
+ than reaching the message.
631
+
632
+ **Read the "through a builder's segment joiner" qualifier literally.** `buildTA1` does not use one,
633
+ and is therefore **not covered**: it emits its five caller-supplied elements with a direct join, no
634
+ escape and no padding, so a numeric or `undefined` `interchangeControlNumber` is emitted silently
635
+ (`TA1**250101*1200*A*000`). TA1-01 is the reassociation key from the acknowledgment back to the
636
+ interchange it acknowledges, so that matters. Unchanged from `0.0.9` and tracked as its own item.
637
+
638
+ The monetary slots got their own guard on top of it: a slot typed `X12Decimal` refuses anything that
639
+ is not one, rather than rendering `number.toString()` into the document.
640
+
641
+ **What is still worth validating at your own boundary,** because it is a list and lists have the
642
+ weakness described above:
643
+ 1. **A `string` carrying an active delimiter.** The segment guard passes it, because it is a
644
+ string. Slots that route through the escape helper release it correctly (`"1*BOGUS"` emits as
645
+ `1?*BOGUS`); the fixed-width ISA slots do not go through it at all.
646
+ 2. **Whether an `X12Decimal` carries the SCALE you meant.** `fromString("0.3")` and
647
+ `fromString("0.30")` are both accepted and both emit verbatim. That choice is yours.
648
+ 3. **Anything `buildTA1` emits**, per the paragraph above.
649
+ 4. **`build835`'s balance-equation amounts, which refuse UNTYPED.** `build835` runs its balance
650
+ guard before it builds the escape helper, and that guard calls `X12Decimal` methods on your
651
+ value. So a raw `number` there throws a plain `TypeError` with **no `code`** - some saying the
652
+ value "has no internal state - was it tampered with?", which is a misleading thing to be told
653
+ when you passed a number. **The rule, not a list:** a slot refuses untyped exactly when the
654
+ balance guard reads it as a term of one of the three TR3 X221A1 §1.10.2 invariants (the claim,
655
+ service-line and remit-total equations). **Named by SPEC FIELD rather than element number,
656
+ because a draft that used element numbers got one wrong:** the untyped set is
657
+ `payment.totalActualPayment`, `claim.totalChargeAmount`, `claim.totalPaymentAmount`, every
658
+ `adjustments[].amount` at claim and line level, `serviceLine.chargeAmount`,
659
+ `serviceLine.paymentAmount` and `providerAdjustments[].amount`. Every other `X12Decimal` field
660
+ refuses typed, including `claim.patientResponsibilityAmount`, `serviceLine.paidUnitsOfService`,
661
+ every `amounts[].amount` and every `adjustments[].quantity`. Unchanged from `0.0.9`; `err.code` is still the thing to branch on, and the
662
+ balance terms are the case where there is not one.
663
+
664
+ **Closed in the release after `0.0.9`, and recorded because the behaviour changed for callers who
665
+ were relying on it.** Each of these emitted silently before and refuses now:
666
+ 1. **Monetary and quantity slots read `.toString()`, so a raw number passed the check.** Measured
667
+ with `warnings.length === 0` in every case: a `patientResponsibilityAmount` of `0.1 + 0.2`
668
+ emitted `CLP*PT-ACCT-001*1*500.00*450.00*0.30000000000000004*…`, `1e21` emitted `…*1e+21*…`,
669
+ `NaN` emitted `…*NaN*…`, and an 837 service-line `units` of `0.1 + 0.2` emitted
670
+ `SV1*HC:99213*150.00*UN*0.30000000000000004***1`. Two of those three the library **cannot parse
671
+ back** - it rejects exponent notation and `NaN` outright - so they did not round-trip.
672
+ 2. **Some string-typed positions never called the escape helper at all**, so a number was emitted
673
+ verbatim with no warning: `build999`'s `envelope.groupControlNumber` (GS-06 / GE-02),
674
+ `envelope.transactionSetControlNumber` (ST-02 / SE-02), `functionalGroup.disposition` (AK9-01)
675
+ and `transactionResponses[].disposition` (IK5-01); `build278`'s `review.levelCode` (HL-03);
676
+ `envelope.groupDate` / `envelope.groupTime` (GS-04 / GS-05) in every domain builder; and
677
+ `build837`'s `serviceLine.lineNumber` (LX-01). **AK9-01 was the one to know about:** it is an
678
+ `ID` element bound to X12 code list 715, so a number there told the receiver nothing about
679
+ whether the functional group was accepted, and the library's own accept-with-errors guard
680
+ compares it against `"A"` and did not fire. These positions also admitted an **unescaped
681
+ delimiter**: `build999` with a `groupControlNumber` of `"1*BOGUS"` emitted
682
+ `GS*FA*…*1*BOGUS*X*005010X231A1`, shifting GS-07 and GS-08 by one. They are routed through the
683
+ escape helper now, so that reads `1?*BOGUS`.
684
+ 3. **The fixed-width ISA slots** (`senderId`, `receiverId`, `interchangeControlNumber`, …) go
685
+ through padding rather than escaping, and **are still not covered** - they do not pass through
686
+ the segment join either. A number there throws an untyped `TypeError` with no `code`
687
+ (`value.slice is not a function`), and a numeric `interchangeControlNumber` throws the builder's
688
+ typed refusal with the **misleading** text "exceeds the 9-char spec limit". Both terminate
689
+ rather than emitting silently, which is why they were the smaller hazard and why they are left
690
+ alone. GS-04 and GS-05 were the counter-example that made this distinction worth stating - they
691
+ are envelope elements and they WERE silent. They are covered now; ISA still is not.
692
+
693
+ **One other change, and it is a behaviour change:** the exported `escapeRelease(value, delimiters)`
694
+ now **throws `TypeError` on a non-string** rather than returning `""`. If you call it directly, it
695
+ is a `TypeError` and not a code-tagged library error, because it is a pure text utility with no
696
+ spec context to name. Nothing inside the library can reach it: the builders refuse first. A boxed
697
+ `new String("…")` is also refused now, where it built at `0.0.8`.
698
+
699
+ - **A forged non-array in a builder spec refuses; in a few places it throws an untyped `TypeError`.**
700
+ The types say `readonly T[]`, but a JavaScript or JSON caller can hand a builder something else. As
701
+ of `0.0.6` every indexed loop in every builder takes its bound from a checked array (32 loops across
702
+ 7 modules), so an object like `{ length: "9".repeat(120000) }` draws that builder's own typed
703
+ refusal - previously the length coerced to `Infinity` and the builder **looped forever instead of
704
+ refusing**. Measured across the nineteen probes the suite ships: at base **16 hung** and 3 threw an
705
+ untyped `TypeError`; at head **17 refuse with a typed, coded error** and 2 still throw the untyped
706
+ `TypeError`.
707
+
708
+ **`null` is treated as absent, not forged**, exactly as the `?? []` this replaced did, so an
709
+ optional list you send as `null` still builds. On a required list `null` draws that builder's own
710
+ "at least one X is required" refusal instead of an untyped `TypeError` for five of the six:
711
+ `build834`, `build820`, `build837`, `build271` and `build277`. **`build835`'s `claims` is the
712
+ exception** and still throws an untyped `TypeError`, as it did at base.
713
+
714
+ Not covered, and disclosed rather than fixed: the places a builder reads a caller array with
715
+ `for…of` - `buildInterchange`'s `spec.groups`, `build999`'s `functionalGroup.transactionResponses`,
716
+ and every optional leaf array such as `claim.dates` or `member.references`. Those throw
717
+ `TypeError: … is not iterable` immediately. They terminate, so they are not the hang, but they carry
718
+ **no `code`**, so `err.code` is `undefined` and you cannot branch on it. Validate your spec shape at
719
+ your own boundary if it comes from JSON.
720
+
721
+ **One qualification worth stating precisely: on the acknowledgment path the value is not always
722
+ strictly your own.** TR3 005010X231A1 requires AK2-02 to be a verbatim copy of the acknowledged
723
+ transaction set's ST-02, and `buildTA1` exists to echo an inbound ISA-13, so a _document's_ control
724
+ numbers reach those refusals by the standard's own design. They are envelope control numbers rather
725
+ than clinical content, and they are bounded like everything else, but "the value is always the
726
+ caller's own" would be false and this library does not claim it.
727
+
728
+ Logging `err.code` rather than `err.message` remains the safest habit, and the parse side is stronger
729
+ still, where no factory takes a value parameter at all.
730
+
731
+ - **`X12ParseError.snippet` on a Tier-3 fatal can carry PHI, by design, and the library does not
732
+ redact it.** Warning messages come from a frozen registry and no factory takes a value parameter, so
733
+ they cannot echo an element; the four structural fatals are different, because they are raised
734
+ before the envelope is readable and each carries a bounded (≤ 64 character) copy of the start of
735
+ the input so the error is actionable. On real traffic those bytes can be patient data. Redact at
736
+ your call site, or log `err.code` and `err.position` and drop `err.snippet`. A **strict-mode
737
+ escalation carries no snippet**: the error it raises wraps a registry-built warning, so `err.snippet`
738
+ is `""`.
739
+
740
+ - **Two classes of locator-flavoured model field are left unbounded on purpose, and a downstream
741
+ package should not interpolate them.** `X12HierarchicalLevel.hlId` / `.parentHlId` / `.levelCode` /
742
+ `.hasChild` (and the shared `X12Hl`) stay byte-verbatim because collapsing two distinct
743
+ non-conformant HL ids to one sentinel would make them compare equal and silently merge two
744
+ subscribers' claims into one hierarchy; a wrong hierarchy is worse than a wide locator. The 999's
745
+ error report is the other class: `X12Ack999Ak1.functionalIdCode`,
746
+ `X12Ack999Ak2.transactionSetIdCode`, `X12Ack999Ik3.segmentIdCode` / `.loopIdentifier` and
747
+ `X12Ack999Ik4.dataElementReferenceNumber` are the trading partner's report of
748
+ where **they** found a problem, which is the content a 999 exists to deliver. `X12Segment.id` is
749
+ bounded to the X12 segment-id grammar (a non-conformant first element yields
750
+ `NON_SPEC_SEGMENT_ID`) precisely because it is a derived locator with no such argument; the bytes
751
+ stay on `seg.raw` and `seg.elements[0]`. **No warning code is raised for that substitution, and no
752
+ code distinguishes two different non-conformant ids through `seg.id`.** That is deliberate rather
753
+ than an oversight: it bounds a derived field, it is not a parse deviation, and every walker
754
+ dispatches on an exact segment-id match, so a first element outside the grammar could never have
755
+ matched a walker in the first place. Compare `seg.id === NON_SPEC_SEGMENT_ID` when you need to
756
+ detect one, and read `seg.elements[0]` for which one it was.
757
+
758
+ - **`X12Ack999Ik4.copyOfBadDataElement` is a copy of the offending bytes and can carry PHI.** It is
759
+ whatever the sender put in IK4-04; the library never auto-populates it, and senders SHOULD omit it
760
+ when the bytes are PHI.
761
+
762
+ - **Balance and integrity checks warn; they never rebalance or renumber.** The 835 TR3 §1.10.2 balance
763
+ invariants, 837 HL parent-pointer integrity, and envelope-count reconciliation surface a warning on
764
+ mismatch and preserve the inbound values verbatim. The library will not "fix" a payer artifact for
765
+ you. Gate your own posting/adjudication on the warning.
766
+
767
+ ## Conformance testing not yet wired
768
+
769
+ - **No external-oracle differential corpus yet.** A best-effort differential harness against CMS
770
+ Medicare 835 public examples (and/or another external X12 reader) is planned for the first real
771
+ release but is **not yet wired**, pending a redistribution-terms review of the CMS sample material.
772
+ Conformance today rests on the three-tier synthetic corpus (spec-clean → vendor-quirk → round-trip
773
+ goldens), property/round-trip tests, and a nightly amplified byte-flip fuzz job, not on parity with
774
+ a third-party implementation. Do not assume byte-for-byte agreement with any specific vendor parser.
775
+
776
+ ## Scope (non-goals for v1)
777
+
778
+ - **Healthcare HIPAA 005010 only.** Non-healthcare transaction sets (850/856/810/204, etc.), the
779
+ EDIFACT syntax family, and pre-005010 versions are out of v1 scope. Pre-005010 input is tolerated
780
+ and flagged (`X12_PRE_005010`), not decoded to those older field maps.
781
+ - **No transport.** AS2, SFTP, and MLLP-style delivery are out of scope. This is a parser/serializer,
782
+ not a communications stack.
783
+ - **Published, still pre-alpha.** The package is published on npm as `@cosyte/x12` from a public
784
+ repo, but it stays on the `0.0.x`-until-first-alpha ladder. `npm view @cosyte/x12 version` is the
785
+ only source of truth for the current version, so this page does not restate one. Treat the API as
786
+ pre-alpha and pin the exact version until the first alpha.
787
+ - **No typed model for the 270 and 276 inquiries.** Every other v1 transaction has both a
788
+ per-transaction reader and a domain builder. The 270 eligibility inquiry and the 276 claim-status
789
+ inquiry have neither: they parse into segments, composites, and dot-paths like any other X12 input,
790
+ and the responses (271, 277) decode fully, but the inquiry directions have no typed surface yet.
791
+
792
+ ## Code-list `--fetch` regeneration
793
+
794
+ `pnpm refresh:code-lists` (default) validates the bundled snapshots and prints a freshness audit,
795
+ offline and CI-safe. The `--fetch` mode that would **regenerate** the full lists from their canonical
796
+ WPC / X12 sources is deliberately **not** run in automation: redistributing the full WPC code
797
+ descriptions requires a redistribution-terms review that has not cleared, and it needs outbound
798
+ network. The tool prints the canonical source manifest and exits rather than fabricating descriptions
799
+ the maintainers have not reviewed.
800
+
801
+ ---
802
+
803
+ For the phase-by-phase surface and the exact fields each helper decodes, see the package's
804
+ [`CLAUDE.md`](./CLAUDE.md) status section and the [Cookbook](./docs-content/cookbook.md).