@cosyte/x12 0.0.10 → 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/README.md CHANGED
@@ -1,7 +1,9 @@
1
- <picture>
2
- <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
3
- <img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
4
- </picture>
1
+ <a href="https://cosyte.com">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
4
+ <img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
5
+ </picture>
6
+ </a>
5
7
 
6
8
  # @cosyte/x12
7
9
 
@@ -45,7 +47,7 @@ That's the pitch: no schema upload, no spec knowledge. The parser accepts vendor
45
47
  - **Postel's Law**: a lenient parser (deviations → warnings with a stable code + positional context) and a conservative serializer. `serializeX12` is byte-faithful **for the segments on the model** by default: each comes back verbatim, including element padding, composites, and `?`-release escapes. **`serialize(parse(s)) === s` is not guaranteed in general.** Six constructs are known not to survive: line breaks between segments (any run of CR / LF between segments is absorbed at parse, so a pretty-printed or double-spaced file emits compact), a doubled terminator outside a transaction, a missing final terminator (the emit supplies one), post-IEA `trailingBytes` (re-joined, not verbatim), a TA1 that followed a functional group (emitted right after the ISA, so it is **reordered**, though nothing is lost), and a segment whose first element is empty outside a transaction (skipped entirely, with no warning at all). A **segment outside a transaction is not on that list**: it is reported as `X12_UNEXPECTED_SEGMENT`, kept on the model at `ix.orphanSegments` with a structural `anchor`, and re-emitted at that anchor, so the segment, its value and its warning all survive the round trip - placement is by the anchor and never by `segmentIndex`, which indexes the input stream the emit does not follow. The last five break the round trip on inputs with no line breaks at all, so do not treat "no line breaks" as sufficient; five of the six are silent, so a clean warnings list does not mean byte-exact either. Measured across this repo's 56 fixtures: every emit is a fixed point and re-parses to an identical model with an identical warning stream, the 14 with no line breaks return byte-identical, and the other 42 differ by line breaks and nothing else. See [Line endings between segments](./docs-content/spec-notes-envelope.md). `{ specClean: true }` reconciles the envelope, and `{ specClean: true, recomputeCounts: true }` also emits the corrected counts (`recomputeCounts` does nothing on its own). A mismatch is always warned, never silently corrected. Only **4 structural failures** are ever fatal.
46
48
  - **Money is exact**: every monetary/percent/quantity field decodes as `X12Decimal` (string-backed, BigInt arithmetic). The library **never `parseFloat`s** an EDI amount.
47
49
  - **Safety-critical fidelity**: TRN reassociation traces, 835 balance invariants, 837 HL hierarchy integrity, 834 maintenance types, and 278 certification actions are preserved **verbatim** and never inferred; ambiguity yields a warning or a typed refusal, never a confident wrong answer.
48
- - **PHI-disciplined**: synthetic-only fixtures, a PHI commit-gate, and a warning `message` that is a **lookup into a frozen registry**, not something built from your document. No warning factory in the library takes a value parameter, so no element can reach a diagnostic: the code and `position` say what and where, and the bytes stay on the model. `ALL_WARNING_MESSAGES` is exported so you can assert it. **Builder refusals are a different, and deliberately weaker, surface:** twenty-three refusal sites across ten builder modules name a value you passed in, so you can see which control number, count or code was refused. Each goes through `renderCallerValue`, capping the rendered **fragment** at `BUILD_REFUSAL_VALUE_MAX_RENDERED` (90 characters); all three names are exported so you can assert the ceiling. The whole `message` is that plus the site's own fixed text, so it is bounded by a constant but a larger one: a 120,000-character control number gave a 120,066-character `X12BuildError.message` before this and gives 150 now. That is robustness, not redaction: the value is one you supplied and bounding it hides nothing from you. It is also not escaped, and on the **ack** path it is not always strictly your own, since `build999`'s AK2-02 and `buildTA1` echo an inbound document's control numbers by design. Log `err.code`, not `err.message`, from a builder. **`defineProfile()` is bounded on the same terms since `0.0.6`** (twelve refusal sites, twenty-three caller values; the worst message measured **360,181 characters** before and **431** now, both at the `fixture` refusal, which names three caller values; the ceiling for that site is 443 and the suite asserts every site under 500; `X12ProfileError.profileName` is deliberately left unbounded so it still matches the name you passed). **And a forged non-array in a builder spec now refuses instead of hanging:** every indexed loop takes its bound from a checked array, so `{ length: "9".repeat(120000) }` draws a typed refusal rather than coercing to `Infinity` and looping forever. A few `for…of` reads still throw an untyped `TypeError` instead; both are in [KNOWN-LIMITATIONS.md](./KNOWN-LIMITATIONS.md). **The one deliberate exception:** `X12ParseError.snippet` on the four Tier-3 structural fatals is a bounded (≤ 64 character) copy of the start of the input, so on real traffic it can carry PHI. The library does not redact it. Redact at your call site, or log `err.code` and `err.position` instead. See [Keeping PHI out of logs](./docs-content/troubleshooting.md).
50
+ - **PHI-disciplined**: synthetic-only fixtures, a PHI commit-gate, and a warning `message` that is a **lookup into a frozen registry**, not something built from your document. No warning factory in the library takes a value parameter, so no element can reach a diagnostic: the code and `position` say what and where, and the bytes stay on the model. `ALL_WARNING_MESSAGES` is exported so you can assert it. **Builder refusals are a different, and deliberately weaker, surface:** twenty-four refusal sites across ten builder modules name a value you passed in, so you can see which control number, count or code was refused. **What they will never name is a `claimId`, a member id, a member name, a trace or a diagnosis code** - a guarantee that held for the refusal templates and, until the release after `0.0.10`, did not hold for the shared type guards underneath them: a JSON-driven caller who sent a number where the types say string got `a number ("900412345678")` back, bounded but not redacted, from a guard standing on every element of every builder. Those guards report the type now and never the value. Only the segment-join guard names the slot as well; the `esc` and `X12Decimal` guards name the builder, which is a diagnostic cost stated rather than hidden. The array guard's primitive arm is redacted with them, but it still reports the `length` and the class tag of a forged array-like, bounded, because those describe the shape you forged rather than the contents of a document element. Each goes through `renderCallerValue`, capping the rendered **fragment** at `BUILD_REFUSAL_VALUE_MAX_RENDERED` (90 characters); all three names are exported so you can assert the ceiling. The whole `message` is that plus the site's own fixed text, so it is bounded by a constant but a larger one: a 120,000-character control number gave a 120,066-character `X12BuildError.message` before this and gives 150 now. That is robustness, not redaction: the value is one you supplied and bounding it hides nothing from you. It is also not escaped, and on the **ack** path it is not always strictly your own, since `build999`'s AK2-02 and `buildTA1` echo an inbound document's control numbers by design. Log `err.code`, not `err.message`, from a builder. **`defineProfile()` is bounded on the same terms since `0.0.6`** (twelve refusal sites, twenty-three caller values; the worst message measured **360,181 characters** before and **431** now, both at the `fixture` refusal, which names three caller values; the ceiling for that site is 443 and the suite asserts every site under 500; `X12ProfileError.profileName` is deliberately left unbounded so it still matches the name you passed). **And a forged non-array in a builder spec now refuses instead of hanging:** every indexed loop takes its bound from a checked array, so `{ length: "9".repeat(120000) }` draws a typed refusal rather than coercing to `Infinity` and looping forever. A few `for…of` reads still throw an untyped `TypeError` instead; both are in [KNOWN-LIMITATIONS.md](./KNOWN-LIMITATIONS.md). **The one deliberate exception:** `X12ParseError.snippet` on the four Tier-3 structural fatals is a bounded (≤ 64 character) copy of the start of the input, so on real traffic it can carry PHI. The library does not redact it. Redact at your call site, or log `err.code` and `err.position` instead. See [Keeping PHI out of logs](./docs-content/troubleshooting.md).
49
51
 
50
52
  See the [**Cookbook**](./docs-content/cookbook.md) for task-oriented recipes (post an 835, route 277CA rejections, round-trip a 271, walk an 837, read a 999) and [**KNOWN-LIMITATIONS.md**](./KNOWN-LIMITATIONS.md) for the honest do-not-over-trust list.
51
53
 
package/dist/index.cjs CHANGED
@@ -143,7 +143,9 @@ var WARNING_CODES = {
143
143
  X12_834_UNKNOWN_MAINTENANCE_TYPE: "X12_834_UNKNOWN_MAINTENANCE_TYPE",
144
144
  X12_UNPARSEABLE_DECIMAL: "X12_UNPARSEABLE_DECIMAL",
145
145
  X12_837_SERVICE_LINE_NOT_DECODED: "X12_837_SERVICE_LINE_NOT_DECODED",
146
- X12_837_SERVICE_LINE_DROPPED: "X12_837_SERVICE_LINE_DROPPED"
146
+ X12_837_SERVICE_LINE_DROPPED: "X12_837_SERVICE_LINE_DROPPED",
147
+ X12_837_SERVICE_SEGMENT_WITHOUT_LX: "X12_837_SERVICE_SEGMENT_WITHOUT_LX",
148
+ X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX: "X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX"
147
149
  };
148
150
  var CONTROL_NUMBER_PAIRS = {
149
151
  /** ISA-13 against IEA-02. */
@@ -219,7 +221,9 @@ var WARNING_MESSAGES = {
219
221
  X12_834_UNKNOWN_MAINTENANCE_TYPE: "Unknown 834 maintenance type: the INS-03/HD-01 code is outside the bundled snapshot. The verbatim code is preserved on the enrollment and the action is NEVER inferred.",
220
222
  X12_UNPARSEABLE_DECIMAL: "Unparseable decimal: the element at `position.elementIndex` held bytes this library could not decode as a decimal, so NO value was decoded from it. Whatever occupies that slot on the model, including 0 and undefined, is a stand-in and is NOT a value the sender supplied. The verbatim bytes are preserved on the segment; read them there before acting on the amount, quantity or percent.",
221
223
  X12_837_SERVICE_LINE_NOT_DECODED: "837 service line with no decoded service segment: the Loop 2400 line opened at `position.segmentIndex` is followed by no SV1 / SV2 / SV3 matching the variant this submission resolved to, so NOTHING carried by the service segment was read. The line's `charge` and `units` hold 0 as a stand-in and are NOT values the sender supplied; its procedure code, modifiers, unit of measure and place of service are equally undecoded. Two common causes: the line carries no SVx at all, or it carries one for a different 837 variant than ST-03 (or the caller's `type` option) named. Which side is wrong is NOT decided here, because a caller-supplied `type` can disagree with a perfectly conformant document. The verbatim segments are preserved on the transaction set; read them there before acting on the charge or the quantity.",
222
- X12_837_SERVICE_LINE_DROPPED: "837 service line dropped from the typed model: the LX at `position.segmentIndex` opened no Loop 2400, so no line appears on any claim's `serviceLines` for it and the SV1 / SV2 / SV3 that followed - its charge, units, procedure code and modifiers - was read into nothing. Compare `X12_837_SERVICE_LINE_NOT_DECODED`, where the line IS on the model and only its service segment went unread. Two causes: no Loop 2300 (CLM) is open at this LX, so there is no claim to attach a line to; or the submission's variant is not one of P / I / D, so no variant-specific line shape could be built. Read `submission.variant` and `submission.claims` to tell them apart; do NOT expect `X12_837_UNKNOWN_VARIANT` alongside this code, because a caller-supplied `type` outside P / I / D reaches the second cause without it. Nothing is fabricated to stand in for the missing line and no claim is synthesized. What becomes of a DTP / AMT / NTE / REF that follows the dropped LX depends on the route and this message does not say; see KNOWN-LIMITATIONS.md. The verbatim segments are preserved on the transaction set; read them there before concluding the claim had no service lines."
224
+ X12_837_SERVICE_LINE_DROPPED: "837 service line dropped from the typed model: the LX at `position.segmentIndex` opened no Loop 2400, so no line appears on any claim's `serviceLines` for it and the SV1 / SV2 / SV3 that followed - its charge, units, procedure code and modifiers - was read into nothing. Compare `X12_837_SERVICE_LINE_NOT_DECODED`, where the line IS on the model and only its service segment went unread. Two causes: no Loop 2300 (CLM) is open at this LX, so there is no claim to attach a line to; or the submission's variant is not one of P / I / D, so no variant-specific line shape could be built. Read `submission.variant` and `submission.claims` to tell them apart; do NOT expect `X12_837_UNKNOWN_VARIANT` alongside this code, because a caller-supplied `type` outside P / I / D reaches the second cause without it. Nothing is fabricated to stand in for the missing line and no claim is synthesized. What becomes of a DTP / AMT / NTE / REF that follows the dropped LX depends on the route and this message does not say; see KNOWN-LIMITATIONS.md. The verbatim segments are preserved on the transaction set; read them there before concluding the claim had no service lines.",
225
+ X12_837_SERVICE_SEGMENT_WITHOUT_LX: "837 service segment with no Loop 2400 to read it into: no service line was open at the SV1 / SV2 / SV3 at `position.segmentIndex`, so NOTHING it carries - its charge, units, procedure code, modifiers, unit of measure and place of service - was read. Read that literally: an LX may well appear earlier in the transaction, and what this reports is that none of them had opened a Loop 2400 still current at this segment. No line appears on any claim's `serviceLines` for it and nothing is fabricated to stand in. Compare the two codes anchored at an LX: `X12_837_SERVICE_LINE_DROPPED`, where an LX IS present and opened no line, and `X12_837_SERVICE_LINE_NOT_DECODED`, where the line is on the model and only its service segment went unread. Neither of those can report the SAME service segment as this code, because both are raised at an LX and this one only where no Loop 2400 is open; a document with several claims can still carry all three. This says NOTHING about how the submission's variant resolved: absent a caller-supplied `type` option, and where ST-03 names no known implementation convention, the reader falls back to the first SV1 / SV2 / SV3 in the transaction, and a segment reported here is eligible for that fallback like any other, so a stray one can decide the variant every line is read against. Read `submission.variant`. The verbatim segments are preserved on the transaction set; read them there before concluding the claim had no service lines.",
226
+ X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX: "837 entity segment read into nothing after a dropped LX: the N3 / N4 / PER / REF at `position.segmentIndex` arrived while no entity loop was open, because an earlier LX in this transaction opened no Loop 2400 (no CLM was open at it) and closed the entity loop that was current there. NOTHING this segment carries reached the model: no party's `address`, `contacts` or `references` was written from it, and no party, claim or line was synthesized to hold it. This is the code that names THAT loss; `X12_837_SERVICE_LINE_DROPPED` is raised at the LX itself and names the SERVICE LINE's loss, never an entity segment, so the two report different things about the same stretch of the document. Read the bound literally, because this code does NOT report every unattached entity segment: it reports one discarded after such an LX and only while nothing since has opened a new loop, so an N3 / N4 / PER / REF that reaches no party by any other route is still silent, and one arriving after a later NM1 is outside this code's scope, whether or not this reader surfaces that segment kind on that party. It reports that the segment reached NO party; it does not claim it would have reached one had the LX been absent, because this reader does not surface every one of these segment kinds on every party (a PER on a patient or a pay-to address, for one). Which party a segment following a stray LX belongs to is not derivable from the TR3s in either direction, so it is discarded rather than attributed: see KNOWN-LIMITATIONS.md. The verbatim segments are preserved on the transaction set; read them there before concluding a party had no address, no secondary identifier or no contact."
223
227
  };
224
228
  var ALL_WARNING_MESSAGES = new Set(
225
229
  Object.values(WARNING_MESSAGES)
@@ -394,6 +398,20 @@ function serviceLineDropped(position) {
394
398
  position
395
399
  };
396
400
  }
401
+ function serviceSegmentWithoutLx(position) {
402
+ return {
403
+ code: WARNING_CODES.X12_837_SERVICE_SEGMENT_WITHOUT_LX,
404
+ message: WARNING_MESSAGES.X12_837_SERVICE_SEGMENT_WITHOUT_LX,
405
+ position
406
+ };
407
+ }
408
+ function entitySegmentDiscardedAfterLx(position) {
409
+ return {
410
+ code: WARNING_CODES.X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX,
411
+ message: WARNING_MESSAGES.X12_837_ENTITY_SEGMENT_DISCARDED_AFTER_LX,
412
+ position
413
+ };
414
+ }
397
415
  function unknownClaimStatusCategory(position) {
398
416
  return {
399
417
  code: WARNING_CODES.X12_UNKNOWN_CLAIM_STATUS_CATEGORY,
@@ -2528,7 +2546,7 @@ function renderCallerJson(value) {
2528
2546
  var NONE = Object.freeze([]);
2529
2547
  function describeShape(value) {
2530
2548
  if (typeof value !== "object") {
2531
- return `a ${typeof value} (${renderCallerValue(value)})`;
2549
+ return `a ${typeof value}`;
2532
2550
  }
2533
2551
  let length;
2534
2552
  try {
@@ -2565,7 +2583,7 @@ function describeCallerDecimal(value) {
2565
2583
  const type = typeof value;
2566
2584
  if (type === "object") return Array.isArray(value) ? "an array" : "an object";
2567
2585
  if (type === "function") return "a function";
2568
- return `a ${type} (${renderCallerValue(value)})`;
2586
+ return `a ${type}`;
2569
2587
  }
2570
2588
  function requireCallerDecimal(value, at, refuse) {
2571
2589
  if (value instanceof X12Decimal) return value;
@@ -2575,19 +2593,20 @@ function requireCallerDecimal(value, at, refuse) {
2575
2593
  }
2576
2594
 
2577
2595
  // src/builder/caller-segment.ts
2596
+ var SEGMENT_ID_RE2 = /^[A-Z][A-Z0-9]{1,2}$/u;
2578
2597
  function describeSegmentValue(value) {
2579
2598
  if (value === null) return "null";
2580
2599
  if (value === void 0) return "undefined";
2581
2600
  const type = typeof value;
2582
2601
  if (type === "object") return Array.isArray(value) ? "an array" : "an object";
2583
2602
  if (type === "function") return "a function";
2584
- return `a ${type} (${renderCallerValue(value)})`;
2603
+ return `a ${type}`;
2585
2604
  }
2586
2605
  function locateSlot(parts, index) {
2587
2606
  const id = parts[0];
2588
2607
  const position = String(index).padStart(2, "0");
2589
- if (typeof id !== "string" || id.length === 0) return `element ${String(index)}`;
2590
- return `${renderCallerValue(id)}-${position}`;
2608
+ if (typeof id !== "string" || !SEGMENT_ID_RE2.test(id)) return `element ${String(index)}`;
2609
+ return `"${id}"-${position}`;
2591
2610
  }
2592
2611
  function requireCallerSegment(parts, at, refuse) {
2593
2612
  let index = 0;
@@ -2608,7 +2627,7 @@ function describeCallerValue(value) {
2608
2627
  const type = typeof value;
2609
2628
  if (type === "object") return Array.isArray(value) ? "an array" : "an object";
2610
2629
  if (type === "function") return "a function";
2611
- return `a ${type} (${renderCallerValue(value)})`;
2630
+ return `a ${type}`;
2612
2631
  }
2613
2632
  function requireCallerString(value, at, refuse) {
2614
2633
  if (typeof value === "string") return value;
@@ -3269,7 +3288,7 @@ function get837Claims(delimiters, tx, opts) {
3269
3288
  }
3270
3289
  const variant = explicitType ?? variantFromIcr ?? variantFromSegment ?? "unknown";
3271
3290
  if (variant === "unknown") {
3272
- warnings.push(unknown837Variant({ segmentIndex: 1, transactionIndex: 0 }));
3291
+ warnings.push(unknown837Variant({ segmentIndex: 0, transactionIndex: 0 }));
3273
3292
  }
3274
3293
  const hierarchies = [];
3275
3294
  const hlIndex = /* @__PURE__ */ new Map();
@@ -3292,7 +3311,9 @@ function get837Claims(delimiters, tx, opts) {
3292
3311
  let currentOtherSubscriber;
3293
3312
  let currentAdjudication;
3294
3313
  let context = { kind: "header" };
3314
+ let droppedLineReported = false;
3295
3315
  let activeEntity;
3316
+ let entityLoopClosedByStrayLx = false;
3296
3317
  const flushAdjudication = () => {
3297
3318
  if (currentAdjudication !== void 0 && currentServiceLine !== void 0) {
3298
3319
  currentServiceLine.adjudications.push(freezeAdjudication(currentAdjudication));
@@ -3308,6 +3329,15 @@ function get837Claims(delimiters, tx, opts) {
3308
3329
  currentClaim.serviceLines.push(freezeServiceLine2(currentServiceLine));
3309
3330
  }
3310
3331
  currentServiceLine = void 0;
3332
+ droppedLineReported = false;
3333
+ };
3334
+ const reportOrphanServiceSegment = (position) => {
3335
+ if (droppedLineReported) return;
3336
+ warnings.push(serviceSegmentWithoutLx(position));
3337
+ };
3338
+ const reportDiscardedEntitySegment = (position) => {
3339
+ if (!entityLoopClosedByStrayLx) return;
3340
+ warnings.push(entitySegmentDiscardedAfterLx(position));
3311
3341
  };
3312
3342
  const flushOtherSubscriber = () => {
3313
3343
  if (currentOtherSubscriber !== void 0 && currentClaim !== void 0) {
@@ -3411,6 +3441,7 @@ function get837Claims(delimiters, tx, opts) {
3411
3441
  context = { kind: "header" };
3412
3442
  }
3413
3443
  activeEntity = void 0;
3444
+ entityLoopClosedByStrayLx = false;
3414
3445
  break;
3415
3446
  }
3416
3447
  case "SBR": {
@@ -3427,6 +3458,7 @@ function get837Claims(delimiters, tx, opts) {
3427
3458
  otherPayer: void 0
3428
3459
  };
3429
3460
  activeEntity = { kind: "otherSubscriber" };
3461
+ entityLoopClosedByStrayLx = false;
3430
3462
  }
3431
3463
  break;
3432
3464
  }
@@ -3442,6 +3474,7 @@ function get837Claims(delimiters, tx, opts) {
3442
3474
  case "NM1": {
3443
3475
  const qualifier = elementValue(seg, 1, delimiters);
3444
3476
  const entity = decodeNm12(seg, delimiters);
3477
+ entityLoopClosedByStrayLx = false;
3445
3478
  if (qualifier === NM1_QUALIFIERS.SUBMITTER) {
3446
3479
  submitter = entity;
3447
3480
  activeEntity = { kind: "submitter" };
@@ -3486,16 +3519,19 @@ function get837Claims(delimiters, tx, opts) {
3486
3519
  case "N3": {
3487
3520
  const lines = collectElementValues(seg, 1, 2, delimiters);
3488
3521
  attachAddressLines(lines, activeEntity, entityMutators);
3522
+ reportDiscardedEntitySegment(position);
3489
3523
  break;
3490
3524
  }
3491
3525
  case "N4": {
3492
3526
  const partial = decodeN42(seg, delimiters);
3493
3527
  attachAddressFields(partial, activeEntity, entityMutators);
3528
+ reportDiscardedEntitySegment(position);
3494
3529
  break;
3495
3530
  }
3496
3531
  case "PER": {
3497
3532
  const contact = decodePer2(seg, delimiters);
3498
3533
  attachContact(contact, activeEntity, entityMutators);
3534
+ reportDiscardedEntitySegment(position);
3499
3535
  break;
3500
3536
  }
3501
3537
  case "REF": {
@@ -3507,6 +3543,7 @@ function get837Claims(delimiters, tx, opts) {
3507
3543
  currentClaim.references.push(ref);
3508
3544
  } else {
3509
3545
  attachReference(ref, activeEntity, entityMutators);
3546
+ reportDiscardedEntitySegment(position);
3510
3547
  }
3511
3548
  break;
3512
3549
  }
@@ -3542,6 +3579,7 @@ function get837Claims(delimiters, tx, opts) {
3542
3579
  sink
3543
3580
  );
3544
3581
  activeEntity = void 0;
3582
+ entityLoopClosedByStrayLx = false;
3545
3583
  context = { kind: "loop2300" };
3546
3584
  break;
3547
3585
  }
@@ -3590,24 +3628,53 @@ function get837Claims(delimiters, tx, opts) {
3590
3628
  flushServiceLine();
3591
3629
  if (currentClaim === void 0) {
3592
3630
  warnings.push(serviceLineDropped(position));
3631
+ droppedLineReported = true;
3632
+ entityLoopClosedByStrayLx ||= activeEntity !== void 0;
3633
+ activeEntity = void 0;
3593
3634
  break;
3594
3635
  }
3595
3636
  currentServiceLine = openServiceLine2(seg, delimiters, currentClaim.variant, position);
3596
- if (currentServiceLine === void 0) warnings.push(serviceLineDropped(position));
3637
+ if (currentServiceLine === void 0) {
3638
+ warnings.push(serviceLineDropped(position));
3639
+ droppedLineReported = true;
3640
+ }
3597
3641
  activeEntity = void 0;
3642
+ entityLoopClosedByStrayLx = false;
3598
3643
  context = { kind: "loop2400" };
3599
3644
  break;
3600
3645
  }
3646
+ // A service segment with no line open to decode into is a loss the
3647
+ // walker used to take in silence. Two ways to reach it, and only one
3648
+ // was ever reported: an LX that opened nothing already warned at the
3649
+ // LX (`droppedLineReported`), while a segment with no Loop 2400 open
3650
+ // at all had no anchor to warn at and so reported on no channel - a
3651
+ // charge, a quantity, a procedure code and its modifiers off the wire
3652
+ // and onto nothing. `reportOrphanServiceSegment` closes only the
3653
+ // second: it anchors at the service segment itself, because that is
3654
+ // the only segment the case has. Note the condition is "no line open",
3655
+ // NOT "no LX in the file": an LX in an earlier claim is still an LX.
3656
+ //
3657
+ // It does NOT decode the segment. The charge is SV1-02 / SV2-03 and
3658
+ // the units SV1-04 / SV2-05 / SV3-06, so reading a service segment
3659
+ // into a line the walker never opened is how a mis-read charge gets
3660
+ // minted. Refusing to read is the safe half; doing it silently was the
3661
+ // defect. This says nothing about VARIANT resolution, which happens
3662
+ // before the walk and scans every SVx in the body: an orphan segment
3663
+ // is eligible for that fallback like any other, which is pre-existing
3664
+ // and disclosed in KNOWN-LIMITATIONS.md rather than changed here.
3601
3665
  case "SV1": {
3602
3666
  if (currentServiceLine !== void 0) decodeSv1(currentServiceLine, seg, delimiters, sink);
3667
+ else reportOrphanServiceSegment(position);
3603
3668
  break;
3604
3669
  }
3605
3670
  case "SV2": {
3606
3671
  if (currentServiceLine !== void 0) decodeSv2(currentServiceLine, seg, delimiters, sink);
3672
+ else reportOrphanServiceSegment(position);
3607
3673
  break;
3608
3674
  }
3609
3675
  case "SV3": {
3610
3676
  if (currentServiceLine !== void 0) decodeSv3(currentServiceLine, seg, delimiters, sink);
3677
+ else reportOrphanServiceSegment(position);
3611
3678
  break;
3612
3679
  }
3613
3680
  case "TOO": {
@@ -8401,6 +8468,7 @@ var HL_LEVEL4 = {
8401
8468
  DEPENDENT: "23"
8402
8469
  };
8403
8470
  var DEFAULT_REVIEW_LEVEL = "EV";
8471
+ var REVIEW_LEVEL_CODES = Object.freeze(["EV", "SS"]);
8404
8472
  function build278Request(spec) {
8405
8473
  return buildServicesReview("005010X217", "request", spec);
8406
8474
  }
@@ -8539,6 +8607,13 @@ function enforceStructuralSpec7(spec, direction) {
8539
8607
  }
8540
8608
  }
8541
8609
  function enforceReview(review, locator, direction) {
8610
+ const resolvedLevel = review.levelCode ?? DEFAULT_REVIEW_LEVEL;
8611
+ if (!REVIEW_LEVEL_CODES.includes(resolvedLevel)) {
8612
+ throw new ServicesReview278BuildError(
8613
+ AUTH_278_BUILD_ERROR_CODES.X12_278_BUILD_INVALID_SPEC,
8614
+ `build278: review at ${locator} has HL-03 level code ${renderCallerValue(resolvedLevel)}; a review level must be "EV" (patient event) or "SS" (service). A level outside those two emits a well-formed document whose review loop no reader opens, so the review and its HCR-01 certification decision would not decode.`
8615
+ );
8616
+ }
8542
8617
  if (review.requestCategoryCode === "") {
8543
8618
  throw new ServicesReview278BuildError(
8544
8619
  AUTH_278_BUILD_ERROR_CODES.X12_278_BUILD_INVALID_SPEC,
@@ -10117,7 +10192,7 @@ var profiles = Object.freeze({
10117
10192
  });
10118
10193
 
10119
10194
  // src/index.ts
10120
- var VERSION = "0.0.10";
10195
+ var VERSION = "0.0.11";
10121
10196
 
10122
10197
  exports.ACK_BUILD_ERROR_CODES = ACK_BUILD_ERROR_CODES;
10123
10198
  exports.ALL_WARNING_MESSAGES = ALL_WARNING_MESSAGES;
@@ -10254,6 +10329,7 @@ exports.elementDecimal = elementDecimal;
10254
10329
  exports.elementDecimalOrZero = elementDecimalOrZero;
10255
10330
  exports.elementOptional = elementOptional;
10256
10331
  exports.elementValue = elementValue;
10332
+ exports.entitySegmentDiscardedAfterLx = entitySegmentDiscardedAfterLx;
10257
10333
  exports.escapeRelease = escapeRelease;
10258
10334
  exports.get271Eligibility = get271Eligibility;
10259
10335
  exports.get277CADisposition = get277CADisposition;
@@ -10300,6 +10376,7 @@ exports.segmentCountMismatch = segmentCountMismatch;
10300
10376
  exports.serializeX12 = serializeX12;
10301
10377
  exports.serviceLineDropped = serviceLineDropped;
10302
10378
  exports.serviceLineNotDecoded = serviceLineNotDecoded;
10379
+ exports.serviceSegmentWithoutLx = serviceSegmentWithoutLx;
10303
10380
  exports.setDefaultProfile = setDefaultProfile;
10304
10381
  exports.trailingGarbage = trailingGarbage;
10305
10382
  exports.transactionCountMismatch = transactionCountMismatch;