@cosyte/x12 0.0.10 → 0.0.12

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,47 @@ 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
+
12
53
  - **🩺 `X12ServiceLineStatus.unitsOfService` and `Build277ServiceLineSpec.unitsOfService`, the 277
13
54
  Loop 2220 SVC-07 units of service count** (`X12-277-SVC07-NOT-DECODED`). X12 element 380
14
55
  (Quantity), decoded as an `X12Decimal` like every other quantity in this library and never via
@@ -98,6 +139,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
98
139
 
99
140
  ### Changed
100
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
+
101
182
  - **🩺 BREAKING: `build277` now REFUSES a Loop 2220 service line that carries no `unitsOfService`
102
183
  (`X12-277-SVC07-NOT-DECODED`).** SVC-07 is usage **R** in TR3 `005010X212`, so a service line
103
184
  without it cannot be emitted as a conformant 277. The refusal is the existing
@@ -173,6 +254,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
173
254
 
174
255
  ### Security
175
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
+
176
320
  - **The PHI scanner refuses an in-scope entry that is not a regular file, on both of its enumerating
177
321
  routes.** A symbolic link under a scan root pointing at a PHI-bearing file used to scan CLEAN on
178
322
  both, so the pre-commit gate and CI both reported "no hits" over a capture the scan never read.
@@ -204,7 +348,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
204
348
  exited 1 and printed that shape, because `git show` handed the path text straight to the
205
349
  cross-cutting shape pass.
206
350
 
207
- **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
208
352
  reachable.** Replacing a TRACKED regular file with a link is neither an add nor a modify: measured
209
353
  on this tree, `git diff --cached --raw --diff-filter=AM` returned zero rows for that change while
210
354
  the unfiltered `--raw` showed `:100644 120000 <sha> <sha> T`. Without `T` the record died before
@@ -225,21 +369,284 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
225
369
  `readFileSync`, which follows a link, so an explicitly named path that is a link to a PHI-bearing
226
370
  file is scanned and hits (measured, exit 1).
227
371
 
228
- **Not closed here, and stated rather than implied**: `R` (rename) and `C` (copy) are still not
229
- enumerated by `--staged` at all, which is pre-existing and needs the two-path `--raw` record shape
230
- handled. Measured, so the cost is not left to inference: renaming a fixture while substituting a
231
- real name stages as `:100644 100644 <sha> <sha> R080 <old> <new>`, which both `AM` and `AMT` return
232
- zero rows for, and `--staged` exits 0 over a payload that is a hit as an ordinary add; `git mv` of
233
- an already-committed link into `test/fixtures/` is `R100` and is likewise not refused. The all-mode
234
- sweep is the backstop for both (exit 1 and exit 2 respectively), so the gap is at pre-commit, not
235
- in CI. Also unclosed: a scan that observed nothing is still reported clean rather than refused; and
236
- the
237
- enumerate-then-read window in all mode is untouched, because tolerating a failed read pulls the
238
- opposite way from narrowing what the enumeration admits and belongs in its own change. No library
239
- 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.
240
556
 
241
557
  ### Fixed
242
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
+
243
650
  - **🩺 An X212 277 this library emitted with a service line was short a required element, and every
244
651
  277 it read silently discarded the submitted units** (`X12-277-SVC07-NOT-DECODED`). `get277Status`
245
652
  read SVC-01 through SVC-04 and stopped; `build277` emitted exactly those four. SVC-07, the units of
@@ -759,6 +1166,29 @@ string, …` - which `esc` cannot, being unary.
759
1166
  slot for a silent normalization; the tiers now name it, and note that a Tier-2 unexpected segment is
760
1167
  warned about but not kept.
761
1168
 
1169
+ - **The universal about a stray 837 `LX` is cut back in the `src/` comments and 837 test-file headers
1170
+ the release before this one disclosed it in** (`X12-837-LOOP-RESIDUALS`). That release deleted it
1171
+ from the documents this package publishes and disclosed, rather than claimed closed, that it still
1172
+ stood in those two places; this is the rest of that sweep. **No count and no completeness claim is
1173
+ published**, because both are what went stale twice here: the sites swept are the `NM1` and `LX`
1174
+ case comments in `src/transactions/claim/get-837.ts`, and the headers, section comments and case
1175
+ titles of `test/transactions-claim-837-loop-residuals.test.ts`,
1176
+ `test/transactions-claim-837-discard-after-stray-lx.test.ts` and
1177
+ `test/transactions-claim-837-variant-lookup.test.ts`. Each said a trailing `N3` / `N4` / `PER` /
1178
+ `REF` "attached to" or "landed on" whichever party the last `NM1` left active, or that a party
1179
+ named after the `LX` "is addressable again", all of which read as all four kinds reaching every
1180
+ party. They do not: this reader does not surface every one of those kinds on every party. Each copy
1181
+ takes the qualifier already graded on the shipped surfaces, "wherever this reader surfaces that
1182
+ segment kind on that party at all", or is cut back to the measured instance beside it and that
1183
+ instance is named - a payer in every case but one, where it is a Loop 2320 other subscriber; no
1184
+ copy is given a new wording and **no per-kind, per-party map is published.** Counterfactual headings ("no longer attaches", "no longer leaves the last `NM1`
1185
+ addressable") lose the counterfactual only. The bullet in `documentation/agent-notes.md` beginning
1186
+ "RESIDUAL 1, MEASURED AT `93b2428`", and the identically-scoped paragraph in the test header it was
1187
+ written from, are **not** copies - both are scoped by "every trailing segment that attaches to a
1188
+ named party" - and are deliberately left alone. Nothing a consumer reads at runtime changed:
1189
+ neither the warning registry nor any `docs-content/` page nor the README carried the wording, and
1190
+ the executable behaviour of the swept comments is nil.
1191
+
762
1192
  ### Documented, not fixed
763
1193
 
764
1194
  > **Superseded in part by the "Fixed" entry above, which ships in the same release.** The model half
@@ -767,6 +1197,20 @@ string, …` - which `esc` cannot, being unary.
767
1197
  > not re-emit an orphan, so the warning still does not survive. The two entries are kept separate
768
1198
  > because they were separate changes, and the sentences below describe the release before this one.
769
1199
 
1200
+ - **🩺 A repeated `NM1*87` inside one 837 Loop 2000A fuses two pay-to addresses into one**
1201
+ (`X12-837-LOOP-RESIDUALS`). The `N3` line collector appends and the `N4` merge falls back, so
1202
+ `X12Claim.payToAddress` carries a street from each of two addresses and an `N4` field the second
1203
+ address omitted. Reproduces unchanged at `0.0.11` and is **not** fixed here: two remedies were
1204
+ built and both were refuted for moving the loss rather than removing it, so per ADR 0016 the unit
1205
+ was cut back rather than given a third. Clearing the accumulator at the `NM1*87` erases the
1206
+ address a repeat with no `N3` / `N4` of its own did state; replacing at the first following write
1207
+ erases it on a VALUELESS `N3~` / `N4~`. **Neither erasure is a neutral absence, and that is the
1208
+ constraint the next attempt starts from:** `build837P/I/D` emits Loop 2010AB only where the slot
1209
+ is defined, and `emitAddress` emits `N3` only for a non-empty `lines` and `N4` only for a defined
1210
+ field, so an emptied slot re-emits as **no pay-to loop at all** and a half-emptied one re-emits a
1211
+ bare `NM1*87` with neither. **The emit side is therefore in scope for that fix from the start**,
1212
+ which is what makes it its own unit. `documentation/agent-notes.md` carries the measurements.
1213
+
770
1214
  - **A segment that falls outside a transaction is dropped from the model, and its warning does not
771
1215
  survive a round trip.** The envelope walker keeps body segments only while an ST..SE transaction is
772
1216
  open; anything else raises `X12_UNEXPECTED_SEGMENT` and is then discarded. A **blank** line between