@cosyte/x12 0.0.2 → 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
@@ -7,8 +7,383 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
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
+
326
+ - **Brand lockup on the README, following the reader's colour scheme (ASSETS-P8).** The README now
327
+ opens with the shared Cosyte lockup, above the `# @cosyte/x12` heading. It is a `<picture>`
328
+ element: a `<source>` carrying the on-dark cut for `prefers-color-scheme: dark`
329
+ (`https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png`) and an `<img>` carrying the
330
+ on-light cut as the fallback (`https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png`).
331
+ Both were verified `200` / `image/png` before landing. On GitHub a dark-mode reader gets the dark
332
+ cut; on npm the `<img>` is lifted out of the `<picture>` by the surrounding anchor so the light cut
333
+ renders, which is correct there because npmjs.com has no dark mode. The heading and the blockquote
334
+ under it are unchanged: the lockup reads "Cosyte" while the heading reads `@cosyte/x12`, so the two
335
+ strings do not collide and nothing is duplicated. The alt text describes the artwork rather than
336
+ the package, since that is what a screen reader announces on the npm page and what a reader gets if
337
+ the image fails to load.
338
+
339
+ **Recorded because it changed inside the unreleased window rather than after it.** This entry first
340
+ landed describing a _per-package_ banner (`cosyte-banner-x12-1200x300.png`), which baked the
341
+ package name and the tagline into pixels and duplicated the two lines beneath it. It explicitly
342
+ chose a plain markdown image over an `<img>` or a `<picture>` pair, on the ground that whether
343
+ npm's markdown sanitizer preserves `<picture>` was unverified. That was an accurate statement of
344
+ what was known at the time. It has since been measured on a published package page: the sanitizer
345
+ keeps the `<picture>`, and the anchor wrapper hoists the `<img>` out of it, so the light cut
346
+ renders. The per-package banner was replaced before any release carried it, so no published
347
+ version of this package ever had it, and this is written as one change rather than an addition
348
+ followed by a replacement. The superseded reason is kept here rather than removed, because a
349
+ record that quietly flips a stated reason is worse than one that shows the correction.
350
+
10
351
  ### Fixed
11
352
 
353
+ - **Stale version claim removed from five public pages (ASSETS-P8).** `README.md`,
354
+ `KNOWN-LIMITATIONS.md`, `docs-content/intro.md`, `docs-content/installation.md`, and
355
+ `docs-content/troubleshooting.md` all asserted the package was "published on npm at `0.0.1`". The registry says
356
+ `0.0.2`. The literal was pinned by a previous docs sweep and went stale on the very next release,
357
+ so it is now removed rather than re-pinned: the npm badge renders the live version on the README,
358
+ and each page points at `npm view @cosyte/x12 version` as the source of truth. Docs only, no
359
+ runtime or public-API change.
360
+ - **Scope claim corrected: the 270 and 276 inquiries have no typed model (ASSETS-P8).** The README
361
+ status line and "What's inside" list, and `docs-content/intro.md`, claimed "the full v1 read scope
362
+ (270/271, 276/277/277CA, …) and emit scope … are complete". There is no `get270` / `get276` reader
363
+ and no `build270` / `build276` builder, and no 270 or 276 dispatch anywhere in `src/`;
364
+ `get271Eligibility` and `get277Status` return `undefined` for any other `ST-01`. The inquiry
365
+ directions parse into segments and dot-paths like any other X12 input but decode into no typed
366
+ model. The claim is now stated per transaction on every page that made it: the README status line
367
+ and "What's inside" list, `docs-content/intro.md` (both the status block **and** the "transaction
368
+ sets it covers" list under it), `docs-content/spec-notes-transaction-sets.md` (the lead-in and the
369
+ `270 / 271` + `276 / 277` rows of the reader/builder map), and `docs-content/cookbook.md`, which
370
+ said the 270 was "a read-only surface" when it has no reader either. The gap is recorded in
371
+ `KNOWN-LIMITATIONS.md`, `docs-content/troubleshooting.md`, and this repo's `CLAUDE.md`. Docs only,
372
+ no runtime or public-API change.
373
+ - **Serializer description corrected to its actual defaults (ASSETS-P8).** The README described "a
374
+ strict, spec-clean serializer with recomputed envelope counts", which reads as default behaviour,
375
+ and `CLAUDE.md` said the serializer "always emits spec-clean X12". `serializeX12` is byte-faithful
376
+ by default; `{ specClean: true }` reconciles the envelope, and the corrected counts need
377
+ `{ specClean: true, recomputeCounts: true }` together, because `recomputeCounts` is inert on its
378
+ own (`const recompute = specClean && opts.recomputeCounts === true`). A mismatch is always warned
379
+ and never silently corrected. Docs only, no runtime or public-API change.
380
+ - **PHI claim narrowed to what the library actually guarantees (ASSETS-P8).** The README said
381
+ "warnings/errors that carry codes and positions but never patient data". Warning messages and
382
+ builder refusals are PHI-free by construction, but `X12ParseError.snippet` is a bounded (≤ 64
383
+ character) copy of the offending input, so on real traffic it can carry PHI, and the library does
384
+ not redact it. That was documented in the source JSDoc and nowhere a consumer reads. The README now
385
+ states the exception, and it is written up in `KNOWN-LIMITATIONS.md` and
386
+ `docs-content/troubleshooting.md`. Docs only, no runtime or public-API change.
12
387
  - **Developer-docs publish status corrected to the published reality (README-ORG-SWEEP).** The
13
388
  docs.cosyte.com pages (`docs-content/intro.md`, `installation.md`, `troubleshooting.md`) and
14
389
  `KNOWN-LIMITATIONS.md` still said "not yet published to npm" / "gated on the coordinated public
@@ -479,9 +854,12 @@ EV/SS`), and HL-04 has-child flag from the nested input tree, so an
479
854
  byte-faithful: reconstructed purely from the verbatim `.raw`
480
855
  strings the parser preserved (ISA + terminator, then each
481
856
  TA1 / GS / segment / GE / IEA terminator-joined, then any
482
- `trailingBytes`), so for a Tier-1 input it reproduces the source
483
- bytes exactly: the idempotency fixed point
484
- `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
485
863
  reconciles the envelope (SE-01 / GE-01 / IEA-01 counts + the
486
864
  ISA-13↔IEA-02 / GS-06↔GE-02 / ST-02↔SE-02 control pairs),
487
865
  surfacing every mismatch via `opts.onWarning` and NEVER silently
package/README.md CHANGED
@@ -1,15 +1,20 @@
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
3
+ <img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
4
+ </picture>
5
+
1
6
  # @cosyte/x12
2
7
 
3
- > Parse real-world, vendor-quirky ASC X12 healthcare EDI (835 remits, 837 claims, 270/271 eligibility, 276/277 status, 278, 820, 834, 999/TA1) and pull the fields you need without reading a TR3.
8
+ > Parse real-world, vendor-quirky ASC X12 healthcare EDI (835 remits, 837 claims, 271 eligibility responses, 277/277CA status, 278, 820, 834, 999/TA1) and pull the fields you need without reading a TR3.
4
9
 
5
10
  [![npm version](https://img.shields.io/npm/v/@cosyte/x12.svg)](https://www.npmjs.com/package/@cosyte/x12)
6
11
  [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/x12/ci.yml?branch=main&label=CI)](https://github.com/cosyte/x12/actions/workflows/ci.yml)
7
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
8
13
  [![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org)
9
14
 
10
- A developer-focused ASC X12 EDI parser and utility library for Node.js and TypeScript: the payer-side sibling of [`@cosyte/hl7`](https://github.com/cosyte/hl7). Zero runtime dependencies, dual ESM/CJS, strict types. Lenient on the way in (vendor deviations become warnings, not exceptions), spec-clean on the way out.
15
+ A developer-focused ASC X12 EDI parser and utility library for Node.js and TypeScript: the payer-side sibling of [`@cosyte/hl7`](https://github.com/cosyte/hl7). Zero runtime dependencies, dual ESM/CJS, strict types. Lenient on the way in (vendor deviations become warnings, not exceptions), spec-clean on the way out: every domain builder emits spec-clean X12 by construction, and the general serializer does so on request.
11
16
 
12
- > **Status:** pre-alpha, **published on npm at `0.0.1`**, on the `0.0.x`-until-first-alpha ladder, from a public repo. The full v1 **read** scope (270/271, 276/277/277CA, 278, 820, 834, 835, 837P/I/D, 999, TA1) and **emit** scope (per-transaction domain builders + a general serializer) are complete and hardened. Pre-alpha means the public API may still move before `0.1`. Pin an exact version.
17
+ > **Status:** pre-alpha, **published on npm** from a public repo, on the `0.0.x`-until-first-alpha ladder. `npm view @cosyte/x12 version` is the source of truth for the current version and this page does not restate it; the badge above is a convenience, not the record. Typed **read** and **emit** support is complete for 271, 277/277CA, 278 (request + response), 820, 834, 835, 837P/I/D, 999, and TA1: every one of those has both a per-transaction reader and a matching domain builder, on top of a general serializer and interchange builder. The **270** and **276** inquiry directions do not: they parse into segments and dot-paths like any other X12, but nothing decodes them into a typed model yet. Pre-alpha means the public API may still move before `0.1`. Pin an exact version.
13
18
 
14
19
  ## Quickstart
15
20
 
@@ -36,11 +41,11 @@ That's the pitch: no schema upload, no spec knowledge. The parser accepts vendor
36
41
 
37
42
  ## What's inside
38
43
 
39
- - **Every v1 HIPAA transaction, read + emit**: 270/271, 276/277/277CA, 278, 820, 834, 835, 837P/I/D, plus 999/TA1 acknowledgments. Per-transaction helpers (`get835`, `get837Claims`, `get271Eligibility`, …) and domain builders (`build835`, `build837P/I/D`, `build271`, …).
40
- - **Postel's Law**: a lenient parser (deviations → warnings with a stable code + positional context) and a strict, spec-clean serializer with recomputed envelope counts. Only **4 structural failures** are ever fatal.
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 **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.
41
46
  - **Money is exact**: every monetary/percent/quantity field decodes as `X12Decimal` (string-backed, BigInt arithmetic). The library **never `parseFloat`s** an EDI amount.
42
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.
43
- - **PHI-disciplined**: synthetic-only fixtures, a PHI commit-gate, and warnings/errors that carry codes and positions but never patient data.
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).
44
49
 
45
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.
46
51