@cosyte/x12 0.0.17 → 0.1.0

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,63 +1,307 @@
1
1
  <a href="https://cosyte.com">
2
2
  <picture>
3
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">
4
+ <img alt="The Cosyte logo on its own white ground: the icon beside the word Cosyte." src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
5
5
  </picture>
6
6
  </a>
7
7
 
8
8
  # @cosyte/x12
9
9
 
10
- > Parse real-world, vendor-quirky ASC X12 healthcare EDI (835 remits, 837 claims, 271 eligibility responses, 277/277CA status, 278, 820, 834, 999/TA1) and pull the fields you need without reading a TR3.
10
+ > Read the money out of an 835, an 837 or a 271 without a TR3 open on your desk.
11
11
 
12
12
  [![npm version](https://img.shields.io/npm/v/@cosyte/x12.svg)](https://www.npmjs.com/package/@cosyte/x12)
13
13
  [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/x12/ci.yml?branch=main&label=CI)](https://github.com/cosyte/x12/actions/workflows/ci.yml)
14
14
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
15
15
  [![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org)
16
16
 
17
- A developer-focused ASC X12 EDI parser and utility library for Node.js and TypeScript: the payer-side sibling of [`@cosyte/hl7`](https://github.com/cosyte/hl7). Zero runtime dependencies, dual ESM/CJS, strict types. Lenient on the way in (vendor deviations become warnings, not exceptions), spec-clean on the way out: every domain builder emits spec-clean X12 by construction, and the general serializer does so on request.
17
+ Developer-focused ASC X12 healthcare EDI parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.
18
18
 
19
- > **Status:** pre-alpha, **published on npm** from a public repo, on the `0.0.x`-until-first-alpha ladder. `npm view @cosyte/x12 version` is the source of truth for the current version and this page does not restate it; the badge above is a convenience, not the record. Typed **read** and **emit** support is complete for 271, 277/277CA, 278 (request + response), 820, 834, 835, 837P/I/D, 999, and TA1: every one of those has both a per-transaction reader and a matching domain builder, on top of a general serializer and interchange builder. The **270** and **276** inquiry directions do not: they parse into segments and dot-paths like any other X12, but nothing decodes them into a typed model yet. Pre-alpha means the public API may still move before `0.1`. Pin an exact version.
19
+ ## Why this exists
20
20
 
21
- ## Quickstart
21
+ Reading an X12 healthcare transaction correctly normally means buying the TR3 implementation guide
22
+ for it and then hand-mapping element positions that no compiler will ever check. This library
23
+ removes that step for the engineer who has one 835 to post, one 271 to answer or one 837 to send,
24
+ and who needs the amounts to be exactly right rather than approximately right. The nearest
25
+ alternatives are a general-purpose EDI translator, which frames segments correctly but knows nothing
26
+ about what CLP-04 means, and a hand-rolled split on the segment terminator, which works until the
27
+ first trading partner sends a repetition separator you did not plan for. This is neither: a
28
+ zero-dependency TypeScript library that decodes the healthcare transaction sets into typed models,
29
+ keeps every amount in exact decimal arithmetic, and reports each vendor deviation it tolerated with
30
+ a stable code instead of failing or guessing.
31
+
32
+ ## Status
33
+
34
+ **Version 0.1.0**, published on npm from a public repository.
35
+
36
+ From `0.1.0` the public API is settled and safe to depend on: the exported readers, builders,
37
+ types and warning codes are the surface we keep stable, and the warning registry only ever gains
38
+ codes. Below 1.0 a breaking change bumps the minor version and is called out in the changelog with
39
+ its migration. On a `0.0.x` version, pin an exact version rather than a range. Typed read and typed
40
+ emit both ship for every transaction set
41
+ this package covers: 270, 271, 276, 277 and 277CA, 278 request and response, 820, 834, 835, 837P,
42
+ 837I and 837D, 999 and TA1, each with a reader and a matching domain builder, and the two claims
43
+ attachments guides 45 CFR 162.2002 adopts for the period on and after 26 May 2028: the 277 request
44
+ for additional information (`006020X313`) and the 275 that answers it (`006020X314`). For those two the base X12 006020
45
+ structure is typed and no implementation-guide usage is checked, so a qualifier or segment the
46
+ guide requires is carried as sent and never validated; `KNOWN-LIMITATIONS.md` lists what else
47
+ they leave open. `X12_TR3_CONFORMANCE` is the machine-readable answer to which implementation
48
+ guide each one follows, and is a better source than this page.
49
+
50
+ Not covered, and deliberately so: a byte-exact round trip is not guaranteed in general (see
51
+ [Compatibility](#compatibility)), and non-healthcare transaction sets, EDIFACT, transport such as
52
+ AS2 and SFTP, pre-005010 revisions, 006020 guides other than the two attachments guides, and
53
+ decoding what an attachment carries are all out of scope.
54
+
55
+ ## Install
22
56
 
23
57
  ```bash
24
- # pnpm (recommended), also works with: npm install @cosyte/x12 | yarn add @cosyte/x12
25
58
  pnpm add @cosyte/x12
26
59
  ```
27
60
 
28
- Parse an 835 remittance advice and read the money, three lines of useful output, no TR3 lookup:
61
+ `npm install @cosyte/x12` and `yarn add @cosyte/x12` work the same way.
29
62
 
30
- ```ts
63
+ Node `>=22.0.0` is the engine floor, and there are zero runtime dependencies. The package ships
64
+ dual ESM and CJS builds with type declarations for both conditions, so `import` and `require` each
65
+ resolve to the artifact meant for them.
66
+
67
+ ## Usage
68
+
69
+ Parse an 835 remittance advice and read the money: the payment total, the claim's
70
+ charge/paid/patient-responsibility split, and the adjustment reason behind the difference. The
71
+ interchange below is synthetic, with a fabricated payer, provider, patient and amounts.
72
+
73
+ ```ts runnable
31
74
  import { parseX12, get835 } from "@cosyte/x12";
32
75
 
33
- const ix = parseX12(rawEdi); // never throws except on 4 structural fatals
76
+ const raw = `ISA*00* *00* *ZZ*MEDICARE *ZZ*SUBMITTER *260601*1200*^*00501*000000001*0*P*:~
77
+ GS*HP*MEDICARE*SUBMITTER*20260601*1200*1*X*005010X221A1~
78
+ ST*835*0001~
79
+ BPR*I*450.00*C*ACH*CCP*01*123456789*DA*987654321*1512345678**01*111111111*DA*222222222*20260601~
80
+ TRN*1*0012345*1512345678~
81
+ DTM*405*20260601~
82
+ N1*PR*MEDICARE PART A~
83
+ N3*123 PAYER WAY~
84
+ N4*BALTIMORE*MD*21244~
85
+ PER*BL*JANE COORDINATOR*TE*5551234567~
86
+ N1*PE*SAMPLE CLINIC INC~
87
+ N3*456 PROVIDER LN~
88
+ N4*CLEVELAND*OH*44113~
89
+ REF*TJ*123456789~
90
+ LX*1~
91
+ CLP*PT-ACCT-001*1*500.00*450.00*50.00*MC*PAYER-CLAIM-001*11*1~
92
+ NM1*QC*1*PATIENT*TEST*A***MI*MEMBER001~
93
+ NM1*82*2*RENDERING PROVIDER INC*****XX*1234567890~
94
+ DTM*232*20260501~
95
+ DTM*233*20260501~
96
+ SVC*HC:99213*500.00*450.00**1~
97
+ DTM*472*20260501~
98
+ CAS*PR*1*50.00~
99
+ REF*6R*LINE-CTRL-001~
100
+ SE*23*0001~
101
+ GE*1*1~
102
+ IEA*1*000000001~`;
103
+
104
+ const ix = parseX12(raw);
34
105
  const tx = ix.groups[0]?.transactions.find((t) => t.st.elements[1] === "835");
35
- const remit = tx ? get835(ix.delimiters, tx) : undefined;
106
+ if (tx === undefined) throw new Error("no 835 in this interchange");
107
+ const remit = get835(ix.delimiters, tx);
108
+ if (remit === undefined) throw new Error("not an 835");
36
109
 
37
- remit?.payment.totalActualPayment?.toString(); // "450.00": BigInt-exact, never a float
38
- remit?.claims[0]?.patientControlNumber; // "PT-ACCT-001": your account number, echoed back
39
- remit?.claims[0]?.serviceLines[0]?.adjustments[0]?.reasonCode; // "1": CARC (why it was adjusted)
110
+ // The payment, the claim's split, and who owes the difference and why.
111
+ const claim = remit.claims[0];
112
+ const adjustment = claim?.serviceLines[0]?.adjustments[0];
113
+ console.log("paid", remit.payment.totalActualPayment?.toString(), "by", remit.payment.method);
114
+ console.log("claim", claim?.patientControlNumber, "charged", claim?.totalChargeAmount?.toString());
115
+ console.log("patient owes", claim?.patientResponsibilityAmount?.toString());
116
+ console.log("reason", adjustment?.groupCode, adjustment?.reasonCode, adjustment?.reasonDescription);
117
+ console.log("warnings", ix.warnings.length);
118
+
119
+ // The values printed above, which the test suite asserts on every run:
120
+ remit.payment.totalActualPayment?.toString(); // => "450.00"
121
+ claim?.totalChargeAmount?.toString(); // => "500.00"
122
+ claim?.patientResponsibilityAmount?.toString(); // => "50.00"
123
+ adjustment?.groupCode; // => "PR"
124
+ adjustment?.reasonCode; // => "1"
125
+ adjustment?.reasonDescription; // => "Deductible Amount"
126
+ ix.warnings; // => []
40
127
  ```
41
128
 
42
- That's the pitch: no schema upload, no spec knowledge. The parser accepts vendor-quirky input by default and flags what it tolerated with stable warning codes; you reach for strict mode, dot-paths, profiles, or the emit builders when you want them.
129
+ It prints:
130
+
131
+ ```text
132
+ paid 450.00 by ACH
133
+ claim PT-ACCT-001 charged 500.00
134
+ patient owes 50.00
135
+ reason PR 1 Deductible Amount
136
+ warnings 0
137
+ ```
138
+
139
+ The `groupCode` is what tells you who owes the money (`PR` patient responsibility, `CO` contractual
140
+ obligation, and so on). Read it; never infer it.
141
+
142
+ ## PHI and safety
143
+
144
+ X12 healthcare transactions carry protected health information, so treat every interchange you hand
145
+ this library as PHI.
146
+
147
+ **What it does with your document.** It decodes it in memory and nothing else. No socket is opened,
148
+ nothing is written to disk, and nothing is fetched at run time: the bundled code lists are versioned
149
+ data snapshots, so updating one is a release rather than a network call. The acknowledgment builders
150
+ are pure functions and never auto-send.
151
+
152
+ **What it keeps out of diagnostics.** A warning `message` is a lookup into a frozen registry rather
153
+ than something built from your document, and no warning factory in this library takes a value
154
+ parameter, so no element of yours can reach a diagnostic. The code and the `position` say what and
155
+ where; the bytes stay on the model. `ALL_WARNING_MESSAGES` is exported so you can assert that.
156
+
157
+ **The one deliberate exception.** `X12ParseError.snippet` on the four structural fatals is a bounded
158
+ copy of the start of the input, so on real traffic it can carry PHI, and it is not redacted. Log
159
+ `err.code` and `err.position` instead, or redact at your call site.
160
+
161
+ **An attachment refuses to print.** The data of each BDS a 275 carries is read into
162
+ `X12AttachmentData`, whose string, JSON and `util.inspect` forms state its octet count and nothing
163
+ else; `readOctets()` is the one route to the octets.
164
+
165
+ **Builder refusals are a weaker surface, deliberately.** A `build*` refusal names the control
166
+ number, count or code you passed in, so that you can see what was refused. The rendered fragment is
167
+ bounded by an exported constant, but it is bounded rather than redacted. The two attachments
168
+ builders are the exception and name no value at all. Log `err.code`, not `err.message`, from a
169
+ builder.
170
+
171
+ **What you still own.** Transport, storage, retention, access control, audit logging, and every log
172
+ line your own code writes. This library makes no HIPAA compliance claim on your behalf.
173
+
174
+ ## API
175
+
176
+ Everything ships from one entry point; there are no subpath imports.
177
+
178
+ - **Read.** `parseX12` decodes an interchange into a model, and a per-transaction reader turns a
179
+ transaction set into a typed one (`get835`, `get837Claims`, `get270Inquiry`, `get271Eligibility`,
180
+ `get276StatusInquiry`, `parse999`, `parseTA1`, and the rest).
181
+ - **Emit.** `serializeX12` and `buildInterchange` are the general path, and every transaction set
182
+ with a reader also has a domain builder (`build835`, `build837P`, `build271`, and the rest) that
183
+ layers that guide's own invariants on top.
184
+ - **Errors and warnings.** Parsing is lenient, and only **four** structural failures are ever fatal
185
+ (`X12_NO_ISA_HEADER`, `X12_ISA_TOO_SHORT`, `X12_INVALID_DELIMITERS`, `X12_EMPTY_INPUT`).
186
+ Everything else arrives on `ix.warnings` as a stable code with positional context, so a tolerated
187
+ deviation is visible rather than silent.
188
+ - **Money.** Every monetary, percent and quantity field decodes as `X12Decimal`, which is
189
+ string-backed with BigInt arithmetic, and this library never **`parseFloat`s** an EDI amount. A
190
+ slot the sender left empty reads `undefined` rather than zero, so "stated zero" and "stated
191
+ nothing" stay different readings.
192
+ - **Conformance.** `X12_TR3_CONFORMANCE` states which implementation guide each transaction set
193
+ follows and how that identifier stands against the federal incorporation by reference.
194
+ - **CORE Code Combinations.** `checkCoreCodeCombination` answers whether an 835 adjustment's group,
195
+ reason and remark codes are in a CORE Code Combinations table for a CORE-defined business scenario
196
+ you name. You supply the table and name its version, no CORE table ships with this package, and
197
+ an `unevaluated` answer is never permission to post.
198
+
199
+ Task-oriented recipes are in the [cookbook](./docs-content/cookbook.md), and
200
+ [KNOWN-LIMITATIONS.md](./KNOWN-LIMITATIONS.md) is the honest do-not-over-trust list.
201
+
202
+ ### Dates and times
203
+
204
+ Every typed reader surfaces its dates verbatim, as the qualifier the sender used plus the bytes it
205
+ carried. `toObject`, `toISO` and `toDate` are the three ways to read one, and every `@cosyte/*`
206
+ parser exports the same three names with the same shapes and the same timezone rule.
207
+
208
+ ```ts
209
+ import { toObject, toISO, toDate } from "@cosyte/x12";
210
+
211
+ // A DTP or DTM date off any typed reader: claim.dates[0], eligibility.dates[0], and so on.
212
+ const day = { formatQualifier: "D8", value: "20260601" };
213
+
214
+ toObject(day); // { year: 2026, month: 6, day: 1 }
215
+ toISO(day); // "2026-06-01"
216
+
217
+ // A calendar day is not an instant. Without a stated offset there is no answer.
218
+ toDate(day); // undefined
219
+ toDate(day, { assumeOffsetMinutes: 0 })?.toISOString(); // "2026-06-01T00:00:00.000Z"
220
+ toDate(day, { assumeOffsetMinutes: -300 })?.toISOString(); // "2026-06-01T05:00:00.000Z"
221
+
222
+ // A range converts to nothing, because an interval is not a point in time.
223
+ const span = { formatQualifier: "RD8", value: "20260601-20260605" };
224
+ toISO(span); // undefined
225
+ ```
226
+
227
+ **The decoded format qualifier set is `D8` and `RD8`, and nothing else.** Those are the two DTP-02
228
+ values this library already parses and builds: `D8` is a single `CCYYMMDD` day, and `RD8` is a
229
+ `CCYYMMDD-CCYYMMDD` range. A qualifier outside that pair, an absent one, a value whose digits do not
230
+ match the shape its qualifier declares, and a day that is not on the calendar all convert to
231
+ `undefined`. None of the three ever throws, so a lenient parse stays lenient all the way to the
232
+ value you read.
233
+
234
+ **`RD8` converts to `undefined` from all three, deliberately.** An interval is not a point in time,
235
+ so there is no single day to hand back for a service period or an eligibility span. Handing back the
236
+ first endpoint would be a quiet wrong answer with a right-looking shape. Split the range yourself
237
+ and convert whichever end you meant.
238
+
239
+ **`toDate` returns an instant only when you state the zone.** No X12 date element this library
240
+ decodes carries a UTC offset, so `assumeOffsetMinutes` (signed minutes east of UTC) is the only way
241
+ one reaches the result. Without it the answer is `undefined`: the host machine's timezone is never
242
+ read and UTC is never assumed, because the same midnight is two different instants either side of a
243
+ border and a date of birth read in the wrong zone is a day out.
244
+
245
+ `toObject` returns only the components the value stated, with `month` spec-native 1 to 12 rather
246
+ than the JavaScript `Date` 0 to 11, so `Object.keys()` of the result tells you the precision. Delete
247
+ `offsetMinutes` and what is left is accepted by `Temporal.PlainDateTime.from` and by luxon's
248
+ `DateTime.fromObject` with no renaming. Neither library is a dependency here; this package still has
249
+ zero of those.
250
+
251
+ `parseDocumentDate`, which reads the document date for the date-aware code-list queries, keeps its
252
+ own contract and still THROWS on input it refuses. That divergence is deliberate: it answers a
253
+ caller who asked a validity question and could not state the day, while these three read a document
254
+ that was already parsed leniently.
255
+
256
+ #### Using two @cosyte parsers in one file
257
+
258
+ The three names are identical in every `@cosyte/*` parser, so importing two of them means aliasing:
259
+
260
+ ```ts
261
+ import { toISO as x12ToISO } from "@cosyte/x12";
262
+ import { toISO as hl7ToISO } from "@cosyte/hl7";
263
+
264
+ x12ToISO({ formatQualifier: "D8", value: "20260601" });
265
+ hl7ToISO(someParsedHl7Timestamp);
266
+ ```
267
+
268
+ A namespace import works too: `import * as x12 from "@cosyte/x12"`, then `x12.toISO(day)`.
269
+
270
+ ## Compatibility
271
+
272
+ - **005010 HIPAA transaction sets**, with hooks for the errata revisions the industry actually
273
+ exchanges. Non-healthcare sets such as 850, 856, 810 and 204, plus EDIFACT, AS2 and SFTP
274
+ transport, and pre-005010 revisions, are out of scope.
275
+ - **Vendor deviations become warnings, not exceptions.** Built-in profiles record whose
276
+ companion-guide deviation they accommodate; selecting one never changes an otherwise correct
277
+ parse, and profiles are authored through the same public `defineProfile()` API you have.
278
+ - **Round trips are not byte-exact in general.** `serializeX12` reproduces the segments on the model
279
+ verbatim, including element padding, composites and `?`-release escapes, but
280
+ **`serialize(parse(s)) === s` is not guaranteed in general**. Line breaks between segments are the
281
+ common case: any run of CR or LF between segments is absorbed at parse, so a pretty-printed file
282
+ emits compact. Most of the constructs that break a round trip are silent, so "my file has no line
283
+ breaks" is not sufficient either. [KNOWN-LIMITATIONS.md](./KNOWN-LIMITATIONS.md) is the canonical
284
+ list.
43
285
 
44
- ## What's inside
286
+ ## Contributing
45
287
 
46
- - **Typed read + emit, per transaction**: 271, 277/277CA, 278, 820, 834, 835, 837P/I/D, plus 999/TA1 acknowledgments. Per-transaction helpers (`get835`, `get837Claims`, `get271Eligibility`, …) and a matching domain builder for each (`build835`, `build837P/I/D`, `build271`, …). The 270 and 276 inquiries parse as generic segments; they have no typed model yet.
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.
48
- - **Money is exact, and never invented**: every monetary/percent/quantity field decodes as `X12Decimal` (string-backed, BigInt arithmetic), and the library **never `parseFloat`s** an EDI amount. **No slot holds an amount this library did not decode.** Where a reader used to substitute `X12Decimal.ZERO` the slot is now `X12Decimal | undefined` and reads `undefined`, so "the sender stated zero" and "the sender stated nothing" are different readings; through `0.0.12` they were the same `0`. That is a rule about the substitution, not a census of the model: some rows are dropped whole instead, which is a different and pre-existing shape, and no total is published here. Read [KNOWN-LIMITATIONS.md](./KNOWN-LIMITATIONS.md).
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.
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. 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).
288
+ Questions, bug reports and trading-partner quirks all belong in
289
+ [GitHub issues](https://github.com/cosyte/x12/issues).
51
290
 
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.
291
+ Pull requests are welcome. A contribution must clear `pnpm typecheck`, `pnpm lint` and `pnpm test`,
292
+ hold the per-directory coverage floors, pass the PHI commit gate (`pnpm phi-scan`) and the prose
293
+ gates (`pnpm check:no-emdash`, `pnpm check:no-internal-refs`), and carry a changeset. Every fixture
294
+ must be synthetic: never open a pull request carrying a real patient's data.
53
295
 
54
296
  ## Trademarks
55
297
 
56
- Availity and Blue Cross Blue Shield are trademarks of their respective owners. cosyte is not affiliated with, endorsed by, or
57
- sponsored by any of them. The names identify the trading partners whose companion-guide deviations the built-in profiles accommodate. See [TRADEMARKS.md](./TRADEMARKS.md).
298
+ Availity and Blue Cross Blue Shield are trademarks of their respective owners. cosyte is not
299
+ affiliated with, endorsed by, or sponsored by any of them. The names identify the trading partners
300
+ whose companion-guide deviations the built-in profiles accommodate. See
301
+ [TRADEMARKS.md](./TRADEMARKS.md).
58
302
 
59
303
  ## License
60
304
 
61
- MIT. See [LICENSE](./LICENSE).
305
+ MIT, copyright Cosyte. See [LICENSE](./LICENSE).
62
306
 
63
307
  Built by [Cosyte](https://cosyte.com).