@cosyte/x12 0.0.3 → 0.0.4

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