@cosyte/terminology 0.0.5 → 0.0.7

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,8 +9,298 @@ this file is maintained by hand (Changesets handles the version bump and publish
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ### Changed
13
+
14
+ - **Breaking: `ExpansionDiagnostic.system` is replaced by `ExpansionDiagnostic.path`.** The field
15
+ used to carry the code system or value set URI the concern was about — consumer-supplied text of
16
+ unbounded length on a diagnostic surface. It now carries a value-free structural locus into your
17
+ own resource, built from integers and the engine's own field names: `"compose.include[2]"`,
18
+ `"compose.include[0].valueSet[1]"`, `"expansion"`. A diagnostic raised while expanding a
19
+ referenced value set is prefixed with the reference that reached it. This is strictly more precise
20
+ than the URI, which cannot distinguish two `include` components naming the same system. `expand`
21
+ and `validateCodeInValueSet` build the path identically, so a locus means the same thing in both;
22
+ they do **not** always emit the same _set_ of diagnostics, because membership can decide an answer
23
+ without evaluating a part expansion still has to report. Reading `d.system` is now a type error.
24
+ The rationale, and the other three sites, are under **Fixed** below.
25
+
12
26
  ### Fixed
13
27
 
28
+ - **The UCUM unit table `loadUcumEssence()` hands out was `readonly` to TypeScript only, so a unit's
29
+ definition could be rewritten in place — and doing so fabricated a unit equivalence that outlived
30
+ putting the table back** (`TERMINOLOGY-MUTABLE-ESSENCE-TABLE`). One table is shared by every caller
31
+ and by the engine's own reducer, `essence.ts` froze nothing, and `reduce` memoizes each atom's
32
+ reduction against the atom object. So corrupting a loaded atom **before that atom's first
33
+ reduction** wrote the memo, and the reading survived restoring the table exactly as shipped.
34
+
35
+ Measured on `bf153cb`, and live on published `0.0.5` and `0.0.6` alike — `0.0.6` is that commit plus
36
+ a version bump. Defining the loaded litre as `1` with no prior
37
+ touch of `L`: `ucumEqual("L", "1")` and `ucumEqual("mg/L", "mg")` both answered `true` where the
38
+ same calls without it answered `false`, and `mmol/L` fell from `6.0221407599999985e+23 × m-3` to a
39
+ dimensionless `6.02214076e+20` — **a concentration comparing equal to a mass**. Two further routes
40
+ to the same readings on that build: writing the nested definition object in place
41
+ (`atom.value.unit = "1"`), and replacing an element of the `atoms` array, which is the one
42
+ `parseUcum` scans, so it hands the parser a forged atom without touching the memo at all.
43
+
44
+ This is the half that keying the memo on the atom **object** did not reach. That closed the route
45
+ where the atom was one you had _built_; this is the route where the atom is one you were _handed_,
46
+ and the consequence class is identical. Neither substitutes for the other.
47
+
48
+ The table is frozen when it is parsed, deeply: every atom, every atom's definition object, every
49
+ prefix, and both arrays. Shallow would have been worth nothing — freezing the atom alone leaves
50
+ `atom.value.unit`, which reproduces all three readings. `atomByCode` and `prefixByCode` refuse
51
+ `set`, `delete` and `clear`, which `Object.freeze` cannot do for a `Map`. `parseEssence` freezes
52
+ what it returns on the same rule.
53
+
54
+ **The guarantee is that the table does not change, not that a write throws.** `Object.freeze`
55
+ refuses a write by throwing in strict mode and ignoring it in sloppy mode, and this package ships a
56
+ CJS build a sloppy-mode caller can require; the table is the one that was parsed either way. The
57
+ tests assert the readings, and the `TypeError` only as how a strict-mode caller observes it.
58
+
59
+ **Two things this does not claim, both measured rather than reasoned.** `Map.prototype.set.call()`
60
+ can still force an entry into a lookup map — no `Map` can be made immutable in place — and what
61
+ that reaches is bounded and pinned in both directions: `parseUcum` resolves an atom by scanning
62
+ `atoms`, never through `atomByCode`, so a forced entry changes **no** reduction and no `ucumEqual`
63
+ answer, only what the caller's own `get` returns. And the values `parseUcum` and `reduce` hand back
64
+ are deliberately still per-call and mutable.
65
+
66
+ **No reduction changes.** 3,080 expressions built from the table — every atom bare, squared,
67
+ inverted and annotated; every prefix over rotating metric atoms; rotating ratios, products and
68
+ grouped powers; and the shapes the README and the tests name — plus 312 `ucumEqual` pairs, reduce
69
+ byte-identically on `bf153cb` and on this tree (3,393 output lines, one sha256).
70
+
71
+ One knock-on, stated because getting it wrong is easy: freezing the table removes **a** route to
72
+ `reduce`'s cyclic-definition guard, not the last one. Two routes that look like they should reach
73
+ it do not, and both are now pinned as tests — naming a table atom in a definition (it is resolved
74
+ against the loaded table, so your atom can _name_ one but never _be_ one; a self-referential `Pa`
75
+ taken off a table copy resolves to the shipped pascal and answers `1000 × g.m-1.s-2`), and
76
+ corrupting a loaded atom, which is what is now refused. The route that does reach it needs no table
77
+ at all: `UcumAtom.value` is `readonly` to TypeScript, which an **accessor** satisfies, and the
78
+ definition is read after the atom is marked in progress, so a getter can re-enter `reduce` for the
79
+ same atom. That is a supported public call, and the guard is pinned by it under a 100,000-byte atom
80
+ code — so all three of `reduce`'s messages are again asserted whole, against both `message` and
81
+ `err.stack`, with the caller's own code as the unbounded value in each.
82
+
83
+ Also here: the `/* v8 ignore next */` on `reduce`'s `atom.base` line is removed. It justified
84
+ itself with a property of the bundled table — every base atom there carries a `dim` — for a line
85
+ reachable through the exported surface with a caller-built `{ base: true }` atom that carries none.
86
+ That arm is answered on the atom's own code, is now covered by a test rather than excluded, and
87
+ changes nothing for the table's own base units.
88
+
89
+ - **A `UnitNode` you assembled yourself could change how `reduce` and `ucumEqual` answer for a unit
90
+ parsed out of the bundled UCUM table, for the rest of the process**
91
+ (`TERMINOLOGY-ATOMMEMO-POISONING`). `reduce` is exported and takes a caller-built node, and
92
+ nothing checks that the atoms on it came from the bundled table. Its per-atom memo, and the guard
93
+ against a definition that leads back to itself, were keyed on `atom.code` — a **string** — so an
94
+ atom you built wrote under the key of whichever shipped atom happened to share its code, and every
95
+ later reduction of that shipped atom read what yours had left.
96
+
97
+ Measured on `0.0.5`: one `reduce()` over a value-less atom coded `L`, as the first touch of `L` in
98
+ the process, made `ucumEqual("L", "1")` and `ucumEqual("mg/L", "mg")` both answer `true` where the
99
+ same calls without it answered `false`, and dropped `mmol/L` from `6.02214076e23 × m-3` to a
100
+ dimensionless `6.02214076e20` — **a concentration comparing equal to a mass**, from an engine
101
+ whose contract is never to fabricate. Two further readings on the same build: an atom coded `N`
102
+ and defined as `N` left the cycle guard armed, so every later `ucumEqual("N", "kg.m/s2")` threw;
103
+ and the value returned for a term with no factors was the engine's own module-global dimensionless
104
+ object, so mutating a result you had been handed turned `kg.m/s2` into `999000 × m2.g.s-2` and
105
+ made `ucumEqual("N", "kg.m/s2")` answer `false`.
106
+
107
+ Four changes:
108
+ - The memo and the cycle guard key on the **atom object**, in a `WeakMap` / `WeakSet`. The atoms
109
+ `parseUcum` resolves are the unit table's cached singletons, so a parsed expression still gets
110
+ one memo entry per atom; an atom you built gets its own entry and is collected with it. Measured
111
+ warm over ten mixed expressions, best of seven trials of 2,000,000 `reduce` calls, on two
112
+ different machines: identity-keyed came in at or ahead of string-keyed both times. Read that as
113
+ no measured cost rather than as a speed-up; the absolute figures are machine- and load-dependent
114
+ and are deliberately not quoted as a pair.
115
+ - An atom carrying no linear definition, and which is neither a base nor an arbitrary unit, is now
116
+ **refused** with `UCUM atom has no linear definition` rather than answered — and memoized — as
117
+ dimensionless. No expression `parseUcum` accepts reaches this, though **not** because every atom
118
+ in the bundled table has a linear definition: 21 of its 312 atoms, the special units, have none.
119
+ It is that an expression containing a special unit short-circuits to the opaque `special` form
120
+ before linear reduction, base and arbitrary units are answered directly, and every remaining atom
121
+ resolves. A node you assemble can reach it — including by defining an atom in terms of a special
122
+ unit such as `Cel`, which lands on the table's own `Cel`.
123
+ - The cycle guard releases its atom in a `finally`, so a throw from inside the recursion no longer
124
+ wedges that atom for the rest of the process.
125
+ - `reduce` no longer returns the module-global dimensionless object. What you get back is per-call
126
+ state, and mutating it cannot reach the engine.
127
+
128
+ Keying on identity was chosen over validating that each atom came from the bundled table: it needs
129
+ no provenance check on the hot path and adds no failure mode for real input, and it is the
130
+ narrower change. The cost is that an atom you built is answered on its own terms only — the
131
+ intended contract, not a regression. **No unit obtained from `parseUcum` changes its answer**: the
132
+ 530-case UCUM conformance suite and the rest of the suite are unchanged and green, and the new
133
+ refusals are reachable only through a node you assembled. **The claim rests on one property, and
134
+ it is the one this repo asserts: every atom in the bundled table reduces, and none of them reaches
135
+ a refusal** (`test/ucum/reduce-memo.test.ts`, last case, over all 312). That is enough to cover
136
+ every expression `parseUcum` accepts rather than sampling them — an expression containing a
137
+ special unit short-circuits before linear reduction, linear reduction is compositional over atoms,
138
+ and the table's own definitions close over that same set, so once every atom answers there is
139
+ nothing left for a bigger corpus to vary. `test/ucum/functional.test.ts` separately drives the
140
+ vendored UCUM functional-test suite's unit strings through `reduce`.
141
+ **Do not restate this as more evidence than that.** Three drafts of this paragraph each claimed
142
+ more than the tree holds — two cited generated-corpus sizes that were not reproducible from their
143
+ own descriptions, and the third attributed a three-clause sweep to a file that pins one clause of
144
+ it. A before/after differential is a two-tree measurement and **no in-tree test can pin one**, by
145
+ construction; do not add a guard to make a sentence here true, shrink the sentence. The three
146
+ guards are now covered by tests
147
+ rather than by a `/* v8 ignore */` block, and each message is pinned by whole-message equality
148
+ rather than by a substring match.
149
+
150
+ - **Consumer-supplied strings reached `TerminologyError.message`, `err.stack` and an
151
+ `ExpansionDiagnostic`, contradicting this package's own "value-free by construction" claim**
152
+ (`PHI-WARNING-MESSAGE-LEAK`). Three sites, found by binding
153
+ `assertNoDiagnosticPhiLeak` from `@cosyte/test-utils@0.0.2` to a 52-slot table covering the
154
+ consumer-controlled positions across the readers and loaders that this repo has identified — which
155
+ is not a proof that none was missed, as the fourth site below shows; **14 of the 52 slots were red on the
156
+ base commit** — 4 CSV column-config slots, 1 `invertGem` slot, 4 in `expand.ts` and 5 in
157
+ `validate.ts` — and the table was run there first for exactly that reason. (An earlier draft of
158
+ this entry said 9. That was the measurement taken before the slot table was extended to the
159
+ **second copy** of the `src/valueset/` diagnostic factories, and it was never re-taken; the five
160
+ `validate.ts` slots are the difference. Re-measure after you widen a table.)
161
+ 1. `csv.ts` `requireHeader` interpolated the caller's configured column name into the
162
+ `TERM_CODESYSTEM_MALFORMED` message. Measured against the published `0.0.5`: a 1,000,000-byte
163
+ `columns.code` produced a 1,000,063-byte `err.message`, and the same bytes landed in
164
+ `err.stack`. It now reads the message out of a frozen `MISSING_COLUMN_MESSAGE` table keyed on
165
+ the column's **role** (`code` / `display` / `status` / `property`) and takes no value
166
+ parameter. That is the `astm`/`transform` shape the ecosystem audit recorded for the designs
167
+ that do not leak, and it is what this factory now is — a property of **this** factory, not a
168
+ rule this package holds everywhere. Several factories here do take `string` parameters and are
169
+ safe because every argument reaching them is engine-owned; they are named below, and reading
170
+ the no-value-parameter shape as a package-wide rule is what made three drafts of this entry
171
+ wrong.
172
+ 2. `invertGem` interpolated `GemMap.direction` into the `TERM_MAP_NOT_INVERTIBLE` message.
173
+ `GemDirection` is a two-value union, but nothing validates it on load and a JavaScript caller
174
+ can pass anything, so the echo was unbounded in practice. Now a closed-set `Map` lookup with a
175
+ direction-free fallback; the direction is kept where it is one of the two authored values,
176
+ because it tells the caller which file to load instead.
177
+ 3. `ExpansionDiagnostic.system` carried the caller's `include.system`, referenced `valueSet` URL,
178
+ or `ValueSet.url` verbatim. **This site is not in the 2026-07-30 ecosystem audit, which
179
+ recorded `terminology` as "one site, a CSV column name".** It is the only one of the three
180
+ that is _document_-derived rather than caller-configuration. **Breaking:** the field is
181
+ replaced by `path`, a value-free index locus (`compose.include[2]`,
182
+ `compose.include[0].valueSet[1]`, `expansion`), built the same way in both `expand.ts` and
183
+ `validate.ts` so a locus means the same thing in each, and prefixed with the reference that
184
+ reached it when the diagnostic comes from a referenced value set. The two do **not** always
185
+ emit the same _set_ of diagnostics and the changeset no longer says they do: membership
186
+ short-circuits on a system mismatch and on an exclude that cannot change a decided verdict,
187
+ where expansion still has to report the part it could not compute. Not a truncation: an index path is
188
+ strictly more precise than the URI, which cannot distinguish two `include`s over one system.
189
+
190
+ **Severity is medium-low and stays medium-low.** Sites 1 and 2 echo the caller's own
191
+ configuration, not a patient's document; site 3 echoes a canonical URI out of reference content.
192
+ The engine's genuinely patient-derived inputs are its _query_ values (a code off a parsed
193
+ message, a unit off an observation, an NDC off a prescription, the `PatientContext` age/gender a
194
+ complex map branches on), and **no query value reaches a message or a stack anywhere**: the
195
+ engine throws only on an unusable source, never on a query. That is now asserted rather than
196
+ argued.
197
+
198
+ **A misfiled result field, corrected in the same docblock and shipped in `dist/index.d.ts`:
199
+ `MapProvenance.sourceSystem`.** A draft of the "which result fields carry verbatim text" list put
200
+ the whole of `MapProvenance` under _your loaded artifact_ — "reference data, not anything a
201
+ patient supplied". That is true of `.targetSystem`, `.conceptMapUrl` and `.conceptMapVersion`
202
+ (`group.target`, `map.url`, `map.version`) and **false of `.sourceSystem`**, which `translate`
203
+ fills from the `system` on the `Coding` **you** passed it, verbatim and unbounded, falling back to
204
+ the map's `group.source` only when the coding carries none. It is a query echo and is now listed
205
+ as one, so a reader applies the log-it-only-if-you-would-log-the-code rule to it. This is a
206
+ classification fix in prose: the value has always been the caller's own, it is not on a
207
+ diagnostic-message surface, and **nothing about the runtime behaviour changed** — bounding it is
208
+ not attempted here, and would be the same fabricate-a-miss trade-off as bounding any other echo.
209
+
210
+ **The `@param` contract this broke** is `TerminologyError`'s own: _"a **value-free** structural
211
+ description (path + fault; never an input value)"_. It now also tells a caller why, since the
212
+ string reaches `err.stack`.
213
+
214
+ **The "Value-free by construction" claim in `src/common/diagnostics.ts` was checked before being
215
+ rewritten, and it was false** on two counts: the two messages above echoed arbitrary input, and
216
+ "at most a code + system + version" described the leaking `ExpansionDiagnostic.system` as though
217
+ it were the safety property. The new wording states the mechanical fact — **every string a message
218
+ is assembled from is owned by the engine**: a literal, a frozen-table entry, or a locus this
219
+ package built (a `line`, a column count, an index path of integers and FHIR field names) — and, in
220
+ the same place, scopes it: it covers **diagnostics**, not **results**, which carry the caller's
221
+ values by design. A first draft of that replacement said instead that "no message is built from a
222
+ value parameter" and that every message is "a literal or a frozen-table entry". **That was false as
223
+ written**, of factories it did not touch: `malformed(path, fault)` in `conceptmap/load.ts` and
224
+ `valueset/load.ts`, and `cannotExpand` / `truncated` / `underPath` in `valueset/` — where
225
+ `expand.ts` and `validate.ts` each carry their own copy — all of which do interpolate
226
+ `string` parameters. **An earlier draft of this paragraph said "four", and the tree has seven**;
227
+ a count in prose is the thing that goes stale, so it is derived now, not written down.
228
+ The code is fine — every argument reaching them is engine-owned, each `fault`
229
+ a literal at the call site and each `path` built from indices — but the sentence was stronger than
230
+ the code, which is precisely the failure this entry exists to end. It now describes the arguments,
231
+ not the signatures.
232
+
233
+ **The mechanism is now stated as a general rule in exactly one place — the
234
+ `src/common/diagnostics.ts` docblock.** `README.md`, `docs-content/troubleshooting.md` and
235
+ `docs-content/concepts-archetype.md` carried the old "code + system + version" sentence, and the
236
+ `test/phi/diagnostic-surface.test.ts` header (new in this slice) carried the same doctrine in a
237
+ later wording; all four now state only the **consumer-facing property** — nothing you configured
238
+ and nothing your release or resource contained reaches a `message`, `detail`, `reason` or
239
+ `err.stack`, at any length — and none restates the general rule. An earlier draft of this slice
240
+ moved all four to a _restatement_ of the mechanism instead, in the wording already found false of
241
+ `malformed` / `cannotExpand` / `truncated`, and this entry claimed they had moved with the fix
242
+ when what they had moved to was the superseded draft. No gate in this repo can grade an English
243
+ sentence, which is the argument for one copy of the rule rather than four. **Statements scoped to
244
+ a single factory are not copies of the rule and deliberately stay put** — `csv.ts` still says its
245
+ own `MISSING_COLUMN_MESSAGE` lookup takes no value parameter, and `TerminologyError`'s `@param`
246
+ still tells a caller what may go into the string it is handed. The `diagnostics` guidance in
247
+ `troubleshooting.md`, which told readers to read a diagnostic for "which code system was missing",
248
+ points at `path`.
249
+
250
+ **What the gate proves is narrow, deliberately:** for each declared slot, no verbatim echo of
251
+ four or more bytes of the planted value on any swept surface, and the slot provably reached the
252
+ code it names. It does not prove the absence of a re-encoded echo, an echo under four bytes, or a
253
+ leak through a slot nobody declared. Two limits are written into the file rather than implied
254
+ away. First, `expectCode` shows the branch ran, not that the _marker_ ran it: about half the slots
255
+ are **model-only** and pair the marker with a second marker-free malformed element so a diagnostic
256
+ exists to sweep, and for those it is the sweep of **those diagnostics** that carries the proof. (An
257
+ earlier draft said "the whole rendered result". The runner sweeps exactly what the two selectors
258
+ return plus the thrown value, so for the `CodeSystem.url` slot the `url` itself is never swept —
259
+ correctly, since it is payload, but not swept.) Second, `getModelIdentifiers` returns the model's
260
+ real loci — a `RxNormLoadWarning.file`
261
+ token, an `ExpansionDiagnostic.path` — which are also swept as part of their diagnostic, so it is
262
+ redundant rather than load-bearing; it exists so the classification of every name-like string on
263
+ every exported model type is executed and reviewable instead of collapsing to a `[]` a reader has
264
+ to trust. `Property.code` and `RxNormEdge.predicate` are classified as payload on a checkable
265
+ test: an HL7 v2 `segment.type` is spec-bounded to three characters, so bounding it discards
266
+ nothing, while a FHIR `code` carries no `maxLength` and RxNorm's `RELA` is an open vocabulary, and
267
+ both are the keys the engine and the caller match on, so truncating either turns a hit into a
268
+ miss. The residual is
269
+ named: that protects the lookup, not a future consumer who builds a locus out of one.
270
+ `@cosyte/cli` is today the only package in the ecosystem that depends on this one, and it uses
271
+ `loadConceptMap` + `translate` only.
272
+
273
+ **A FOURTH site, found by the refuter after the three above: `reduce` echoed a forged atom's
274
+ `code` into a plain `Error.message` and `err.stack`** — a second ~1 MB leak. `reduce` is exported
275
+ and takes a caller-built
276
+ `UnitNode`, and nothing checks that the atoms on it came from the vendored UCUM table, so a
277
+ forged atom whose `value.unit` does not parse reaches the throw. Measured on `0.0.5`: a
278
+ 1,000,000-byte atom code produced a **1,000,042-byte** `Error.message`, with the same bytes in
279
+ `err.stack`. Every `Error` message `reduce` can throw is a literal — two of them at that point,
280
+ three after the memo fix recorded at the top of this section. The site had been marked `/* v8 ignore */` as
281
+ "unreachable with the shipped data", and the throw was moved outside that block, with
282
+ `test/ucum/reduce.test.ts` pinning the message and the stack. **The two branches still inside the
283
+ block were not unreachable either, and the comment stopped saying they were.** A draft of that slice
284
+ relabelled them "genuinely unreachable through the public API"; they were not, because `inProgress`
285
+ and `atomMemo` keyed on the atom's `code` string and nothing checked that an atom came from the
286
+ vendored table, so a caller-forged atom whose code collided with a shipped one re-entered the
287
+ cyclic guard (measured: `reduce` over a forged `mol` defined as `mol` threw it) and a forged atom
288
+ with no `value` fell through the second, which threw nothing — it memoized dimensionless and
289
+ returned. The "with the shipped data" scope was the defensible one and was restored, stated
290
+ as a coverage exclusion over a corrupt-table guard rather than as an assertion of unreachability.
291
+ Neither branch leaked — the one message inside the block was a literal — so nothing was deferred **on
292
+ the diagnostic surface**. What was deferred is not merely that the branches were reachable: that
293
+ second branch wrote the forged atom's `code` into the module-global `atomMemo` before returning,
294
+ so one `reduce()` over a forged, value-less atom whose code collided with a shipped one poisoned
295
+ every later reduction of that atom in the process — enough to make `ucumEqual("mg/L", "mg")`
296
+ answer `true`. That is a **never-fabricate defect, not a diagnostic-surface one**, it was
297
+ byte-identical on `0.0.5`, and it was filed as its own item rather than folded in here. **It is
298
+ fixed in this same unreleased window — see the memo-poisoning entry at the top of this section —
299
+ and the `/* v8 ignore */` block is gone with it.** It is **not** in the slot table and
300
+ cannot be: it throws an un-coded `Error`, so it can carry no `expectCode`, and every slot in
301
+ that table names one. The 2026-07-30 ecosystem audit recorded this package as "one site"; with
302
+ `ExpansionDiagnostic.system` and this, it was **three**.
303
+
14
304
  - **`LICENSE` was unqualified MIT over a tree that also distributes third-party material**
15
305
  (`TERMINOLOGY-LICENSE-THIRD-PARTY`, residuals 1 and 2 of `TERMINOLOGY-RESIDUALS`). The README had
16
306
  already grown a "What is bundled" carve-out; the licence file had not, so the two consumer-facing
package/README.md CHANGED
@@ -199,10 +199,19 @@ _names_, listed under [What is bundled](#what-is-bundled).
199
199
  **are** bundled are named, with their copyright, under [What is bundled](#what-is-bundled).
200
200
  - **Liberal load, conservative assertion.** Malformed input degrades to a typed diagnostic; a 1:many
201
201
  mapping returns the full candidate set, never collapsed to one.
202
- - **Value-free diagnostics.** A diagnostic carries a code + system + version, never patient context —
203
- a code _in patient context_ can be PHI.
202
+ - **Value-free diagnostic messages.** Nothing you configured and nothing your release or resource
203
+ contained reaches a `message`, `detail`, `reason` or `err.stack` — not a column name, not a
204
+ canonical URI, not a unit atom — at any length. What sits beside a message is a locus: a line
205
+ number, a column count, an index path into your own resource (`compose.include[2]`), or a fixed
206
+ token naming which file of a release it came from. The
207
+ **objects** are a different matter: a typed `unknown`/`unmapped` outcome names the code, unit or
208
+ NDC you asked about, so log the `code` and the locus rather than the whole result — a code _in
209
+ patient context_ can be PHI.
204
210
  - **Zero runtime dependencies. Dual ESM + CJS.** Node stdlib only; built with `tsup`, validated with
205
- `attw`. Immutable by construction (every returned value is deep-frozen).
211
+ `attw`. The models the engine loads for you are frozen — the bundled unit table deeply so, atom by
212
+ atom and definition by definition, because it is shared with the engine's own reducer. The AST and
213
+ the reduction that `parseUcum` and `reduce` hand back are per-call values you own, and mutating one
214
+ changes nothing for anyone else.
206
215
 
207
216
  ## What is bundled
208
217