@cosyte/x12 0.0.3 → 0.0.5

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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,357 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - **`renderCallerValue`**, plus the `BUILD_REFUSAL_VALUE_MAX_LENGTH` (63) and
13
+ `BUILD_REFUSAL_VALUE_MAX_RENDERED` (90) bounds. This is the single sanctioned route a
14
+ caller-supplied value takes into a `build*` refusal message, and both ceilings are exported so a
15
+ consumer can assert them rather than take them on trust - the builder-side counterpart to
16
+ `ALL_WARNING_MESSAGES` on the parse side. `BUILD_REFUSAL_VALUE_MAX_RENDERED` bounds the rendered
17
+ **fragment**, not the whole message: a message is that plus the site's own fixed template text.
18
+ - **`X12OrphanSegment.anchor`**, plus the `X12OrphanAnchor` / `X12OrphanAnchorKind` types. Every
19
+ retained orphan now records **where it sat in the structure** rather than only where it sat in the
20
+ byte stream: `{ kind: "interchange", groupIndex }` for a segment outside every functional group,
21
+ `{ kind: "group", groupIndex, transactionIndex }` for one inside a group but outside every
22
+ transaction set, and `{ kind: "transaction", groupIndex, transactionIndex, segmentOffset }` for one
23
+ inside an open `ST..SE` - which only a `TA1` can be, since anything else arriving there is body
24
+ content. An index equal to the eventual length means "after the last one" (immediately before the
25
+ `GE` or the `IEA`), and `segmentOffset` is never `0` because `rawSegments[0]` is always the `ST`.
26
+ - **`X12Interchange.orphanSegments`** and the `X12OrphanSegment` type. Every segment that falls
27
+ outside an `ST..SE` transaction set is now retained verbatim (`raw`, the decoded `segment`, its
28
+ `segmentIndex`, and the library-owned `context` discriminant) instead of being discarded.
29
+ `segmentIndex` equals the `position.segmentIndex` of that segment's `X12_UNEXPECTED_SEGMENT`
30
+ warning, so the two surfaces join without string matching. Empty for a well-formed interchange.
31
+ **Treat it as PHI when logging.** It sits on the model side of this library's diagnostic boundary:
32
+ a warning `message` is a frozen-registry lookup with positional metadata only, but an orphan is
33
+ document content, verbatim, exactly like `tx.rawSegments`. Log `context` and `segmentIndex`.
34
+
35
+ ### Fixed
36
+
37
+ - **A `build*` refusal message no longer grows with the value you passed in.** All twenty-three
38
+ caller-value slots across the ten builder modules route through `renderCallerValue`, capping the
39
+ rendered fragment at 90 characters. Nine are the `control number "…" exceeds the N-char spec limit`
40
+ refusal, where the branch fires _because_ the value is over-long; seven had no length gate at all
41
+ (`build999`'s ST-02 trace twice, `buildInterchange`'s transaction-set id, `build837`'s service-line
42
+ variant, `build834`'s INS-03 and HD-01 maintenance types, `buildTA1`'s note code); and seven are in
43
+ `build999`, found by adversarial review rather than by the census - the AK9-02 / AK9-03 / AK9-04
44
+ counts, typed `number` but reachable with a string from a `JSON.parse`d spec at 120,063 characters,
45
+ and three `.length` reads on caller-supplied arrays that a forged `{ length: … }` drove to 120,152.
46
+ Measured: a 120,000-character control number produced a **120,066-character**
47
+ `X12BuildError.message` from `buildInterchange` and now produces a **150-character** one. (150, not
48
+ 90: the 90 is the ceiling on the interpolated fragment, not on the message.) **This is robustness
49
+ and log hygiene, not redaction, and the docs now say so** - you passed the value in and still hold
50
+ it, so bounding it hides nothing from you; what it buys is a fixed ceiling on anything that reaches
51
+ a log line or a JSON error envelope. The surviving characters are bounded but **not escaped**, and
52
+ on the ack path the value is not always strictly your own (TR3 005010X231A1 has AK2-02 echo the
53
+ acknowledged ST-02 verbatim). `err.code` remains the thing to branch on.
54
+ - **A builder refusal handed a non-string where the types say `string` stays a typed error.**
55
+ `renderCallerValue` coerces rather than reading `.length` off whatever it is given, so a spec built
56
+ from `JSON.parse` that carries a numeric control number still raises `X12BuildError` with its
57
+ `code`, instead of an uncaught `TypeError` with none.
58
+ - **The 835 remit-total balance warning points at the BPR instead of at the ST.** Its
59
+ `position.segmentIndex` was a literal `0`, which reads like "no segment" but is not one:
60
+ `tx.segments[0]` is the `ST`, so a consumer resolving the position landed on a segment with nothing
61
+ to do with the invariant. It is now the BPR's own 1-based body index, so
62
+ `tx.segments[w.position.segmentIndex]` is the segment carrying the BPR-02 the equation compares
63
+ against, matching the treatment claim-level and service-line warnings already had. The only
64
+ remaining `0` is a transaction that carries no BPR at all. The corresponding position inside
65
+ `build835` stays synthetic and is now named and documented as such: the builder has no parsed
66
+ segment stream to index into, and it consumes only the warning's `message`, which is a
67
+ registry lookup keyed by the invariant and therefore position-independent.
68
+ - **A segment outside a transaction now survives a round trip, and so does its warning.**
69
+ `serializeX12` re-emits every entry of `ix.orphanSegments` at its structural `anchor`, so a
70
+ consumer who serializes an interchange and re-derives warnings from the copy no longer loses the
71
+ segment or the `X12_UNEXPECTED_SEGMENT` that described it. Placement is **by the anchor and never
72
+ by `segmentIndex`** - that index addresses the _input_ stream, which the emit does not follow, and
73
+ a replay keyed on it was measured splicing a stray segment into an 835's `ST..SE` body with no
74
+ warning on the re-parse. An anchor names a slot in the typed tree, which is invariant under both
75
+ the `ta1Segments` hoist and the skipped zero-length segment. Use `segmentIndex` to join an orphan
76
+ to its warning, never to place it.
77
+
78
+ Measured on a stray segment inserted at every position of a two-group, three-transaction
79
+ interchange, over five segment ids (`ZZ`, `SE`, `GE`, `ST`, `TA1`) covering all five orphan
80
+ `context` values and all three anchor kinds: **50 of the 50 insertions that produce an orphan
81
+ round-trip byte-exactly** on a base with no envelope-level `TA1`. On the same base _with_ one, all
82
+ **54** differ - and all 54 are byte-identical once the `TA1` is removed from both sides, so the
83
+ only thing that moved is the `TA1`, which moves on that base with no orphan present at all. Across
84
+ all 104: transaction bodies, `orphanSegments` (raw, context, anchor), `ta1Segments`,
85
+ `trailingBytes` and the warning multiset are unchanged by the round trip, and every emit is a fixed
86
+ point.
87
+
88
+ **SE-01 now counts an orphan re-emitted between the `ST` and the `SE`.** A `TA1` that arrived
89
+ inside an open transaction set is lifted off `tx.rawSegments` by the walker but is re-emitted
90
+ where it came from, so it is a segment of that transaction set for SE-01 purposes ("segments
91
+ included in the transaction set, including ST and SE", X12.6). Reconciling against the model alone
92
+ would describe bytes the serializer did not write: `{ specClean: true, recomputeCounts: true }`
93
+ would shrink a **correct** `SE*4*` to `SE*3*` over four emitted segments, and the inverse input
94
+ would draw no mismatch warning at all. Both counts now come from the emitted range, so recompute
95
+ is idempotent under the library's own reconciliation. An orphan emitted before the `ST` or after
96
+ the `SE` is outside the range and is not counted, and GE-01 / IEA-01 are unaffected because an
97
+ orphan is never a `GS` and never opens a transaction set.
98
+
99
+ **`KNOWN-LIMITATIONS.md` is therefore down from seven constructs to six**, and the remaining five
100
+ silent ones no longer include anything that loses a warning: line breaks, a doubled terminator, a
101
+ missing final terminator, the `TA1` reorder, and a segment whose first element is empty outside a
102
+ transaction (still the one construct that loses a value with no diagnostic at all - it is skipped
103
+ by the walker, so there is nothing on the model to re-emit, and it is deliberately unchanged here).
104
+ Retention and placement are still **not** promotion: no `get*` reader sees an orphan, and a `TA1`
105
+ inside a group still does not join `ta1Segments`. No new warning code (registry unchanged at 22
106
+ codes, 4 fatals), and no construct became fatal.
107
+
108
+ - **Silent data loss: a segment outside a transaction was dropped from the model, and a
109
+ double-spaced file lost its entire interchange body.** Two defects with one cause. The envelope
110
+ walker raised `X12_UNEXPECTED_SEGMENT` for a segment it could not place and then discarded it, so
111
+ the segment's bytes were unrecoverable. Separately the line-break tolerance was exactly one
112
+ optional CR then one optional LF, which admitted **4 of the 15** CR/LF sequences of length 0 to 3;
113
+ the other 11 left a break in the stream that opened an unrecognized segment, so via the first
114
+ defect a uniformly **double-spaced file returned `groups: []`**.
115
+
116
+ The parser now absorbs any run of CR / LF bytes between segments (safe because a CR or LF in the
117
+ segment-terminator position, the byte immediately after ISA-16, is refused as the Tier-3 fatal
118
+ `X12_INVALID_DELIMITERS` (as it is at all four delimiter positions), so such a run is never
119
+ structural), and every unplaceable segment is retained on `ix.orphanSegments` through a
120
+ single chokepoint that raises the warning and records the segment together, so the two can never
121
+ disagree. Measured before, then after: CR/LF sequences of length 0 to 3 that frame correctly, **4
122
+ of 15 then 15 of 15**; orphan cases that retain the segment, **0 of 10 then 10 of 10** (ten
123
+ constructed cases over nine distinct positions). Across the
124
+ 56 committed fixtures nothing changed: zero model divergences, zero warning divergences, zero
125
+ fixed-point failures, and no fixture produces an orphan.
126
+
127
+ **This fixed the model; the emit was closed separately, by anchor.** See the round-trip entry
128
+ below. A positional replay keyed on `segmentIndex` was built and then removed during _this_ change
129
+ because it was unsound: the emit is not in input order (it hoists `ta1Segments`) and skips the
130
+ zero-length segment a doubled terminator produces, so replaying by input index spliced the orphan
131
+ into whatever occupied that slot. Measured on a two-group interchange with a TA1 after the first
132
+ group, that put a stray segment inside an 835's `ST..SE` body between `CLP` and `SE` with **no
133
+ warning at all** on the re-parse, made a stray `SE` close the transaction early and corrupt SE-01,
134
+ and carried an orphan across the IEA into `trailingBytes`. A documented omission was preferable to
135
+ silent structural corruption, and there are regression tests fencing each of those shapes.
136
+
137
+ **Retention is not placement.** An orphan is not decoded by any `get*` reader, and a `TA1` inside
138
+ an open group is not added to `ta1Segments` (that surface means "envelope-level TA1", and is what
139
+ `parseTA1` reads). Neither a doubled segment terminator nor a segment whose first element is empty
140
+ is recorded as an orphan; both are long-standing behaviour, now stated rather than implied.
141
+
142
+ Also in this change: a trailing CR/LF run after the final segment terminator is absorbed rather
143
+ than surfacing as `trailingBytes` (previously `~\n\n` there produced a `trailingBytes` of `"\n~"`,
144
+ a byte the input never contained, plus an `X12_TRAILING_GARBAGE` warning); a double-spaced file
145
+ that previously produced 7 warnings now parses cleanly with none; and the five
146
+ `X12_UNEXPECTED_SEGMENT` messages were rewritten, since each stated that the segment was not
147
+ retained. No warning code was added or removed (registry unchanged at 22 warnings and 4 fatals) and
148
+ nothing new throws.
149
+
150
+ ### Documentation
151
+
152
+ > **Read the "Fixed" entry above first: it ships in the same release and supersedes two statements
153
+ > made below.** The entry below describes the state of the parser BEFORE that fix, and two of its
154
+ > present-tense sentences are no longer true as of this release: the parser now absorbs **any run of
155
+ > CR / LF** between segments, not "an optional CR then an optional LF"; and a segment outside a
156
+ > transaction is **retained on `ix.orphanSegments`**, not "discarded". What still holds from it, and
157
+ > is the reason it is kept, is the round-trip scope: `serialize(parse(s)) === s` is not guaranteed,
158
+ > the absence of line breaks is not sufficient, `KNOWN-LIMITATIONS.md` is the canonical list at
159
+ > **seven** constructs, and an orphan is still not re-emitted.
160
+
161
+ - **Corrected the round-trip claim: `serialize(parse(s)) === s` is NOT guaranteed, and the emit is
162
+ byte-faithful only for the segments the parser recorded on the model.** The README described the
163
+ default emit mode as "byte-faithful by default" with no qualification, and line-ending handling was
164
+ absent from the entire consumer surface (zero mentions across the README, all nine `docs-content/`
165
+ pages, and `KNOWN-LIMITATIONS.md`). Meanwhile the parser absorbs an optional CR then an optional LF
166
+ after every segment terminator and the model has nowhere to record it. Re-measured against the
167
+ committed corpus: **42 of the 56 fixtures do not return byte-identical, and all 42 differ from their
168
+ source by line breaks and nothing else**; the remaining 14 carry no line breaks and do return
169
+ byte-identical.
170
+
171
+ Nothing about the emit changed. What changed is the claim. Line breaks turned out to be only the
172
+ most common of **six** constructs the emit does not reproduce, and the other five fire on inputs
173
+ containing no line breaks at all, so "my file is compact" is not sufficient grounds to expect byte
174
+ equality: segments outside a transaction (raised as `X12_UNEXPECTED_SEGMENT` on the first parse, then
175
+ discarded, so the segment **and its warning** are absent from the emit), a doubled segment terminator
176
+ outside a transaction, a missing final terminator (the emit supplies one), post-IEA `trailingBytes`
177
+ (re-joined from segment slices rather than preserved verbatim), and a **TA1 that followed a
178
+ functional group**, which is collected onto `ix.ta1Segments` and emitted immediately after the ISA,
179
+ so the emit **reorders** it (nothing is lost there: the model and warning stream round-trip
180
+ identically, and no position is taken on where ASC X12 requires a TA1 to sit). **Four of the six are
181
+ silent**, so a clean `ix.warnings` is not evidence that a round trip will be byte-exact.
182
+ `KNOWN-LIMITATIONS.md` now holds the canonical list, and the other sites link to it and carry the
183
+ load-bearing warnings rather than restating a count: `serialize(parse(s)) === s` is not guaranteed,
184
+ the absence of line breaks is not sufficient, and `serializeX12(parseX12(source))` must not be used
185
+ as a normalization step before comparing warnings.
186
+
187
+ The properties that hold are now stated **as measured over the committed corpus** rather than as
188
+ universals: every emit is a fixed point and re-parses to an identical model with an identical warning
189
+ stream, the 14 line-break-free fixtures return byte-identical, and the other 42 differ by line breaks
190
+ and nothing else. Two caveats bound that sweep and are stated with it: the corpus contains no
191
+ instance of the five non-line-break cases, and 13 of the 14 byte-identical fixtures are
192
+ `golden/*.edi`, which are serializer output by construction, leaving `envelope/no-trailing-crlf.edi`
193
+ as the only independent witness.
194
+
195
+ Also corrected: `KNOWN-LIMITATIONS.md` and `troubleshooting.md` both opened by promising the lenient
196
+ parser "never silently drops or garbles data". Both now scope that to a **decoded value** and name
197
+ the two things that are discarded. `spec-notes-tolerance.md` presented a three-tier taxonomy with no
198
+ slot for a silent normalization; the tiers now name it, and note that a Tier-2 unexpected segment is
199
+ warned about but not kept.
200
+
201
+ ### Documented, not fixed
202
+
203
+ > **Superseded in part by the "Fixed" entry above, which ships in the same release.** The model half
204
+ > of the entry below is now fixed: the segment is retained on `ix.orphanSegments`, and a double-spaced
205
+ > file no longer loses its interchange body. The **round-trip** half still stands: `serializeX12` does
206
+ > not re-emit an orphan, so the warning still does not survive. The two entries are kept separate
207
+ > because they were separate changes, and the sentences below describe the release before this one.
208
+
209
+ - **A segment that falls outside a transaction is dropped from the model, and its warning does not
210
+ survive a round trip.** The envelope walker keeps body segments only while an ST..SE transaction is
211
+ open; anything else raises `X12_UNEXPECTED_SEGMENT` and is then discarded. A **blank** line between
212
+ segments (`~\n\n`) exceeds the one-CR-plus-one-LF tolerance and triggers exactly this, so a uniformly
213
+ double-spaced file loses its **entire interchange body** and parses to `groups: []`. The first parse
214
+ does warn, so it is detectable rather than silent, but the warning is the only signal and it cannot
215
+ be recovered from the emit. Long-standing behaviour, unchanged here and reproducing on the previous
216
+ release; now disclosed in `README`, `KNOWN-LIMITATIONS.md`, `troubleshooting.md` and
217
+ `spec-notes-envelope.md` rather than latent, and pinned by tests, while the fix is scoped as its own
218
+ item.
219
+
220
+ ### Tests
221
+
222
+ - **The default emit mode's guarantees are locked against the whole committed corpus, not just the 13
223
+ goldens.** The goldens are already in the serializer's image, so they could only ever demonstrate
224
+ the easy half of the round trip. `test/serialize.test.ts` now discovers every `.edi` fixture from
225
+ disk (so a fixture added later is covered without anyone remembering) and asserts four properties per
226
+ fixture: the emit differs from its source by line breaks and nothing else, it re-parses to an
227
+ identical model with an identical warning stream, it is a fixed point, and it is byte-identical to
228
+ its source exactly when the source has no line breaks. A guard test asserts the corpus really
229
+ contains both pretty-printed and compact fixtures, so the sweep cannot pass vacuously. Separate cases
230
+ lock that LF, CRLF, and bare CR all normalize to one identical compact form.
231
+
232
+ - **A `round-trip escape hatches` suite pins the five inputs that falsify the corpus sweep's
233
+ biconditional**, none of which contains a line break: a segment outside a transaction (asserting
234
+ both that the value is gone from the emit and that its warning does not recur on re-parse), a
235
+ doubled terminator, a missing final terminator, post-IEA trailing bytes, and a TA1 following a
236
+ functional group (asserting it is reordered ahead of the GS but not lost, and still a fixed point).
237
+ Each case asserts its own warning count rather than describing it, so the fact that three of them
238
+ are silent is pinned rather than claimed. The committed corpus contains no instance of any of them,
239
+ so without these the sweep would stay green while the prose around it claimed more than the sweep
240
+ could see. The blank-line case now asserts the actual outcome (`groups: []` and an empty emit body)
241
+ instead of only the warning; an earlier revision described that input as "reported not swallowed",
242
+ which is exactly backwards.
243
+
244
+ ### Security
245
+
246
+ - **Warning messages are built from a frozen registry instead of from the document, closing a PHI
247
+ leak in every envelope control number and declared count.** Before this change
248
+ `X12_CONTROL_NUMBER_MISMATCH` rendered **both** sides of the disagreeing pair into `message`
249
+ verbatim and unbounded, on all six slots (`ISA-13`/`IEA-02`, `GS-06`/`GE-02`, `ST-02`/`SE-02`); a
250
+ 300,000-byte trailer control number produced a 300,062-byte `message`. `X12_GROUP_COUNT_MISMATCH`,
251
+ `X12_TRANSACTION_COUNT_MISMATCH`, `X12_SEGMENT_COUNT_MISMATCH` and `X12_PRE_005010` did the same
252
+ with `IEA-01`, `GE-01`, `SE-01` and `ISA-12`, and `X12_835_REMIT_BALANCE_MISMATCH` rendered three
253
+ monetary amounts. Five of those six control-number slots are variable-width, so "it is only a
254
+ control number" was never a bound: it is free-form trading-partner text that routinely carries a
255
+ batch or patient-account identifier.
256
+
257
+ **No warning factory takes a value parameter any more.** Each takes a `X12Position` plus, where one
258
+ code covers several situations, a library-owned discriminant (`CONTROL_NUMBER_PAIRS`,
259
+ `UNEXPECTED_SEGMENT_CONTEXTS`, `BALANCE_INVARIANTS`, `REQUIRED_LOOPS`), and `message` is a lookup
260
+ into a frozen table. That replaces the previous posture, which shape-validated an echoed value
261
+ against a spec grammar and substituted `(non-spec)` when it did not match. The shape test held for
262
+ the code-list slots (CARC, RARC, HI qualifier, CSCC, CSC, maintenance type, 837 variant, segment
263
+ id) and could not hold for a control number, whose grammar is whatever the sender sent. Taking no
264
+ value at all is a property of the signature rather than a filter someone has to remember to apply.
265
+
266
+ Nothing is discarded: every value is still preserved verbatim on the model, which is where a
267
+ consumer that has decided it may handle PHI reads it.
268
+
269
+ - **A strict-mode escalation no longer carries a snippet.** `parseX12(raw, { strict: true })` turned
270
+ the first Tier-2 warning into an `X12ParseError` carrying 64 bytes of the interchange, which put
271
+ document bytes into `err.stack` and from there into any error reporter. The escalated error now
272
+ carries `snippet: ""`; its `message` is the registry entry the warning carried and `position`
273
+ locates it. `snippet` remains on the four Tier-3 structural fatals, which are raised before the
274
+ envelope is readable, and remains the library's one deliberate exception.
275
+
276
+ - **`X12Segment.id` is bounded to the X12 segment-id grammar.** It is a derived structural
277
+ identifier, the field a downstream package interpolates to say where something is, and it was a
278
+ verbatim copy of the segment's first element. A sender that put a 300,000-byte value there had it
279
+ copied into any locus built from `seg.id`. A first element outside the grammar now yields the
280
+ exported `NON_SPEC_SEGMENT_ID` sentinel; `seg.raw` and `seg.elements[0]` keep the bytes, so
281
+ round-trip stays byte-exact.
282
+
283
+ - **`X12_INVALID_DELIMITERS` no longer echoes the detected element separator byte.** The message names
284
+ the fixed ISA position that broke instead.
285
+
286
+ ### Added
287
+
288
+ - **`ALL_WARNING_MESSAGES`**, the frozen set of every message string the library can emit, so a
289
+ consumer or a conformance gate can assert `ix.warnings.every((w) => ALL_WARNING_MESSAGES.has(w.message))`.
290
+ - **`CONTROL_NUMBER_PAIRS`, `UNEXPECTED_SEGMENT_CONTEXTS`, `BALANCE_INVARIANTS`, `REQUIRED_LOOPS`**
291
+ and their `X12ControlNumberPair` / `X12UnexpectedSegmentContext` / `X12BalanceInvariant` /
292
+ `X12RequiredLoop` types: the library-owned discriminants the warning factories now take in place of
293
+ a value.
294
+ - **`NON_SPEC_SEGMENT_ID`**, the sentinel `X12Segment.id` takes when the first element is not a spec
295
+ segment id.
296
+
297
+ ### Changed
298
+
299
+ - **BREAKING (pre-alpha): every exported warning factory changed signature.**
300
+ `controlNumberMismatch(position, pair)`, `unexpectedSegment(position, context)`,
301
+ `remitBalanceMismatch(position, invariant)` and `missingRequiredLoop(position, loop)` now take a
302
+ discriminant from the constants above; `pre005010`, `groupCountMismatch`,
303
+ `transactionCountMismatch`, `segmentCountMismatch`, `trailingGarbage`, `unknownCarc`,
304
+ `unknownRarc`, `hlParentMismatch`, `hlParentLevelInvalid`, `unknownHiQualifier`,
305
+ `unknown837Variant`, `unknownClaimStatusCategory`, `unknownClaimStatus` and
306
+ `unknownMaintenanceType` now take a `position` only.
307
+ - **Warning messages no longer carry the declared-versus-actual counts or the balance amounts.** Both
308
+ sides of each are on the model (`iea.elements[1]` against `ix.groups.length`,
309
+ `claim.totalChargeAmount` against `claim.totalPaymentAmount` and the CAS adjustments), so the
310
+ information is one dereference away rather than rendered into a string a consumer logs by default.
311
+
312
+ ### Fixed
313
+
314
+ - **Every out-of-balance claim and service line now gets a distinct warning position.** `CLP`
315
+ segment positions were hard-coded to `0` (`clpSegmentIndex: 0, // populated in the future`), which
316
+ was harmless while the message rendered the amounts and is not once the message names only the
317
+ equation: two claims failing the same invariant produced byte-identical warnings at byte-identical
318
+ positions. `position.segmentIndex` is now the CLP's own 1-based index in the transaction body, so
319
+ `tx.segments[position.segmentIndex]` is that exact CLP. A service-line warning's is that index plus
320
+ the line's zero-based ordinal plus one, which is a **unique locator rather than a pointer at the
321
+ SVC**: read it as "the claim whose CLP is at `segmentIndex - (ordinal + 1)`, service line
322
+ `ordinal`". Those cannot collide across claims: a claim with `n` service lines spans at least
323
+ `n + 1` body segments.
324
+
325
+ - **Five `X12_UNEXPECTED_SEGMENT` messages claimed the segment was preserved; it is not.** A `GE`
326
+ with no open `GS`, an `ST` with no open group, an `SE` with no open transaction set, a `TA1` inside
327
+ a group, and a body segment outside any transaction set are all warned about and then dropped, so
328
+ "preserved on the prior open container" (and, in the last case, "its bytes are on `seg.raw`") named
329
+ a field that does not exist. Each message now says the segment is not retained and points at
330
+ `position.segmentIndex` in the input.
331
+
332
+ - **The disclosure overclaimed on the builders, and the first correction of it still did.** It said
333
+ builder refusals carry structural locators and numeric totals only. Measured against the source:
334
+ at least **sixteen** refusal sites across **ten** `build*` modules interpolate a caller-supplied
335
+ value verbatim and unbounded. Nine are the shared over-long-control-number template, where the
336
+ branch fires **because** the value is over-long. Seven are not gated on length at all:
337
+ `build999` echoes the supplied ST-02 transaction-set control number in two refusals,
338
+ `buildInterchange` the supplied transaction-set id code, `build837` a service line's `variant`,
339
+ `build834` an unrecognized INS-03 and an unrecognized HD-01 maintenance type, and `buildTA1` an
340
+ unrecognized TA1-05 note code. Measured with a 120,000-byte value: a 120,155-byte
341
+ `AckBuildError.message` from `build999` (with a longer `stack` still, whose exact size depends on
342
+ the frame text and so is not quoted), and a 120,069-byte
343
+ `X12BuildError.message` from `buildInterchange`. The README, `docs-content/troubleshooting.md` and
344
+ `KNOWN-LIMITATIONS.md` now name the whole surface, including the ungated sites, and say to log
345
+ `err.code` from a builder. Bounding the builders is a separate change.
346
+
347
+ - **The shipped PHI disclosure said the opposite of what the code did, in five places.**
348
+ `README.md`, `docs-content/troubleshooting.md`, `docs-content/spec-notes-tolerance.md`,
349
+ `docs-content/cookbook.md` and `KNOWN-LIMITATIONS.md` described warning messages as "bounded and
350
+ PHI-free by construction" and told consumers "you can log the full `.warnings` array without
351
+ leaking", naming `.snippet` as the one exception. The leak was in `.message`, and `.snippet` is not
352
+ a field on a warning at all, so the disclosure was actively green-lighting bulk logging of the
353
+ field that leaked. Each now describes the frozen registry, and `troubleshooting.md` states plainly
354
+ what `0.0.3` and earlier did so a reader on an older version is not misled.
355
+ - **The PHI tests were green over unreachable space.** `test/transactions-remit-835.test.ts` swept a
356
+ fixture whose CARC and RARC values were clean for shapes that could never have appeared there, and
357
+ its balance-mismatch assertion **required** the leak (it matched `/spec="\d/` and `/computed=/`).
358
+ `test/parser-envelope.test.ts` proved the hostile segment id was filtered and said nothing about
359
+ the spec-shaped one. All three are replaced.
360
+
361
+ ### Added
362
+
12
363
  - **Brand lockup on the README, following the reader's colour scheme (ASSETS-P8).** The README now
13
364
  opens with the shared Cosyte lockup, above the `# @cosyte/x12` heading. It is a `<picture>`
14
365
  element: a `<source>` carrying the on-dark cut for `prefers-color-scheme: dark`
@@ -540,9 +891,12 @@ EV/SS`), and HL-04 has-child flag from the nested input tree, so an
540
891
  byte-faithful: reconstructed purely from the verbatim `.raw`
541
892
  strings the parser preserved (ISA + terminator, then each
542
893
  TA1 / GS / segment / GE / IEA terminator-joined, then any
543
- `trailingBytes`), so for a Tier-1 input it reproduces the source
544
- bytes exactly: the idempotency fixed point
545
- `serialize(parse(s)) === s`. With `{ specClean: true }` it ALSO
894
+ `trailingBytes`), so for an input carrying none of the known
895
+ unrecorded constructs catalogued in `KNOWN-LIMITATIONS.md` it
896
+ reproduces the source bytes exactly. (As shipped, this
897
+ entry said "for a Tier-1 input", which later proved too weak: a
898
+ pretty-printed file is Tier 1 and does NOT reproduce exactly.)
899
+ With `{ specClean: true }` it ALSO
546
900
  reconciles the envelope (SE-01 / GE-01 / IEA-01 counts + the
547
901
  ISA-13↔IEA-02 / GS-06↔GE-02 / ST-02↔SE-02 control pairs),
548
902
  surfacing every mismatch via `opts.onWarning` and NEVER silently
package/README.md CHANGED
@@ -42,10 +42,10 @@ That's the pitch: no schema upload, no spec knowledge. The parser accepts vendor
42
42
  ## What's inside
43
43
 
44
44
  - **Typed read + emit, per transaction**: 271, 277/277CA, 278, 820, 834, 835, 837P/I/D, plus 999/TA1 acknowledgments. Per-transaction helpers (`get835`, `get837Claims`, `get271Eligibility`, …) and a matching domain builder for each (`build835`, `build837P/I/D`, `build271`, …). The 270 and 276 inquiries parse as generic segments; they have no typed model yet.
45
- - **Postel's Law**: a lenient parser (deviations → warnings with a stable code + positional context) and a conservative serializer. `serializeX12` is byte-faithful by default; `{ 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.
45
+ - **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.
46
46
  - **Money is exact**: every monetary/percent/quantity field decodes as `X12Decimal` (string-backed, BigInt arithmetic). The library **never `parseFloat`s** an EDI amount.
47
47
  - **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.
48
- - **PHI-disciplined**: synthetic-only fixtures, a PHI commit-gate, warning messages that carry a code and a position but never patient data, and builder refusals that carry structural locators and numeric totals only. **The one deliberate exception:** `X12ParseError.snippet` is a bounded (≤ 64 character) copy of the offending 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.
48
+ - **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-three refusal sites across ten builder modules name a value you passed in, so you can see which control number, count or code was refused. 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. Tracked 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).
49
49
 
50
50
  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.
51
51