@cosyte/x12 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,92 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ### Added
11
11
 
12
+ - **🩺 `X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX`, the 27th Tier-2 warning code, plus the public
13
+ factory `entitySegmentDiscardedAfterLx(position)`** (`X12-DISCARD-AFTER-STRAY-LX`). Raised for
14
+ each 837 `N3` / `N4` / `PER` / `REF` that reached **no party at all** because an earlier `LX` with
15
+ **no `CLM` open** dropped its Loop 2400 and closed the entity loop those segments belonged to.
16
+ `position.segmentIndex` names the **discarded segment itself**, not the `LX`: the loss is per
17
+ segment, so two `N3`s are two warnings, and the segment is what a consumer resolves back through
18
+ `tx.segments`. It ends the silence disclosed under `### Fixed` by the change that introduced that
19
+ discard, which is in this same unreleased block, so no published release ever had the discard
20
+ without the warning.
21
+
22
+ **🩺 Read its bound literally: this is NOT a general "entity segment reached no party" code**, and
23
+ nothing about it should be restated that way. It reports only a segment discarded after such an
24
+ `LX`, and only until the next `NM1` / `HL` / `CLM` opens a loop - a party named after that `LX` is
25
+ outside this code's scope again, and stays silent whether or not this reader surfaces its trailing segments on it. Every other route to an
26
+ unattached `N3` / `N4` / `PER` / `REF` is exactly as silent as before: no entity loop open at the
27
+ `LX` at all, an `NM1` this reader cannot route, an intervening `HL` or `CLM`, and the other
28
+ dropped-`LX` route where a claim **is** open. A `DTP` / `AMT` / `NTE` on that route is discarded
29
+ too and is deliberately **not** reported, because those never attach to a party on any route.
30
+ Each of those bounds is a test, and each is one a widened guard would fail.
31
+
32
+ **It reports that a segment reached NO party, not that it would have reached one.** This reader
33
+ does not surface every one of the four kinds on every party (a `PER` on a patient, or a pay-to
34
+ address), so the code can fire where nothing this library's own reset lost. That is fail-safe and
35
+ is stated rather than narrowed.
36
+
37
+ Nothing about the model changed: the segments are still discarded, still verbatim on
38
+ `tx.segments`, and the message carries positional metadata only, never a value off the wire.
39
+
40
+ - **🩺 `X12_837_SERVICE_SEGMENT_WITHOUT_LX`, the 26th Tier-2 warning code, plus the public factory
41
+ `serviceSegmentWithoutLx(position)`** (`X12-837-LOOP-RESIDUALS`). Raised when an 837 `SV1` / `SV2`
42
+ / `SV3` arrives with **no Loop 2400 open**, so there is no service line to decode it into and
43
+ **nothing the segment carries is read** - not its charge, units, procedure code, modifiers, unit of measure or
44
+ place of service. `position.segmentIndex` names the **service segment itself**, which is the whole
45
+ reason it is a new code rather than a widening of an existing one: the two service-line codes this
46
+ library already had are both anchored at an `LX`, and there is no `LX` in scope here to anchor to.
47
+ **Read that condition literally - it is "no line open", not "the file contains no `LX`":** an `LX`
48
+ in an earlier claim is still an `LX`. Nothing is fabricated to stand in and no line or claim is
49
+ synthesized; the segments stay verbatim on `tx.segments`. See `### Fixed` for the silence it ends,
50
+ and `KNOWN-LIMITATIONS.md` for the measured bounds, including what it does **not** say about how
51
+ the submission's variant resolved.
52
+
53
+ - **🩺 `X12ServiceLineStatus.unitsOfService` and `Build277ServiceLineSpec.unitsOfService`, the 277
54
+ Loop 2220 SVC-07 units of service count** (`X12-277-SVC07-NOT-DECODED`). X12 element 380
55
+ (Quantity), decoded as an `X12Decimal` like every other quantity in this library and never via
56
+ `parseFloat`. It had no representation on either side before, which is why an X212 277 this
57
+ library emitted was short a required element; see `### Fixed`. `undefined` still means **not
58
+ decoded** rather than absent, and an SVC-07 that is present but does not decode raises
59
+ `X12_UNPARSEABLE_DECIMAL` at `position.elementIndex` 7 while an absent one raises nothing. **SVC-05
60
+ stays unread on the 277 on purpose:** it is usage N in both 277 TR3s, unlike the 835 where it is
61
+ the Units of Service **Paid** Count. The warning registry is unchanged at 25 codes plus 4 Tier-3
62
+ fatals.
63
+
64
+ - **🩺 `X12_837_SERVICE_LINE_DROPPED`, the 25th Tier-2 warning code, plus the public factory
65
+ `serviceLineDropped(position)`** (`X12-VARIANT-LOOKUP-PROTOTYPE`). Raised when an 837 `LX` opens
66
+ no Loop 2400 at all, so the service line reaches **no claim's** `serviceLines` - either because no
67
+ `CLM` is open at that point in the walk, or because the submission's variant never resolved to
68
+ `P` / `I` / `D`. Distinct from `X12_837_SERVICE_LINE_NOT_DECODED`, where the line is retained and
69
+ only its service segment went unread. `position.segmentIndex` names the `LX`, the same anchor and
70
+ for the same reason: it is the one segment present in every case. Nothing is fabricated to stand
71
+ in for the missing line and no claim is synthesized; the segments stay verbatim on `tx.segments`.
72
+ The registry stays additions-only.
73
+
74
+ - **🩺 `X12_837_SERVICE_LINE_NOT_DECODED`, the 24th Tier-2 warning code, plus the public factory
75
+ `serviceLineNotDecoded(position)`** (`X12-837-SV-SILENT-ZERO`). Raised when an 837 Loop 2400
76
+ service line is closed without ever having decoded an `SV1` / `SV2` / `SV3` for the variant the
77
+ submission resolved to; see `### Fixed` for what that line was reporting instead.
78
+ `position.segmentIndex` names the `LX` that opened the line rather than the `SVx`, because the
79
+ no-`SVx`-at-all case has no `SVx` to point at. The registry stays additions-only.
80
+
81
+ - **🩺 `X12_UNPARSEABLE_DECIMAL`, the 23rd Tier-2 warning code, plus `readElementDecimal` and
82
+ `X12DecimalWarningSink`** (`X12-QUANTITY-SILENT-DEFAULTS`). A decimal element that is **present**
83
+ and is not an X12 R-type decimal now says so; see `### Fixed` for what it was doing instead.
84
+ `readElementDecimal(seg, n, delimiters)` is the pure primitive underneath both existing helpers and
85
+ returns `{ value, status }` with `status` one of `"decoded"` / `"absent"` / `"unparseable"`, which
86
+ is the distinction a bare `undefined` could not carry. `elementDecimal` and `elementDecimalOrZero`
87
+ each gained an optional 4th `X12DecimalWarningSink` argument (`{ warnings, position }`); the helper
88
+ narrows the position to the failing `elementIndex` itself, so a caller passes the position of the
89
+ segment and never has to remember to do it per element. Existing 3-argument calls still compile and
90
+ are still silent, on purpose.
91
+
92
+ - **`X12RemitServiceLine.originalUnitsOfService` / `Build835ServiceLineSpec.originalUnitsOfService`**,
93
+ the 835 SVC-07 Original Units of Service Count: the units as **submitted**, which a payer sends only
94
+ when they differ from the paid count in SVC-05. It is not a convenience field - without it the
95
+ corrected SVC map (see `### Changed`) would have left SVC-07 unread and unwritten, converting a
96
+ mis-read into a fresh silent drop. `undefined` means "same as paid", not "zero submitted".
97
+
12
98
  - **`requireCallerString` / `makeCallerEscaper`** (internal), the single route a caller-supplied
13
99
  element value takes **through a builder's `esc` helper**. Read that scope literally: it is not
14
100
  every route into an emitted segment, and the `### Fixed` entry below says which positions bypass
@@ -53,6 +139,111 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
53
139
 
54
140
  ### Changed
55
141
 
142
+ - **`KNOWN-LIMITATIONS.md` now ships in the published tarball** (`X12-837-LOOP-RESIDUALS`). Two
143
+ shipped warning messages, `X12_837_SERVICE_LINE_DROPPED` and
144
+ `X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX`, name that document ("see KNOWN-LIMITATIONS.md", each
145
+ one clause before its close), but it was absent from `package.json`'s `files`. An installed copy
146
+ therefore carried a runtime message naming a file that was not beside it. **The citation is what
147
+ was kept and the packaging is what moved:** that document is the canonical account of what this
148
+ reader does not reproduce, and a consumer holding a code, a `position` and a message is precisely
149
+ the consumer who needs it. **The cost is named rather than argued away:** a file in the tarball is
150
+ a published artifact, permanent at the version carrying it, so every claim in that document is now
151
+ held to a published surface's bar. The line is drawn at what the library says to a caller **at
152
+ runtime**, so `docs-content/` is deliberately still not in `files`; the shipped `README.md`'s
153
+ relative links to `./docs-content/cookbook.md` and `./docs-content/troubleshooting.md` therefore
154
+ still do not resolve inside an install, which is pre-existing, unchanged here, and its own
155
+ decision. `test/package-files-cite.test.ts` refuses a shipped warning message that names a
156
+ repo-root `.md` file `files` does not carry, and separately asserts the cited file is on disk, so
157
+ the gate cannot pass on a `files` entry that names nothing. It reads the exported warning registry
158
+ and nothing else, not JSDoc, not source comments, and not the `build*` refusal templates. No code,
159
+ type, warning code or warning message changed.
160
+
161
+ - **A statement about pre-`0.0.10` behaviour that was false as written is cut back in the documents
162
+ this package publishes** (`X12-837-LOOP-RESIDUALS`). "The trailing segments were filed against
163
+ it", "they attached to whichever party the last `NM1` left active", and the counterfactual carried
164
+ by "no longer attaches itself to the last named party" all read as all four of `N3` / `N4` /
165
+ `PER` / `REF` having reached every party through `0.0.10`. They did not: `attachContact` has no
166
+ route for a patient or a pay-to address and `attachReference` none for a pay-to address, so those
167
+ segments reached no party on any release. That is why
168
+ `X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX` reports a segment reached **no** party and never that
169
+ it would otherwise have reached one. `KNOWN-LIMITATIONS.md` additionally carried, ninety lines
170
+ from the entry contradicting it, a fourth wording of the same universal in the present tense: "a
171
+ `NM1` arriving AFTER that `LX` names a party normally, and its own trailing segments attach".
172
+ **That parenthetical is deleted rather than given a fifth wording**, and the past-tense copies
173
+ either take the qualifier already written beside the code's own entry or are cut back to the
174
+ measurement beside them, rather than being given a new wording. What a party named after such an
175
+ `LX` does is unchanged, and that code's own entry still states its scope and says outright that
176
+ whether a later party's trailing segments attach is a separate question it does not answer. **No
177
+ per-kind, per-party map is published, here or in that entry.** Nothing about the
178
+ parser changed, and the measured instances each statement rested on, every one of them on a payer,
179
+ are unchanged and stay. **The same wording survives in `src/` comments and in test-file headers,
180
+ neither of which this change swept or fixed.**
181
+
182
+ - **🩺 BREAKING: `build277` now REFUSES a Loop 2220 service line that carries no `unitsOfService`
183
+ (`X12-277-SVC07-NOT-DECODED`).** SVC-07 is usage **R** in TR3 `005010X212`, so a service line
184
+ without it cannot be emitted as a conformant 277. The refusal is the existing
185
+ `ClaimStatus277BuildError` with code `X12_277_BUILD_INVALID_SPEC`, raised as a precondition before
186
+ any segment is built, and its message names `SVC-07`, the TR3, and the structural locator of the
187
+ offending line (indices only, never a member id, name, trace or claim id). **`build277CA` is
188
+ deliberately unaffected:** in `005010X214` the same element is usage **S**, so the identical spec
189
+ still builds and simply omits the element. **The count is never defaulted in either direction** -
190
+ a quantity the caller did not supply is a quantity nobody sent, and a units figure is one a payer
191
+ reprices against. If you emit X212 277s with service lines, the first build after upgrading throws
192
+ until you supply the submitted count. Usage taken from the pyx12 005010 maps
193
+ (`277.5010.X212.xml`, `277.5010.X214.xml`), outside this repository, rather than from this
194
+ library's own reader.
195
+
196
+ - **The committed canonical 277 fixture and its serializer golden now carry SVC-07**
197
+ (`test/fixtures/status/277-canonical.edi`, `test/fixtures/golden/277.edi`). The X212 fixture was
198
+ itself short the required element, which is part of why nothing in the suite noticed. The other
199
+ twelve goldens regenerate byte-identically.
200
+
201
+ - **🩺 BREAKING: the 835 Loop 2110 SVC element map is corrected, in both directions
202
+ (`X12-SVC-ELEMENT-MAP-OFF-BY-ONE`).** Through `0.0.9` `get835` read `revenueCode` from **SVC-05**
203
+ and `paidUnitsOfService` from **SVC-07**, and `build835` wrote them to those same two positions
204
+ while hard-coding SVC-04 empty behind a comment asserting "revenue code is SVC-05 in X221A1;
205
+ SVC-04 unused". That comment was wrong. SVC-04 is the NUBC revenue code (X12 element 234, a
206
+ string), SVC-05 is the Units of Service **Paid** Count (element 380, a Quantity), and SVC-07 is
207
+ the **Original** Units of Service Count (element 380) - a different quantity, sent only when the
208
+ submitted count differs from the paid one. `revenueCode` now reads and writes **SVC-04** and
209
+ `paidUnitsOfService` now reads and writes **SVC-05**.
210
+
211
+ **The harm was a mis-read code system and a mis-read quantity, in both directions, silently.**
212
+ Measured across the six committed remit fixtures plus the golden, **8 of 8 service lines** read
213
+ back `revenueCode: "1"` at `0.0.9` - `1` is not a valid NUBC revenue code, it is the paid-unit
214
+ count from SVC-05 - while `paidUnitsOfService` came back `undefined` because SVC-07 was absent.
215
+ On emit, a line with revenue code `0300` and 2 paid units produced
216
+ `SVC*HC:99213*600.00*550.00**0300*HC:99212*2`, putting a revenue code into a **Quantity**
217
+ element, so a conformant receiver read `0300` as 300 units of service. It now produces
218
+ `SVC*HC:99213*600.00*550.00*0300*2*HC:99212`.
219
+
220
+ **Nothing in the suite could detect this and that is why it shipped.** Every existing assertion
221
+ was a `build835` -> `get835` round trip, which is green for any pair of positions the two modules
222
+ agree on. All 1,227 tests stayed green with the fix applied. The map is now pinned against literal
223
+ bytes in `test/transactions-remit-835-svc-element-map.test.ts`; all 11 of its cases fail on
224
+ `e3cdf49` and pass at head.
225
+
226
+ **The repo already contradicted itself:** `build277` / `get277Status` read and write the revenue
227
+ code at SVC-04, `build-277-types.ts` says so in prose, and every committed 835 fixture is written
228
+ to the correct map (`SVC*…**1` is an empty SVC-04 and one unit paid). Only the 835 module
229
+ disagreed, and only with itself.
230
+
231
+ **Sources, and what was not read. TR3 005010X221A1 is a paid X12 document; nobody here has read
232
+ it**, and every claim is traceable to something publicly checkable: pyx12's machine-readable
233
+ `835.5010.X221.A1.xml`, an independent open-source implementation of the same guide, which carries
234
+ the whole table and is **the source for SVC-04**; X12's own RFI #2163 for SVC-05; the base 005010
235
+ element dictionary, where SVC-04 is a string and SVC-05/07 are Quantities, which rules out a
236
+ revenue code at SVC-05 on type alone; and two published payer companion guides. **Agreement with
237
+ this repo's own 277 modules is corroborating, not a source** - checking a spec claim against the
238
+ implementation only proves the two agree, which is how the wrong map survived. Listed with links
239
+ in `KNOWN-LIMITATIONS.md`.
240
+
241
+ **Also disclosed rather than fixed:** `undefined` on either quantity means "not decoded", not
242
+ "absent" - the element may have been present and unparseable, which raises no warning and is
243
+ pre-existing at every quantity site. And **835s this library emitted at `0.0.9` or earlier are
244
+ non-conformant on the wire and should be re-emitted**: their revenue code sits in SVC-05, so this
245
+ release reads it back as a paid quantity and reports no revenue code.
246
+
56
247
  - **`escapeRelease` now throws `TypeError` on a non-string instead of returning `""`.** Previously it
57
248
  gave three different wrong answers depending on what arrived: a number, a boolean or a plain object
58
249
  returned the empty string silently; `null` and `undefined` threw on the property read; an array or
@@ -63,6 +254,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
63
254
 
64
255
  ### Security
65
256
 
257
+ - **🩺 A builder refusal no longer echoes the value it refused, so a `claimId` or a member id from a
258
+ JSON-driven caller cannot reach `Error.message`** (`REFUSAL-MESSAGE-PHI-ECHO`). Every domain builder
259
+ documents, on its own error codes, that its refusal message carries structural locators, counts and
260
+ X12 control codes only and **never a `claimId` (patient-account number), member id, member name,
261
+ trace or diagnosis code**. That held for the refusal TEMPLATES and did not hold underneath them. A
262
+ guarantee that is true on one path and false on another is not a guarantee, so it was made true
263
+ rather than reworded.
264
+
265
+ **Measured in the source, not inferred from the prose.** The four shared caller guards
266
+ (`caller-string.ts`, `caller-segment.ts`, `caller-decimal.ts`, `caller-array.ts`) described a
267
+ wrong-typed value by rendering it through `renderCallerValue` - bounded to 90 characters, and **not
268
+ redacted**. On `4a5a943`, with the kind of spec `@cosyte/cli` builds from `JSON.parse`:
269
+
270
+ ```text
271
+ build835({ claims: [{ patientControlNumber: 900412345678, ... }] })
272
+ -> build835: every element value must be a string, but received a number ("900412345678"). ...
273
+ build834({ members: [{ member: { idCode: 700998877, ... } }] })
274
+ -> build834: every element value must be a string, but received a number ("700998877"). ...
275
+ ```
276
+
277
+ **The shipped disclosure named the wrong guard.** It said `requireCallerSegment` echoes the
278
+ primitive and quoted `build835: CLP-01 must be a string, but received a number (...)`. CLP-01 routes
279
+ through `esc`, so `requireCallerString` refuses first and its message names only the builder. The
280
+ echo was on both, and on the two other guards as well.
281
+
282
+ **The remedy is a property, not a list: no caller guard echoes what a caller put in a document
283
+ ELEMENT.** The string, segment and decimal guards report the offending TYPE, and so does the array
284
+ guard's primitive arm; the segment guard keeps its spec-shaped slot locator beside it
285
+ (`build999: "AK9"-01 must be a string, but received a number.`). A guard standing on every element
286
+ of every builder cannot know whether the primitive in front of it is a control number or a patient
287
+ identifier, which is exactly why it may not echo one. The decimal guard went with them because an
288
+ `X12Decimal` slot IS an element slot, and because its message's own fixed text already names
289
+ `0.30000000000000004` / `1e+21` / `NaN` as what a raw number does, so no diagnosis was lost.
290
+ "An `X12Decimal` slot holds no identifier today" would have been the wrong kind of argument: a fact
291
+ about today's slots rather than a property of the guard.
292
+
293
+ **Two things that property does NOT say, and both were drafted as absolutes first.** The array
294
+ guard still renders a forged array-like's `length` and its class tag through `renderCallerValue`,
295
+ bounded: those describe the SHAPE a caller forged rather than an element's contents, and they are
296
+ the whole diagnostic for `{ length: "9".repeat(120000) }`. And **only the segment-join guard names
297
+ the SLOT** - `esc` and `escDec` name the BUILDER, a limit `caller-string.ts` already recorded, so on
298
+ those two the echoed value used to stand in for a locator and now nothing does. That is a real
299
+ diagnostic cost and it is disclosed rather than argued away.
300
+
301
+ **The segment guard's slot locator is now bounded by GRAMMAR rather than by length.** `parts[0]` is
302
+ caller-supplied in `buildInterchange`, which takes `[segmentId, ...elements]` wholesale, so it is
303
+ admitted only when it matches the X12 segment-id grammar (two or three uppercase alphanumerics
304
+ opening with a letter) and otherwise degrades to `element N`. A length bound redacts nothing when
305
+ the thing being bounded has a grammar.
306
+
307
+ **What did NOT change, stated because deleting a claim leaves a new one in its place.** The caller
308
+ values a builder's own template names by field are still rendered, still bounded, still not escaped,
309
+ and still not redacted: control numbers, the 834's INS-03 / HD-01 maintenance type, the 837's
310
+ service-line variant, the TA1-05 note code, the 999's AK9 counts and acknowledged ST-02. That has
311
+ always been documented as robustness and log hygiene rather than redaction, and it remains so.
312
+ `renderCallerValue`, `BUILD_REFUSAL_VALUE_MAX_LENGTH` and `BUILD_REFUSAL_VALUE_MAX_RENDERED` are
313
+ unchanged and still exported. `X12ParseError.snippet` on the four Tier-3 fatals is unchanged and
314
+ still the one deliberate place a document's bytes are copied.
315
+
316
+ **Behaviour change for callers who read a value back out of one of those four messages.** Nothing in
317
+ the library did; the values are gone from the message and unchanged on the spec the caller still
318
+ holds.
319
+
66
320
  - **The PHI scanner refuses an in-scope entry that is not a regular file, on both of its enumerating
67
321
  routes.** A symbolic link under a scan root pointing at a PHI-bearing file used to scan CLEAN on
68
322
  both, so the pre-commit gate and CI both reported "no hits" over a capture the scan never read.
@@ -94,7 +348,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
94
348
  exited 1 and printed that shape, because `git show` handed the path text straight to the
95
349
  cross-cutting shape pass.
96
350
 
97
- **The `--staged` filter is now `AMT`, and `T` is the one-letter difference that made the mode check
351
+ **`T` was added to the `--staged` filter, and it is the one letter that made the mode check
98
352
  reachable.** Replacing a TRACKED regular file with a link is neither an add nor a modify: measured
99
353
  on this tree, `git diff --cached --raw --diff-filter=AM` returned zero rows for that change while
100
354
  the unfiltered `--raw` showed `:100644 120000 <sha> <sha> T`. Without `T` the record died before
@@ -115,21 +369,429 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
115
369
  `readFileSync`, which follows a link, so an explicitly named path that is a link to a PHI-bearing
116
370
  file is scanned and hits (measured, exit 1).
117
371
 
118
- **Not closed here, and stated rather than implied**: `R` (rename) and `C` (copy) are still not
119
- enumerated by `--staged` at all, which is pre-existing and needs the two-path `--raw` record shape
120
- handled. Measured, so the cost is not left to inference: renaming a fixture while substituting a
121
- real name stages as `:100644 100644 <sha> <sha> R080 <old> <new>`, which both `AM` and `AMT` return
122
- zero rows for, and `--staged` exits 0 over a payload that is a hit as an ordinary add; `git mv` of
123
- an already-committed link into `test/fixtures/` is `R100` and is likewise not refused. The all-mode
124
- sweep is the backstop for both (exit 1 and exit 2 respectively), so the gap is at pre-commit, not
125
- in CI. Also unclosed: a scan that observed nothing is still reported clean rather than refused; and
126
- the
127
- enumerate-then-read window in all mode is untouched, because tolerating a failed read pulls the
128
- opposite way from narrowing what the enumeration admits and belongs in its own change. No library
129
- code changed and no published type changed.
372
+ **Not closed by THIS change, and stated rather than implied**: `R` (rename) and `C` (copy) were
373
+ not enumerated by `--staged` at all, so a fixture renamed while a real name was substituted into
374
+ it, and a `git mv` of an already-committed link into `test/fixtures/`, both passed at exit 0. **That
375
+ gap was LIVE at pre-commit through `0.0.7`, `0.0.8`, `0.0.9` and `0.0.10`**, every version published
376
+ after this change landed, with the all-mode sweep as the backstop. It is closed by the change below,
377
+ which is unreleased alongside this one. **Do not read "the same unreleased block" as "no published
378
+ release had it"**: this whole file sits under one `[Unreleased]` heading, so that inference is true
379
+ of the file and false of the registry. Also unclosed at the time: a scan that observed nothing was
380
+ reported clean rather than refused, which the last entry in this section closes, unreleased
381
+ alongside this one and subject to the same warning about what "the same unreleased block" does and
382
+ does not prove. Still open: the enumerate-then-read window in all mode is untouched, because
383
+ tolerating a failed read pulls the opposite way from narrowing what the enumeration admits and
384
+ belongs in its own change. No library code changed and no published type changed.
385
+
386
+ - **🩺 The PHI scanner's `--staged` route stops trusting the caller's git config, so five kinds of
387
+ staged change can no longer disappear from the pre-commit gate's list without a byte of the index
388
+ changing** (`PHI-SCAN-RENAME-BLIND-AT-PRECOMMIT`). `pnpm phi-scan --staged` is the pre-commit hook.
389
+ It enumerated the index with `git diff --cached --raw -z --diff-filter=AMT`, and each of the five
390
+ below returned exit 0 and `OK - no hits`. Every one is measured on a throwaway repository laid out
391
+ like this one, against a synthetic `.edi` payload whose NM1 person name, DMG date of birth, PER
392
+ phone and `REF*SY` SSN are all hits at exit 1 when the same bytes arrive as an ordinary add:
393
+ - **rename.** Detection is on by default and neither `AM` nor `AMT` returns `R`, so `git mv` of an
394
+ already-committed symbolic link into `test/fixtures/` staged as a single TWO-PATH record at mode
395
+ `120000` and the record was deleted before any mode could be read. Renaming a fixture while
396
+ substituting a real-looking surname into it passed identically, over bytes that are two hits as
397
+ an ordinary add. **No similarity score is recorded anywhere in this change**: a score moves with
398
+ the fixture, so a number carried over from one is wrong for the next. What is load-bearing is
399
+ that the record carries TWO PATHS;
400
+ - **copy.** Under `diff.renames=copies`, copying a PHI-bearing file from outside the scan roots
401
+ INTO `test/fixtures/` staged as a genuine two-path `C` record and was dropped the same way. It is
402
+ a distinct hole rather than the same one, because nothing is moved and the source stays put;
403
+ - **gitlink.** With `diff.ignoreSubmodules=all` in the caller's git config, a staged gitlink under
404
+ `test/fixtures/` vanished from `--raw` outright, where the same index without that config is
405
+ refused at exit 2 by the existing mode check;
406
+ - **unmerged path.** Returned by neither `AM` nor `AMT`. Such a path is recorded at one or more of
407
+ stages 1/2/3 and never at stage 0, so `git show :<path>` fails outright and the route attested
408
+ clean over an index it could not read. Git refuses to commit while a path is unmerged, so this
409
+ was never a route to a committed leak; what it was is a gate reporting on a state it never
410
+ observed, and `pnpm phi-scan --staged` is run by hand and from scripts as well as from the hook;
411
+ - **broken pair.** A pair broken by `-B` prints status letter **`M`** with a break score, one path,
412
+ which the record parser reads happily, **but `--diff-filter` classifies a broken pair as `B`
413
+ whatever letter it prints**, so an `AMTU` filter deletes it and a reader checking the raw output
414
+ concludes the opposite.
415
+
416
+ **The remedy is one rule rather than five fixes.** The argv is now
417
+ `git diff --cached --raw -z --no-renames --ignore-submodules=none --diff-filter=AMTUB`.
418
+ `--no-renames` makes a two-path record unemittable whatever `diff.renames` says, so both the rename
419
+ and the copy destination arrive as an ordinary single-path `A` and the source as a `D` the filter
420
+ drops; the two-field stride is therefore STRUCTURAL rather than conditional on the caller's
421
+ configuration. Verified under `diff.renames=true|copies|false|1` and `diff.renameLimit=1`.
422
+ `-M`, `-C` and `--find-copies-harder` each turn detection back on over the top of it and empty the
423
+ route again, which is pinned as a test rather than left to a comment.
424
+
425
+ **An unmerged path is refused (exit 2) with its own message**, separate from the not-a-regular-file
426
+ refusal, because its destination mode is `000000` and that refusal's sentence about symbolic links
427
+ and gitlinks would be false for it. `B` in the filter costs the enumeration nothing today, since
428
+ git only breaks a pair when `-B` is given; it is there so the flag cannot become a silent
429
+ blindfold later, which is why it is a remedy rather than a warning.
430
+
431
+ **The two enumerations are EQUAL when nothing is renamed, copied, unmerged, or a gitlink under
432
+ `diff.ignoreSubmodules`**, and larger only when one of those is present. State the precondition in
433
+ full: `--no-renames` ALONE would make the equality hold on renames and copies only, and the other
434
+ two flags widen it further. This is a superset and NOT a strictly larger set: nothing the previous
435
+ argv
436
+ enumerated stopped being enumerated, and that equality is asserted as a test.
437
+
438
+ **Not closed here, and measured rather than assumed**: a scan that observed nothing was still
439
+ reported clean, and an all-mode walk root replaced by a regular file still died on an unhandled
440
+ directory read rather than refusing cleanly. Both are closed by the entry below, unreleased
441
+ alongside this one. **Still open after both**: a tracked file directly under `test/` is enumerated
442
+ by neither route, and an index entry at exactly a scan root's own path matches no `--staged`
443
+ clause, because every clause tests a `<root>/` prefix. Each is a scope decision belonging in its
444
+ own change. No library code changed and no published type changed.
445
+
446
+ - **🩺 The PHI scanner's all-mode sweep can no longer report clean over the files it never opened
447
+ within its declared roots**
448
+ (`PHI-SCAN-OBSERVED-NOTHING-IS-GLOBAL`). `pnpm phi-scan` with no arguments walks two roots,
449
+ `test/fixtures` and `src`, and prints `OK - no hits` at exit 0 when it finds nothing. **Finding
450
+ nothing and opening nothing were indistinguishable.** Each shape is measured on a throwaway
451
+ repository laid out like this one, against a synthetic `.edi` payload whose NM1 person name, DMG
452
+ date of birth, PER phone and `REF*SY` SSN are hits at exit 1 when the same bytes are read (the
453
+ hit COUNT is deliberately not recorded: a refuter measured the drafted figure short, and this
454
+ repo's rule is to delete a drifting number rather than correct it):
455
+ - **a missing root.** With BOTH walk roots absent, and with `test/fixtures` alone absent, the walk
456
+ returned immediately and the sweep printed `OK - no hits` at **exit 0**. **A root a repository
457
+ never had is the worst shape of it**, because the gate then reads clean on every run it ever
458
+ makes and no run looks wrong;
459
+ - **an emptied root.** With the roots present but emptied on disk while their files stayed in the
460
+ index, the sweep printed `OK - no hits` at **exit 0** over a corpus whose committed bytes are
461
+ hits;
462
+ - **a root that is not a directory.** With a walk root replaced by a regular file, `readdirSync`
463
+ threw an **uncaught `ENOTDIR`** and the process ended at **exit 1**, which is this scanner's own
464
+ code for "hits found", as a stack trace rather than a refusal. A root that is a FIFO ended the
465
+ same way. **That exit code is NOT portable and neither is the mechanism** (`hl7` measures **2**
466
+ for its version of this shape, `terminology` **1** by a different route), so it was re-measured
467
+ here rather than carried over.
468
+
469
+ **The remedy is two rules, and the second is not implied by the first, because existence is not
470
+ observation.** A declared walk root must BE a directory (`refuseUnusableRoots`), and every
471
+ tracked, non-`.md` file under a root must have been one of the files the walk actually enumerated
472
+ (`reconcileObserved`). Both refuse at **exit 2** and name **every** offender, the same rule the
473
+ not-a-regular-file refusal already follows. **Each is independently load-bearing**: of the 17 new
474
+ cases, dropping the first reds **4** and dropping the second reds **5**, and the two sets are
475
+ disjoint. `--deduplicate` on the `git ls-files` argv is pinned separately, by its own case: an
476
+ unmerged path is returned once per STAGE, so without it one conflicted fixture is named three
477
+ times and counted as three.
478
+
479
+ **Naming every offender needs one thing more than the rule.** `git ls-files` returns an unmerged
480
+ path **once per merge stage**, so the listing is taken with `--deduplicate`; without it a single
481
+ conflicted fixture is named three times and counted as three, which falsifies "names every
482
+ offender" in the direction that teaches a developer to distrust the gate. And a refusal here
483
+ **never echoes git's own message**: `execFileSync` appends the child's stderr verbatim, and git's
484
+ fatals in this class carry absolute filesystem paths, so only the engine-owned exit status is
485
+ reported - the same rule `rootProblem` follows with `err.code`.
486
+
487
+ **🩺 SAY "BE A DIRECTORY", NEVER "BE ENUMERABLE".** A draft of this entry said the second and a
488
+ refuter measured it false: a root that IS a directory the process cannot open (mode `000`) passes
489
+ the type check and then throws an **uncaught `EACCES`** out of `readdirSync`, at **exit 1**,
490
+ identically at base and at head. An unreadable **subdirectory** under a root, and the window
491
+ between the root check and the walk, end the same way. **That class is PRE-EXISTING, disclosed and
492
+ NOT closed here**, because closing it means tolerating or classifying a failed directory read,
493
+ which is the deferred enumerate-then-read remedy pulling the same way. The claim was **cut back to
494
+ what the code checks**, not the guard grown.
495
+
496
+ **A root that is itself a symbolic link to a directory is still followed**, as before, and where
497
+ that link is TRACKED, `reconcileObserved` **exempts the root's own index entry**. Without that
498
+ exemption the sweep refused (exit 2) over a tree the base scanner scans as a documented superset at
499
+ exit 1, because `git ls-files` returns the link's own path `test/fixtures` while the walk only ever
500
+ yields `test/fixtures/<name>`. **The case that holds this commits its corpus**, because on an
501
+ uncommitted one `git ls-files` is empty and the case would be green by construction.
502
+
503
+ **🩺 "IT OPENS NO CLEAN PATH" WAS DRAFTED AND IS REFUTED, SO THE CLAIM IS CUT BACK RATHER THAN THE
504
+ GUARD GROWN.** The draft argued the exemption opened none, because a root that is a tracked regular
505
+ file, or a link to one, is refused by the root check first. True, and not the whole of it: when a
506
+ root is a tracked link to a DIRECTORY, everything the walk reads through it lives under the
507
+ target's own names, OUTSIDE the `git ls-files -- test/fixtures src` pathspec, so the whole
508
+ link-target corpus is unreconciled rather than just the root entry. Measured at head with
509
+ `test/fixtures -> ../elsewhere` and a committed `elsewhere/violator.edi`: present, exit 1; removed
510
+ from disk but still in the index, `OK - no hits` at **exit 0** - verbatim the emptied-root shape
511
+ these rules exist to close. It is **PRE-EXISTING** (base is exit 0 over the same tree), so it is
512
+ not a regression; it is **disclosed and not closed**, because covering it means reconciling against
513
+ a second pathspec derived from the link target, which is the same scope decision as the two below.
514
+ **State the closure as "within the declared roots, as git names them", never as a universal.**
515
+
516
+ **A COUNT DOES NOT CLOSE THIS, WHICH IS WHY THE SECOND RULE READS THE INDEX.** An emptied root
517
+ contributes zero and a total still looks like a total, so a denominator measures the roots that DID
518
+ exist. Only naming the corpus from a source OUTSIDE the walk separates "read it and found nothing"
519
+ from "never opened it".
520
+
521
+ **The ignore rule is stated exactly, because its short form is false.** `git check-ignore` consults
522
+ the index by default, so it answers NOT-ignored for a path that is tracked even when a `.gitignore`
523
+ rule names it (only `--no-index` says otherwise, and this scanner does not pass it). So the walk
524
+ SCANS a tracked-and-ignored file and this check correspondingly REFUSES when one is missing from
525
+ disk. Both halves of that pair are asserted; what the rule really exempts is the UNTRACKED ignored
526
+ file, which is never in `git ls-files` at all.
527
+
528
+ **One behaviour outside a git checkout changed and is stated rather than buried**: where
529
+ `git ls-files` cannot answer, the sweep now refuses at exit 2 instead of reporting clean, because
530
+ "git could not tell me" and "git told me there is nothing" are the two answers this check exists to
531
+ keep apart. `scripts/` is not in the published tarball, so every caller is inside a checkout.
532
+
533
+ **Not closed here, measured rather than assumed, and both a scope decision rather than this one**:
534
+ a tracked file directly under `test/` is enumerated by NEITHER route (exit 0 over a payload that
535
+ hits as an ordinary fixture), and an index entry at exactly a scan root's own path matches no
536
+ `--staged` clause (exit 0 over the same payload). **NEITHER IS CLOSED BY THE RECONCILIATION ABOVE,
537
+ and the reason is worth stating because it reads as though it should be**: the reconciliation
538
+ compares the walk against the index WITHIN the declared scope, so a path nothing declares in scope
539
+ is absent from both sides of the comparison. Widening the scope is its own change, because
540
+ **enumerating those files buys only the `scanCommonShapes` floor**: they are `.ts` sources whose
541
+ fixtures are string literals, so `looksLikeX12` is false and the NM1 / DMG / PER / service-date
542
+ recognisers never run. **🩺 NAME THAT FLOOR AS THREE DETECTORS AND NEVER AS TWO.** A draft of this
543
+ entry said "the dashed-SSN and email floor" and a refuter measured it false: the `REF*SY` undashed
544
+ nine-digit SSN recogniser is **not segment-aware either** and fires on a bare string literal
545
+ exactly as the other two do. The two-detector wording understated the deferred scope by precisely
546
+ the shape a dashed-SSN regex cannot see, which is the worst direction to be wrong in. Derive the
547
+ set from `scanCommonShapes`, never from prose - this sentence included. **Widening the enumeration and widening the recogniser are two sides of it,
548
+ each in addition to the other.** Measured on this package's own corpus, the current recogniser over
549
+ the tracked non-fixture files under `test/` finds 8 shapes in exactly one file,
550
+ `test/scripts/phi-scan.test.ts`, which is this scanner's own negative-control corpus; excusing it
551
+ needs an exclusion surface that does not exist, because a bare `--allow-fixture` seeds the
552
+ positional path set and selects `paths` mode. The **enumerate-then-read window** is likewise
553
+ untouched, and the reconciliation does not change its reachability: it runs on the enumeration,
554
+ before any target is read, and it neither widens a root nor reads a file. No library code changed
555
+ and no published type changed.
130
556
 
131
557
  ### Fixed
132
558
 
559
+ - **🩺 `build278Request` / `build278Response` refuse a review whose HL-03 level code is outside `EV`
560
+ and `SS`, instead of emitting a review its own reader cannot decode**
561
+ (`REFUSAL-MESSAGE-PHI-ECHO`). `Build278ReviewSpec.levelCode` is the **one** caller-supplied HL-03 in
562
+ the library: every other level on every builder's spine is a module constant selected by tree
563
+ position. It is typed `"EV" | "SS"`, but a JS or JSON caller reaches it with anything, and `esc`
564
+ type-checks and escapes without constraining the value.
565
+
566
+ **State the failure mode precisely rather than escalating it: it FAILS TO DECODE, it does not decode
567
+ WRONGLY.** The read side is deliberately tolerant at these two levels (they attach under a
568
+ subscriber or a dependent, so they are absent from the expected-parent map), so an out-of-enum HL-03
569
+ falls to the walker's `else` arm, the review loop never opens, and `get278Response` returns the
570
+ review **and its HCR-01 certification decision** as absent, with `warnings: []`. Nothing is
571
+ mis-read: no decision comes back as a different decision, and the bytes stay on `tx.segments`. That
572
+ is the better of the two failure modes. It is still not one to emit, because HCR-01 is a
573
+ safety-critical field this library places verbatim and never infers, so the builder refuses exactly
574
+ as `build834` refuses a maintenance type it cannot name. Reuses `X12_278_BUILD_INVALID_SPEC`; no
575
+ error code was added.
576
+
577
+ **The guard resolves the level through the same `?? "EV"` expression the emitter uses**, rather than
578
+ testing `!== undefined`, so a `null` from a `JSON.parse`d spec is absent rather than forged. Testing
579
+ `undefined` alone refused a spec the emitter would have defaulted and built, which is
580
+ `X12-CALLER-VALUE-RESIDUALS`' recorded regression running the other way; the slice's own test caught
581
+ it before it shipped. It reaches nested service reviews and dependent reviews, not only the first.
582
+
583
+ **No caller who was getting the review into the document is broken.** An out-of-enum level never
584
+ produced a decodable review, so there is no value that worked and stops working, and a TypeScript
585
+ caller could not reach the arm at all. `PRE-EXISTING` at `4a5a943` and disclosed there as filed not
586
+ fixed; this closes it.
587
+
588
+ - **🩺 A `REF`, `N3`, `N4` or `PER` after a dropped 837 `LX` does not attach itself to the last
589
+ named party** (`X12-837-LOOP-RESIDUALS`). Through `0.0.10`, the release published as this was
590
+ written, an `LX` arriving with **no `CLM` open** reported the dropped service line and then left
591
+ the previous `NM1`'s party still addressable. Because the payer accumulator is what the **next**
592
+ `CLM` opens against, the values surfaced on a **later claim**: measured, a line-item control
593
+ number in `payer.references`, a street address in `payer.address`, and a contact in
594
+ `payer.contacts`, none of which the sender put there. On that
595
+ route all seven of `DTP` / `AMT` / `NTE` / `REF` / `N3` / `N4` / `PER` are discarded; the
596
+ `DTP` / `AMT` / `NTE` already were at `0.0.10`, and the other four are what changed.
597
+ **The route-dependence is unchanged and still matters:** with a `CLM` open, a trailing `DTP` /
598
+ `AMT` / `NTE` / `REF` still lands on the enclosing claim.
599
+ **If you parsed with `0.0.10` or earlier and read an entity's `address`, `contacts` or
600
+ `references`, those slots could be carrying line-level values from a dropped Loop 2400.**
601
+
602
+ **🩺 This is a trade, and the cost is that a conformant entity segment can now be dropped.** The
603
+ TR3s nest Loop 2400 inside Loop 2300 and say nothing about an `LX` elsewhere, so which party a
604
+ segment following a **stray** `LX` belongs to is not derivable from the spec in either direction.
605
+ Where the `LX` was injected into an **entity** loop, the segments after it really were that
606
+ entity's: measured, a payer that kept its `PO BOX` address, its `2U` secondary id and its contact
607
+ at `0.0.10` now comes back with `address: undefined`, `references: []` and `contacts: []`. The
608
+ direction was chosen because a mis-attribution puts a value on an object the sender never put it
609
+ on and is indistinguishable from real data, whereas the bytes of a discarded segment are still on
610
+ `tx.segments`. **That discard shipped SILENT in the change above and no longer is:** it now raises
611
+ `X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX` at each discarded segment, listed under `### Added`
612
+ and never released without it. `X12_837_SERVICE_LINE_DROPPED` at that `LX` still reports the
613
+ **service line** and not an entity's address, id or contact, so it never named this loss. The loss
614
+ itself is unchanged and is still pinned by tests; `KNOWN-LIMITATIONS.md` states both.
615
+
616
+ - **🩺 `X12_837_UNKNOWN_VARIANT` now anchors at the `ST` instead of the `BHT`**
617
+ (`X12-837-LOOP-RESIDUALS`). The warning's `position.segmentIndex` was `1`, which in a
618
+ transaction-scoped position is `tx.segments[1]` and in an 837 is the **BHT**, a segment with no
619
+ part in resolving a variant. It is now `0`, the **ST**, which carries the ST-03 the resolution
620
+ reads. A consumer joining this warning back to the document therefore gets a different segment
621
+ than it did on `0.0.10`. No `elementIndex` is set, deliberately: one of the two routes into this
622
+ warning is an ST-03 that is absent entirely, and on that route the `ST` has no element 3 to name.
623
+
624
+ - **🩺 An 837 service segment with no Loop 2400 open no longer takes a charge, a quantity and a
625
+ procedure code off the model in silence** (`X12-837-LOOP-RESIDUALS`). Through `0.0.10` - the
626
+ release published as this was written - an `SV1` / `SV2` / `SV3`
627
+ arriving with no Loop 2400 open found no service line to decode into, was read into nothing, and
628
+ reported on **no channel at all**: the claim came back with an empty `serviceLines` and
629
+ `warnings: []`, indistinguishable from a claim that genuinely had no lines. It now raises
630
+ `X12_837_SERVICE_SEGMENT_WITHOUT_LX` at the service segment itself.
631
+
632
+ **The segment is still not decoded into any line, and that is deliberate.** `SV1-02` and `SV2-03`
633
+ are both the line charge, so reading a service segment into a line the walker never opened
634
+ mis-reads money. Refusing to read is the safe half; doing it silently was the defect. **This says
635
+ nothing about how the variant resolved**, and `KNOWN-LIMITATIONS.md` now discloses why: a caller's
636
+ `type` option wins first, and absent one, where `ST-03` names none of the three known
637
+ implementation conventions, the reader falls back to the first `SVx` in the transaction body,
638
+ orphans included, so a stray `SV2` re-types the whole submission. That is pre-existing behaviour, measured identical at `0.0.10`, and is deliberately
639
+ not narrowed here.
640
+
641
+ **Nothing else about the walk changed, and the other two codes are unmoved.** An `LX` that opened
642
+ no Loop 2400 still reports once, at the `LX`, and the service segments inside that dropped loop
643
+ stay quiet rather than naming the same loss twice under two codes; the suppression is scoped to
644
+ that loop, so a later orphan in the same transaction is still reported. A line that IS on the
645
+ model with an undecoded `SVx` still raises `X12_837_SERVICE_LINE_NOT_DECODED` and nothing else.
646
+ **This does not touch the model:** `charge` and `units` are still typed `X12Decimal`, and an
647
+ absent `SV1-02` on a line that DID open still reads a confident `0`, which closes only with the
648
+ deferred `X12Decimal | undefined` change.
649
+
650
+ - **🩺 An X212 277 this library emitted with a service line was short a required element, and every
651
+ 277 it read silently discarded the submitted units** (`X12-277-SVC07-NOT-DECODED`). `get277Status`
652
+ read SVC-01 through SVC-04 and stopped; `build277` emitted exactly those four. SVC-07, the units of
653
+ service count, is usage **R** in `005010X212`, so **every X212 277 this library produced with a
654
+ Loop 2220 service line was non-conformant on the wire**, and the count a submitter sent never
655
+ reached the model on the way back in. Both directions now carry it, and `build277` refuses rather
656
+ than emit a line without it (see `### Changed`).
657
+
658
+ **Only bytes could have caught this.** Every service-line assertion in the suite was a `build277`
659
+ to `get277Status` round trip through one self-consistent four-element map, so it was green for any
660
+ subset the two modules agreed on, including a subset missing a required element. A round trip
661
+ cannot test an element map, and it cannot test an element usage at all. The new pins in
662
+ `test/transactions-status-277-svc07.test.ts` parse literal EDI and compare literal segment strings:
663
+ **16 of its 21 cases are red against a clean `c34770c` checkout**, and the five that are green are
664
+ the negative pins that were already true (SVC-05 stays unread, an absent SVC-07 warns nothing, a
665
+ 277CA line with no units emits no placeholder).
666
+
667
+ **🩺 Read the scope literally: ONE element's usage was fixed, and an emitted service line is NOT
668
+ thereby conformant.** This was not a 277 usage audit and **no census of what remains is published**,
669
+ on purpose: other required elements of the same `SVC` are still unguarded and finding another is
670
+ expected rather than a new defect. To name two, `SVC-01` and `SVC-02` are both usage **R** in X212
671
+ and both optional on `Build277ServiceLineSpec`, so a line supplying only `unitsOfService` still
672
+ emits `SVC*******1~` with no refusal. **The missing guard is what is pre-existing, not that byte
673
+ string** - at base the same spec emitted a bare `SVC~`, because the slot did not exist. `SVC-03` is usage **R**
674
+ in X212 and usage **N** in X214 and is optional in both builders. And the read side still raises
675
+ **no** warning for an X212 277 that arrives with no SVC-07, because a lenient reader saying so
676
+ needs a new Tier-2 registry code. All of it is pre-existing and reproduces at `e3cdf49`; widening
677
+ the guard would have turned this into that audit, which is its own item.
678
+ `KNOWN-LIMITATIONS.md` carries the same statement.
679
+
680
+ - **🩺 A lookup keyed by document bytes can no longer be defeated by a key inherited from
681
+ `Object.prototype` (`X12-VARIANT-LOOKUP-PROTOTYPE`).** Several lookup tables were plain object
682
+ literals, which inherit `Object.prototype`, so an inbound value matching **any own property of
683
+ `Object.prototype`** resolved TRUTHY against them. That set is engine- and version-dependent and
684
+ is deliberately not enumerated here; on the Node 22 this package targets it is twelve members, and
685
+ a draft of this entry listed eight. `Object.freeze` did not help: it seals the own properties and
686
+ changes nothing about the prototype chain. What that cost, measured at `a33c208`:
687
+ - **🩺 An ST-03 of `constructor` took every 837 service line off the model, silently.**
688
+ `VARIANT_BY_ICR` answered the `Object` constructor, so `submission.variant` was a **function**
689
+ rather than one of `P` / `I` / `D` / `unknown`, `X12_837_UNKNOWN_VARIANT` never fired, and
690
+ `openServiceLine` - which answers `undefined` for anything that is not a known variant - dropped
691
+ **every Loop 2400**, its charge, its units and its procedure code, with `warnings: []`.
692
+ - **🩺 `lookupCarc("constructor")` answered a `CodeListEntry` whose `description` was a function**,
693
+ on a field typed `string`, and suppressed `X12_UNKNOWN_CARC`. The guard is now in `makeLookup`,
694
+ the single factory every bundled snapshot is read through, so the other bundled lists are
695
+ covered by the same fix.
696
+ - **🩺 An HI qualifier of `constructor` suppressed `X12_UNKNOWN_HI_QUALIFIER`** while still
697
+ landing the code with `codeSystem: "unknown"` - an unresolvable code system on a diagnosis, with
698
+ nothing on any channel to say so.
699
+ - **An HL-03 of `constructor` raised `X12_HL_PARENT_LEVEL_INVALID`** against a level code the
700
+ walker has no expectation for: a structural violation the document never made. The 271 / 277 /
701
+ 278 readers were never exposed - they share `src/transactions/shared/hl.ts`, which has always
702
+ guarded this read with `hasOwnProperty`; the 837's local copy did not.
703
+ - **`isClaimAdjustmentGroupCode("constructor")` answered `true`** and narrowed a non-code to
704
+ `ClaimAdjustmentGroupCode`. It used `in`, which **walks the prototype chain**: the safe-looking
705
+ form, not the safe form. It is now `Object.hasOwn`.
706
+
707
+ Every one of these now behaves exactly as it does for any other unrecognized value, which is the
708
+ whole of the claim: each fixed case is pinned against an honest-control case in the same slot, and
709
+ the suite derives its key list from `Object.getOwnPropertyNames(Object.prototype)` at run time
710
+ rather than from a list anyone maintains.
711
+ Tables this package declares itself are built through the new internal
712
+ `wireLookup` (`Object.create(null)`, then frozen), so the absent prototype protects future read
713
+ sites too; tables it receives from a caller are guarded with `Object.hasOwn` at the read, the form
714
+ `shared/hl.ts` already used. **No model shape and no message text changed**, and no code-list
715
+ content changed.
716
+
717
+ - **🩺 An 837 `LX` that opens no Loop 2400 no longer drops the whole service line in silence
718
+ (`X12-VARIANT-LOOKUP-PROTOTYPE`).** Two routes reached it: an `LX` arriving before any `CLM`,
719
+ which produced `claims: []` and `warnings: []` even with a charge and units on the wire; and a
720
+ submission whose variant is not one of `P` / `I` / `D`, where `openServiceLine` answers
721
+ `undefined` and the line was never opened. Both now raise `X12_837_SERVICE_LINE_DROPPED` at the
722
+ `LX`. **Retention is unchanged and nothing is invented**: no claim is synthesized to hold an
723
+ orphan line and no variant is guessed, because either would put structure on the model that the
724
+ sender did not send. The segments were, and remain, verbatim on `tx.segments`.
725
+ **Read the scope of this code literally, because three things sit just outside it and all three
726
+ are unchanged.** It is anchored at the `LX`, so an `SVx` arriving with **no `LX` at all** is still
727
+ dropped in silence (`PRE-EXISTING`, disclosed not fixed). It does **not** travel with
728
+ `X12_837_UNKNOWN_VARIANT`: a caller-supplied `type` outside `"P" | "I" | "D"`, which only a
729
+ JavaScript or `JSON.parse`d caller can pass, reaches the second route without it, so read
730
+ `submission.variant` to tell the routes apart. And what becomes of a `DTP` / `AMT` / `NTE` /
731
+ `REF` following a dropped `LX` is route-dependent and is **not** simply "absent": see
732
+ `KNOWN-LIMITATIONS.md`, which states both routes.
733
+
734
+ - **🩺 An 837 service line whose `SVx` never decoded no longer reads `0` / `0` in silence
735
+ (`X12-837-SV-SILENT-ZERO`).** `get837Claims` resolves ONE variant for the whole submission (the
736
+ caller's `type` option, else ST-03's implementation-convention reference, else the first `SVx`
737
+ present). `openServiceLine` seeds a line's `charge` and `units` at `X12Decimal.ZERO`, and
738
+ `decodeSv1` / `decodeSv2` / `decodeSv3` each **return before reading anything** when the line's
739
+ variant is not theirs. So an `SV2` line on a submission that resolved to Professional read back as
740
+ a `$0.00` charge for `0` units with `warnings: []`, with the procedure code, modifiers, unit of
741
+ measure and place of service equally undecoded; a line carrying no `SVx` at all did the same. This
742
+ is the residual `X12-QUANTITY-SILENT-DEFAULTS` disclosed rather than fixed, and it is the same
743
+ fabrication one level up: a slot a reader never read cannot warn on the decimal channel.
744
+
745
+ Measured against `d8b5085`: a conformant 837I whose ST-03 is flipped to `005010X222A2` read back
746
+ `0` / `0` on both service lines with `warnings: []`, where the same bytes read with their own
747
+ variant give `1500` / `1`. Three further probes were equally silent at base (the same file with
748
+ `{ type: "P" }`, an `LX` with no `SVx` at all, and the 837D fixture with `{ type: "I" }`). All four
749
+ now raise `X12_837_SERVICE_LINE_NOT_DECODED`; the honest 837P and 837I controls stay silent.
750
+
751
+ **Not decoding the foreign `SVx` is unchanged and is not the defect.** `SV1-02` and `SV2-03` are
752
+ both the line charge, so reading an `SV2` into a Professional-shaped line would mis-read money
753
+ rather than fail to read it. **The model is unchanged too:** `charge` and `units` are still typed
754
+ `X12Decimal` and still read `0`, so a consumer that never looks at `.warnings` sees what it saw
755
+ before. Making those slots `X12Decimal | undefined` remains a breaking change and its own slice.
756
+ The line is still retained, every segment stays verbatim on `tx.segments`, and a variant that
757
+ resolves to nothing at all is still the separate `X12_837_UNKNOWN_VARIANT` case, where no line is
758
+ opened and no `0` is fabricated. `KNOWN-LIMITATIONS.md` says how far that second case reaches.
759
+
760
+ - **🩺 An unparseable decimal no longer becomes a confident zero in silence
761
+ (`X12-QUANTITY-SILENT-DEFAULTS`).** `elementDecimalOrZero` returned `X12Decimal.ZERO` for an
762
+ element that was **present** and did not decode, with no warning on any channel. A payer amount of
763
+ `1,234.56` (a thousands separator, which X12 forbids in an R-type element), `$450.00`, `450.00USD`
764
+ or `N/A` read back as `0` and was indistinguishable from a payer that paid nothing. A fabricated
765
+ amount presented as read is the same harm class as a mis-read quantity: a number nobody sent,
766
+ arriving as though somebody had.
767
+
768
+ The same root cause one type away: `elementDecimal` answered `undefined` for **both** "the sender
769
+ omitted this element" and "the sender sent bytes this library could not read", also unwarned, so
770
+ `undefined` at a quantity site meant "not decoded" rather than "absent" and no consumer could tell
771
+ which.
772
+
773
+ Every decimal read in all six transaction readers (835, 837P/I/D, 277 / 277CA, 271, 834, 820) now
774
+ routes through a sink and emits `X12_UNPARSEABLE_DECIMAL` at the failing `position.elementIndex`.
775
+ Measured on nine probes across those readers, one per site class, each substituting a single
776
+ numeric token in a committed fixture: at `0.0.9` **seven of the nine were completely silent**, and
777
+ the two that were not were the 835's `BPR-02` and `CLP-04`, which produced only
778
+ `X12_835_REMIT_BALANCE_MISMATCH` - a warning that names an equation, never an element, and that
779
+ exists in no other reader. All nine now carry `X12_UNPARSEABLE_DECIMAL` naming the element.
780
+
781
+ **What did NOT change, deliberately.** The model is unchanged: a slot typed `X12Decimal` still
782
+ reads `X12Decimal.ZERO`, an optional slot still reads `undefined`, and some rows are dropped whole.
783
+ The warning is a property of the READ rather than of what the reader then does with the result, so
784
+ every one of those outcomes carries it; no list of them is published, because a first draft
785
+ enumerated three and a review measured a fourth. A slot typed `X12Decimal` cannot express "did not
786
+ decode", and changing every such slot to `X12Decimal | undefined` is a breaking model change that
787
+ belongs in its own slice. So a consumer that reads only the model and never looks at `.warnings`
788
+ sees exactly what it saw before. **Gate on the warning.** Also unchanged: an **absent** element
789
+ still returns `X12Decimal.ZERO` and still does **not** warn, because "missing means zero" is the
790
+ documented convention of those slots. That does **not** make every unwarned `0` trustworthy: the
791
+ guarantee is exactly that an unwarned `0` **at an element a reader decoded** is a zero the sender
792
+ sent or omitted, and a slot a reader never read cannot warn. `KNOWN-LIMITATIONS.md` carries the
793
+ residual and the one measured instance of that inversion in full.
794
+
133
795
  - **A number passed where a builder's types say `string` no longer emits an EMPTY element. It is now
134
796
  REFUSED, and it is deliberately not coerced.** `escapeRelease` opened with
135
797
  `if (value.length === 0) return value;` and then looped to `value.length`. On a number `.length` is
@@ -169,9 +831,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
169
831
  `TypeError`, so its duty is to survive anything; an emitted document's duty is to invent nothing.
170
832
 
171
833
  **The guard is on values routed through the escape helper, and not every element position goes
172
- through it.** All of what follows is pre-existing, measured, unchanged here and disclosed in
173
- `KNOWN-LIMITATIONS.md`. **36 `esc` slots read `.toString()` off what the types say is an
174
- `X12Decimal`**, so a raw number arrives already a string and is passed through: a
834
+ through it.** All of what follows was pre-existing, measured and unchanged here; it is **closed by
835
+ the `X12-DECIMAL-BYPASSES-THE-GUARD` entry below**, in the same unreleased window, so the residual
836
+ described here never reached a published version. **`esc` slots read `.toString()` off what the
837
+ types say is an `X12Decimal`**, so a raw number arrives already a string and is passed through: a
175
838
  `patientResponsibilityAmount` of `0.1 + 0.2` still emits `…*0.30000000000000004*…`, `1e21` still
176
839
  emits `…*1e+21*…` and `NaN` still emits `…*NaN*…`, each with zero warnings, which are the exact
177
840
  three renderings this entry names as disqualifying, and an 837 service-line `units` reaches SV1-04
@@ -183,7 +846,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
183
846
  `levelCode` (HL-03), `groupDate` / `groupTime` (GS-04 / GS-05), and the 837's `lineNumber`
184
847
  (LX-01). **Those are examples and not a census, deliberately:** two drafts of this
185
848
  entry published an exhaustive count and adversarial review measured both incomplete, so the claim
186
- is cut back rather than grown a third time. AK9-01 is an `ID` element bound to X12 code source 715,
849
+ is cut back rather than grown a third time. AK9-01 is an `ID` element bound to X12 code list 715,
187
850
  and `build999`'s own accept-with-errors guard compares it against `"A"`, so a number walks past it
188
851
  the same way it walked past `patientControlNumber === ""`. **The fixed-width ISA slots** go through
189
852
  `pad` / `padControl`, so a number throws an untyped `TypeError` and a numeric
@@ -194,6 +857,82 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
194
857
  `new String("PT-ACCT-001")` built cleanly at `0.0.8` and is refused now, because `typeof` it is
195
858
  `"object"`.
196
859
 
860
+ - **A raw `number` in an `X12Decimal` slot is refused instead of rendered, and the type check now
861
+ covers every element of every segment emitted through a builder's segment joiner**
862
+ (`X12-DECIMAL-BYPASSES-THE-GUARD`). This closes the
863
+ two classes the entry above disclosed and deliberately did not fix.
864
+
865
+ **The decimal half.** `makeCallerEscaper` type-checks what reaches `esc`, but an `X12Decimal` slot
866
+ hands `esc` a `value.toString()`, and a raw `number` answers that with a perfectly good string. So
867
+ the value arrived already a string and the guard never applied. Measured at `15abbd4` with
868
+ `warnings.length === 0` in every case: a `patientResponsibilityAmount` of `0.1 + 0.2` emitted
869
+ `CLP*PT-ACCT-001*1*500.00*450.00*0.30000000000000004*…`, `1e21` emitted `…*1e+21*…`, `NaN` emitted
870
+ `…*NaN*…`, an 837 service-line `units` of `0.1 + 0.2` emitted
871
+ `SV1*HC:99213*150.00*UN*0.30000000000000004***1`, and a diagnosis `monetaryAmount` reached
872
+ `HI*ABK:J20.9:::0.30000000000000004`. **Two of those three renderings the library cannot parse
873
+ back** - `X12_DECIMAL_RE` rejects exponent notation and `NaN` - so they did not round-trip; the
874
+ IEEE-754 artifact is worse in the other direction, being well-formed enough that nothing downstream
875
+ refuses it. Every such slot now emits through that builder's `escDec`, over `requireCallerDecimal`.
876
+
877
+ **Why refuse and not round, which is the decision:** rounding `0.1 + 0.2` to `0.30` guesses cents
878
+ and to `0.3` guesses tenths, and guessing the scale of a monetary amount is what `X12Decimal` exists
879
+ to prevent. Same answer as the entry above, for a reason specific to this slot: every one of these
880
+ is _typed_ `X12Decimal` already, so a raw `number` is a caller who defeated their own type checker
881
+ and no supported numeric path is taken away.
882
+
883
+ **The raw-slot half.** The string-typed positions that never called the escape helper at all are
884
+ routed through it: `build999`'s `groupControlNumber` (GS-06 / GE-02), `transactionSetControlNumber`
885
+ (ST-02 / SE-02), `disposition` (AK9-01 and IK5-01) and `groupResponsibleAgency` (GS-07);
886
+ `groupDate` / `groupTime` (GS-04 / GS-05) in all seven domain builders; `build278`'s `levelCode`
887
+ (HL-03); and `build837`'s `lineNumber` (LX-01). That closes the delimiter hole on them too -
888
+ `build999` with a `groupControlNumber` of `"1*BOGUS"` emitted `GS*FA*…*1*BOGUS*X*005010X231A1`,
889
+ shifting GS-07 and GS-08 by one, and now emits `1?*BOGUS`.
890
+
891
+ **And the part that is a property rather than a list.** Three consecutive drafts of the entry above
892
+ published an exhaustive counted census of the slots that bypass `esc`, and adversarial review
893
+ measured all three false, each time by finding one more. Counting a fourth time would repeat that,
894
+ so the check moved to the one place every element must pass: **the segment join**.
895
+ `requireCallerSegment` type-checks every element of every segment emitted **through a builder's
896
+ `seg` / `joinSeg` helper**, on every route in, `escDec` included. `esc` is optional on a slot; the
897
+ join is not. It also names the slot the way the spec does - `build999: "AK9"-01 must be a
898
+ string, …` - which `esc` cannot, being unary.
899
+
900
+ **What is deliberately still NOT claimed.** Type safety is structural here; **delimiter safety is
901
+ per-slot**. A `string` carrying an active delimiter in a slot that skipped `esc` is still emitted
902
+ verbatim, because the segment guard passes it - only the slots named above were routed. And the
903
+ fixed-width ISA slots go through `pad` / `padControl` and not through the segment joiner either, so
904
+ they remain as the entry above describes them: an untyped `TypeError`, or for
905
+ `interchangeControlNumber` a typed refusal whose text misleadingly says "exceeds the 9-char spec
906
+ limit". Both terminate; neither is silent; neither is improved here.
907
+
908
+ **Two more exclusions, both found by the refuter against a draft that claimed more than the code
909
+ did, both `PRE-EXISTING` and both now pinned by tests rather than argued away.** `buildTA1` uses no
910
+ segment joiner and no escape helper - it joins its five caller-supplied elements directly - so a
911
+ numeric or `undefined` `interchangeControlNumber` still emits silently as `TA1**250101*1200*A*000`;
912
+ TA1-01 is the reassociation key back to the acknowledged interchange, so it is filed as its own
913
+ item rather than widened into here. And **`build835`'s balance-equation amounts refuse UNTYPED**:
914
+ `enforceBalance(spec)` runs before the escaper is built and calls `X12Decimal` methods on the
915
+ caller's value, so `requireCallerDecimal` is unreachable on them and the caller gets a plain
916
+ `TypeError` with no `code` (some saying the value was "tampered with") instead of the typed
917
+ refusal. **The rule, rather than a list, because a first draft of this disclosure published a
918
+ closed list of four and a refuter measured it incomplete:** a slot refuses untyped exactly when the
919
+ balance guard reads it as a term of one of the three TR3 X221A1 §1.10.2 invariants in
920
+ `src/transactions/remit/balance.ts`. **Named by SPEC FIELD and not by element number, because the
921
+ next draft used element numbers and got one wrong:** `payment.totalActualPayment`,
922
+ `claim.totalChargeAmount`, `claim.totalPaymentAmount`, every `adjustments[].amount` at claim and
923
+ line level, `serviceLine.chargeAmount`, `serviceLine.paymentAmount` and
924
+ `providerAdjustments[].amount`. Every other `X12Decimal` field refuses typed, including
925
+ `claim.patientResponsibilityAmount`, `serviceLine.paidUnitsOfService` and every `amounts[].amount`.
926
+ Both arms are pinned. Reordering the balance guard changes the refusal precedence of an
927
+ out-of-balance remit, which is its own decision.
928
+
929
+ **AK9-01 was the sharpest of the raw slots** - an `ID` element bound to X12 code list **715**
930
+ (a data element number, and its values are a code _list_; this repo's own `src/transactions/ack/codes.ts`
931
+ had it right and four other places said "code source"), so a number there told a receiver nothing
932
+ about whether the functional group was accepted, and `build999`'s own `X12_ACK_ACCEPT_WITH_ERRORS`
933
+ guard compares `disposition === "A"`, which a number walked past exactly as it walked past
934
+ `patientControlNumber === ""`.
935
+
197
936
  - **The `attw` publish gate no longer passes a tarball that carries no type declarations.** The
198
937
  `attw` script was the bare CLI, and `@arethetypeswrong/cli` returns 0 whenever its analysis found
199
938
  no types at all, before it reads the problem list. For a package that ships types, "This package