diff --git a/CHANGELOG.md b/CHANGELOG.md index d4320f8..748b834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,14 @@ of XML, unit conversion, and executing a transaction Bundle. ### Changed +- **The npm package ships the runtime files only, and the README is the short version.** No runtime + behaviour changes. The package carries `dist/index.mjs`, `dist/index.cjs`, `dist/index.d.ts`, + `dist/index.d.cts`, `LICENSE`, `README.md` and `package.json`: the two sourcemaps and this + changelog no longer ship, and the changelog stays in the repository. Both bundles still end in a + `sourceMappingURL` comment, and the map it names is not in the package. The README keeps the + install command, the quickstart (still executed on every test run), the status, the API outline + and a short PHI and safety section, and points to docs.cosyte.com/fhir; the full capability + readout moved unchanged to `documentation/capabilities.md`. - **The installation page's clone command uses `https://github.com/cosyte/fhir`, without the `.git` suffix.** docs.cosyte.com checks every link into a cosyte repository against the repositories its manifest marks public, and its matcher reads `https://github.com/cosyte/fhir.git` as a repository diff --git a/CLAUDE.md b/CLAUDE.md index 82dc5c2..6f39ce8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,9 +21,15 @@ semantics, and validate it against US Core, without reading the FHIR spec. deliberately NARROWED tarball and is `latest`, tagged, with provenance. `0.0.11` restored the README, the changelog and the sourcemaps, and on 2026-08-28 it drew the identical bare `E403` on `PUT https://registry.npmjs.org/@cosyte%2ffhir`, provenance signed into rekor first - (logIndex `2626322912`) exactly as every refusal before it. Still pre-alpha. - **Read the version from `package.json`, never infer it from npm**: it runs ahead again, at `0.0.11` - unpublished with no tag. + (logIndex `2626322912`) exactly as every refusal before it. + **Read the version from `package.json`, never infer it from npm**: it runs ahead again, at `0.1.0` + unpublished with no tag (`0.0.11` never published either). + - **`0.1.0` WAS REFUSED TOO (2026-09-25), BY THE STAGED PUBLISH, AND THE NARROWED SHAPE IS BACK AS + THE CONTROL.** The full tarball drew `E403` on + `POST https://registry.npmjs.org/-/stage/package/@cosyte%2ffhir`, provenance in rekor first. The + next publish ships `files` narrowed to the four runtime artifacts plus `LICENSE` and a slim README + free of the XML-security vocabulary, NOT a stub. Detail and what it does and does not separate: + [`#publish-state-fhir-npm-name`](documentation/agent-notes.md#publish-state-fhir-npm-name) - **THE CONTENTS ARE NOW THE LEADING HYPOTHESIS, WHICH IS A REVERSAL, AND IT IS STILL NOT PROVEN.** The one publish that succeeded is the one that stripped the tarball; the next one restored it and was refused. **The confound is that `0.0.10` CREATED the package and `0.0.11` ADDED A VERSION to diff --git a/README.md b/README.md index da52df3..67b167e 100644 --- a/README.md +++ b/README.md @@ -7,60 +7,50 @@ # @cosyte/fhir -> Developer-focused FHIR toolkit for Node.js and TypeScript: an **R4-first** resource model, a -> JSON **and XML** codec, and validation, with the same one-line ergonomics as the rest of the -> `@cosyte/*` parser suite. - -**Status: 0.1.** While the package is below 1.0, a breaking change ships in a minor version and is -called out in the changelog. What is built: the no-data-loss core (a -precision-preserving JSON codec and typed primitive model), the first three validation layers -(structure, cardinality, and primitive/enumerated-`code` value-domain) with value-free -`OperationOutcome` output, the **safety-critical status & negation model** (`readSafety`, -fail-closed on unknown `modifierExtension`, the `ait`/`con`/`obs` invariants), the **R4 base -constraints of the eight modeled resource types** (`pat-1`, `obs-3`, `con-1`, `con-2`, `imm-1`, -`dom-2` to `dom-5`, `ele-1`, `ext-1`), evaluated with no profile supplied, **Quantity / UCUM -fidelity** (the 11-way `Observation.value[x]` discrimination, UCUM-`code` unit fidelity, vital-signs -required-unit conformance, dose quantities), **strength-aware, content-free terminology binding -validation** (a frozen known-systems registry, binding-strength severity, the multi-system allergy / -medication bindings, and a pluggable terminology-service interface, none bundled), -**StructureDefinition-driven profile validation** (snapshot generation, slicing, `fixed[x]` / -`pattern[x]`, and must-support as a system obligation, against caller-supplied US Core / vendor -profiles, none bundled), and **profile-invariant validation through a bounded, vendored FHIRPath -subset** (an in-repo lexer → parser → evaluator that evaluates a profile's `constraint[]`, reporting -anything outside the subset `INVARIANT_UNCHECKED` rather than passing it), and a **zero-dependency XML -codec** (`parseResourceXml` / `serializeResourceXml`) that reads and writes the same schema-free model -as the JSON codec, with a reader that is **XXE- and billion-laughs-proof by refusal** (any DTD or -non-predefined entity is refused loudly, never resolved or expanded) and a `nodesEquivalent` oracle for -JSON↔XML model equivalence, and **Bundles + references + Bulk NDJSON streaming** (`readBundle` with -**transaction = all-or-nothing vs batch = independent** semantics: modeled, never executed; reference -resolution for relative / absolute / logical / `#fragment` with a **DoS-safe cycle guard**; and a -`streamNdjson` reader with **per-line error isolation** and **no whole-file load**), and a -**programmatic profile-authoring API** (`defineProfile()` builds a `StructureDefinition` in code: the -same model `loadStructureDefinition` reads from JSON, one path with no privileged internal shape, plus -a spec-grounded **starter kit** of example profiles that dogfood it), and **conformance hardening**: -JSON + XML + NDJSON **fuzz targets** proving adversarial input never -crashes / hangs / OOMs (only a _typed_ error or a bounded rejection; the JSON reader now bounds nesting -with a `MAX_DEPTH_EXCEEDED` fatal, matching the XML reader), a **PHI-leak test tier** gating the -value-free-diagnostics contract, and **type-level (`expect-type`) tests** on the public surface. See -[What works today](#what-works-today). It **reads, round-trips, -structurally validates, never drops a modifier / status / negation, surfaces measured values by their -true type with the UCUM `code`** (never the display string, never converted), **validates code systems -and binding strength without vendoring any SNOMED / CPT / LOINC content, validates against US Core -profiles you supply, and evaluates their FHIRPath invariants** (failing safe to `INVARIANT_UNCHECKED` -on any unsupported expression); it does **not** yet do `type`·`profile` slicing discriminator or -reslicing validation (still `PROFILE_SLICE_UNCHECKED`), and it bundles **no** US Core -IG corpus. The `validator_cli.jar` **differential is authored but CI-only** (a JVM oracle job: there is -no Java in the dev container, so it has not been observed green there) and now runs over **both** the -synthetic spec-clean corpus **and** the real-world quirk corpus. The built-in structural schema set is the base-resource elements plus -`Patient` as a worked demonstrator; other resource types validate only against a caller-supplied schema -or profile. Without a supplied terminology service there is **no code-validity / value-set-membership** -guarantee beyond `system` + strength (no terminology content is bundled: licensing). Its XML codec is -schema-free like the JSON one, so an XML-sourced primitive is kept as its lexical string and **typed -cross-format transcoding** (emitting spec-clean JSON booleans/numbers from an XML model) needs the -datatype schema and is not yet done; the XHTML **structure** inside `Narrative.div` is not modeled or -validated (carried opaquely as a string (the JSON codec's fidelity), never dropped), and RDF/Turtle is -out of scope. It has no typed per-resource models -yet, and it **never converts a unit** or evaluates a reference range. +> Read real-world FHIR R4, keep every value exactly as it was written, and validate it against your +> profiles. + +[![npm version](https://img.shields.io/npm/v/@cosyte/fhir.svg)](https://www.npmjs.com/package/@cosyte/fhir) +[![CI](https://img.shields.io/github/actions/workflow/status/cosyte/fhir/ci.yml?branch=main&label=CI)](https://github.com/cosyte/fhir/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) +[![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org) + +FHIR R4 for Node.js and TypeScript: a resource model that keeps every value exactly as written, JSON and XML codecs, layered validation, and a safety readout for status and negation. + +## Why this exists + +Reading FHIR correctly takes more than a JSON parse: a `decimal` written `0.010` has to stay +`0.010`, an `integer64` has to keep every digit, and a status, a modifier or a negation must never be +skipped, because a missed `refuted` reads as its opposite. We built `@cosyte/fhir` for the engineer +who reads FHIR R4 from a server or an EHR (the format ONC HTI-1 binds §170.315(g)(10) to, with US +Core and SMART on FHIR) and has to get those values right without reading the specification first. +Type definitions alone describe a resource's shape and check nothing at run time, and `JSON.parse` +turns `0.010` into the number `0.01` and knows nothing about modifiers. This library reads, writes +and validates FHIR R4 with zero runtime dependencies, and tells you with a coded reason what it could +not read safely. + +## Status + +**The 0.1 line.** From 0.1 the public API is settled: the exported names, options, result shapes and +issue codes are the surface we keep stable. While the package is below 1.0, a breaking change ships +in a minor version and is called out in the +[changelog](https://github.com/cosyte/fhir/blob/main/CHANGELOG.md). Upgrading from 0.0.10 changes +verdicts: R4's own constraints are now evaluated with no profile supplied, so a document that read +valid can read invalid. The changelog sets out each change. + +What it covers, against FHIR R4 (`4.0.1`): reading and writing JSON and XML into one immutable +model; validation in layers (structure, cardinality, value domains, R4's own constraints on the +eight modeled resource types, Quantity and UCUM, binding strength, and profiles you supply or author +with `defineProfile`, including their FHIRPath invariants); the `readSafety` and +`assertSafeToSummarize` safety readout; and Bundles, references and streaming Bulk Data NDJSON. R5 +and DSTU2 documents can be read, since the model is schema-free, but they are not validated against +their own definitions. + +Not covered yet: typed per-resource models (the built-in structural schemas cover the base elements +plus `Patient`), `type` and `profile` slicing discriminators and reslicing (reported +`PROFILE_SLICE_UNCHECKED`), FHIRPath outside the supported subset (reported `INVARIANT_UNCHECKED`, +never passed), bundled terminology or US Core content, spec-clean JSON written from a model read out +of XML, unit conversion, and executing a transaction Bundle. ## Install @@ -110,1142 +100,71 @@ reading?.quantity?.code; // => "mm[Hg]" validateResource(resource).valid; // => true ``` -The [quickstart](./docs-content/quickstart.md) walks the same document step by step. - -## What works today - -The no-data-loss core: read FHIR R4 JSON into an immutable model and serialize it back, **without -ever losing a decimal, a primitive extension, or an exact 64-bit value**. - -```ts -import { parseResource, serializeResource } from "@cosyte/fhir"; - -const { resource, issues } = parseResource( - '{"resourceType":"Observation","valueQuantity":{"value":0.010,"unit":"mg"}}', -); - -// The trailing zero survives: a naive JSON.parse would have made this 0.01. -serializeResource(resource); // → {"resourceType":"Observation","valueQuantity":{"value":0.010,"unit":"mg"}} - -// Diagnostics are value-free: a code + a FHIRPath location, never the value. The location is -// built from the names the document supplies, and those are bounded to their published form. -issues; // → [{ code: "DECIMAL_PRECISION_AT_RISK", severity: "information", expression: "Observation.valueQuantity.value" }] -``` - -- **`decimal` / `integer64`** are string-backed (`FhirDecimal`, `FhirInteger64`) and never routed - through the JS `number` type. `FhirDecimal.equals` is precision-sensitive (`0.010 ≠ 0.01`); - `.equalsValue` compares quantity only. -- **Primitive extensions** (the `_element` sibling) are modeled first-class with **null-padded array - alignment**; a misaligned value/`_`-array **fails closed** rather than mis-attaching an extension. - A `_`-sibling written beside an element that is **not** a primitive has no defined meaning (a - complex element carries its `id`/`extension` inline), and the reader does not model what was in it, - so that position raises `MISPLACED_PRIMITIVE_EXTENSION`: its own code, because unlike an unexpected - property it says content is **not readable** there rather than merely tolerated. -- **A diagnostic's `expression` is a location and nothing else.** R4 defines - `OperationOutcome.issue.expression` as a FHIRPath subset that resolves to a node, so an issue says - _where_ in the path and _why_ in the `code`, and never explains itself in prose inside the path. - Two forms are deliberately not resolvable and are the only two: a `` segment, which is - what a name that fails the bounded-echo shape test prints as, and the XML reader's `.@name`, which - is an XML attribute FHIR gives no element to address. -- **Lenient read, conservative write** (Postel's Law), `resourceType` resolvable in any position, and - a `parseReference` classifier (relative / absolute / logical / fragment). The writer authors no - value of its own, and it emits spec-clean FHIR for every model FHIR can express; the shapes it - cannot express (an array inside an array, a scalar or `null` where FHIR JSON has an object (at a - complex element's own position, or in a primitive's `_`-sibling), a `null` - in a **primitive's** value channel that padded nothing, and a - non-string `resourceType`) are handed back as written - rather than repaired, because repairing them means inventing or dropping content. See the - no-data-loss notes below. **The two `null` entries are different branches**, and for a long time - only the first was listed while the second was silently deleted on emit. -- **A repeated property name is read, not resolved.** FHIR requires unique property names and JSON - leaves the winner undefined, so the first value wins everywhere and a `DUPLICATE_PROPERTY` issue - says where. On an **object** element both values are kept (`getAllProperties` reads them, - `getProperty` still returns the first), the element is treated as genuinely ambiguous, and nothing - downstream pretends otherwise: it validates as an error, the safety readout declines to - summarize it rather than answering from one arbitrary half of the document, and **both writers - refuse** rather than emit the surviving member alone. Inside a **primitive's - `_element` metadata** (`id` and `extension`, which no safety verdict reads) the issue is raised but - the shadowed member is not kept, and validation and the safety readout are unaffected. - -And the first three validation layers (structure, cardinality, and primitive/enumerated-`code` -value-domain) with a value-free `OperationOutcome`: - -```ts -import { parseResource, validateResource, serializeResource } from "@cosyte/fhir"; - -const { resource } = parseResource('{"resourceType":"Patient","gender":"masculine","wibble":1}'); -const { issues, valid } = validateResource(resource); // lenient (read) mode by default - -valid; // → false -issues; -// → [ -// { code: "UNKNOWN_ELEMENT", severity: "warning", type: "structure", expression: "Patient.wibble" }, -// { code: "CODE_INVALID", severity: "error", type: "code-invalid", expression: "Patient.gender" }, -// ] - -// Render an OperationOutcome: the diagnostics are value-free (a coded reason + a location, never -// the offending value "masculine"), the PHI redaction chokepoint. -serializeResource(validateResource(resource).toOperationOutcome()); -``` - -- **Layered, severity-tagged** (validation.html): structure (`UNKNOWN_ELEMENT`, `TYPE_MISMATCH`, - `CHOICE_AMBIGUOUS`), cardinality (`CARDINALITY_MIN`/`_MAX`), value-domain (`PRIMITIVE_INVALID` with - the R4 datatype regexes, `CODE_INVALID` for required-strength enumerations). -- **Lenient vs strict:** an unknown element is a `warning` on read and an `error` under `mode: "strict"`. -- **Fail-safe:** never a false error: a resource type with no schema degrades to one informational - `RESOURCE_NOT_MODELED`, not a wall of false unknowns. Built-in schemas: base-resource elements + - `Patient` + `Observation`, and that list is the whole set; supply your own via - `validateResource(resource, { schemas: [...] })`. The set grows one fully-verified element table at - a time, because a table missing a row R4 defines would turn a conformant document into a wall of - false unknowns, which is the failure the degrade above exists to avoid. - -And the safety spine: FHIR's modifier (`?!`) elements, surfaced so they can never be silently dropped -or inverted, and the invariants that harm a patient when read wrong: - -```ts -import { parseResource, readSafety, validateResource } from "@cosyte/fhir"; - -const { resource } = parseResource( - '{"resourceType":"AllergyIntolerance",' + - '"clinicalStatus":{"coding":[{"system":"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical","code":"active"}]},' + - '"code":{"coding":[{"system":"http://snomed.info/sct","code":"716186003"}]}}', -); - -readSafety(resource).negations; // → ["no-known-allergy"] (a recorded "no allergy", not an allergy TO it) - -// An unknown modifierExtension fails closed: the resource cannot be safely processed. -const { resource: quirky } = parseResource( - '{"resourceType":"Observation","status":"final","modifierExtension":[{"url":"http://vendor.example/x"}]}', -); -validateResource(quirky).issues.map((i) => i.code); // → ["UNHANDLED_MODIFIER_EXTENSION"] -``` - -- **Never-droppable status/negation:** `readSafety` carries `status` / `clinicalStatus` / - `verificationStatus` / `doNotPerform` / retraction and a classified `negations` list (`refuted`, - `no-known-allergy`, `do-not-perform`, `not-taken`, `not-done`, `entered-in-error`). The - type-scoped slots are filled for the types `SAFETY_RESOURCE_TYPES` names; **the negation reads that - can only add a finding are not type-scoped at all**: a retraction, a refutation, an instruction - **not** to perform, and a `status` of `not-done` / `not-taken` are surfaced whatever the resource - type, because a gate does not merely fail to read the types it omits, it never looks, so nothing is - reported for them either. Those same reads run at **every resource root**, so a retracted - `Observation`, a `Procedure` recorded as not performed or an order marked "do not perform" inside a - `Bundle` entry or `contained` reaches `negations` too. **The readout's location channels - (`unhandledModifierExtensions`, `modifierElements`, `intents`, `datatypeUses`, - `shadowedProperties`, `arrayWrappedScalars`, `nestedArrays`, `droppedText`, `unreadableBooleans`, - `nearMissNegationCodes`, `unreadableNegationCodes`, `unreadableIntents`, - `unreadableDatatypeUses`, `absenceMarkers`, `unreadableAbsenceMarkers`, - `conflictingAbsenceMarkers`) and `safeToSummarize` are document-wide.** Every one of them but - `absenceMarkers`, `intents` and `datatypeUses`, which disclose rather than refuse, sets - `safeToSummarize` to `false` when it is not empty. The **single-valued** fields (`status`, `retracted`, `doNotPerform`, `noKnownAllergy` - and the rest) answer about the resource you handed in, because one value cannot say which resource - it came from, so branch on `negations` whenever a resource may carry others. - `no-known-allergy` is the exception and - stays a root, type-scoped read: it is a _positive_ clinical assertion, read off an element R4 does - not flag `?!`, and surfacing one from somewhere inside a document could make a caller less careful - where leaving it unsurfaced reads as _unknown_. - `assertSafeToSummarize` **refuses** (throws) rather than flatten past an unhandled modifier. -- **A modifier is not only a `modifierExtension`, and the ordinary base elements R4 flags `?!` reach - the readout too.** `modifierElements` carries one entry per location, each - `{ element, location }`: `comparator` wherever a node the walk reaches carries it, `implicitRules` - likewise, `active`, `deceased` (R4's `deceased[x]`, located at the member as written, such as - `Patient.deceasedDateTime`) and `link` (once, at `link`, however many entries) on a `Patient` root, - `isSubpotent` on an `Immunization` root, and `use` on a `Practitioner`'s `identifier` entries - (on the eight types below, `use` is surfaced as its code instead). Each - sets `safeToSummarize` to `false` and makes `assertSafeToSummarize` throw, **on presence and at any - value**, because `{"valueQuantity":{"value":0.01,"comparator":"<","unit":"mg"}}` summarized as a - point value is `0.01 mg` reported for a result the sender wrote as `< 0.01 mg`, a deceased patient - or a record replaced by another is not the live record a summary would show, and a subpotent dose - does not protect the way a potent one does. So `"deceasedBoolean": false` and - `"isSubpotent": false` refuse too, exactly as `"active": true` does: deciding from the value would - be interpreting the modifier. **Reporting only:** the element is - surfaced and never interpreted, so no bound, range or inequality is read out of a `comparator`, no - unit is ever converted, no death date is read and no `link` is followed or its `type` read. - Recognition is by KEY NAME (and, for the type-gated elements, by - literal `resourceType` equality), which OVER-reports by construction: any object carrying a - `comparator` member is an occurrence, a vendor payload that reuses the name included. That trade is - deliberate, since a false positive costs a refusal and a false negative costs a wrong clinical - value. A report carries the element and the location and nothing from the document: no value, no - unit, no code, no URI; and a location roots at a resource type name only when the name is one this - library defines (`MODIFIER_ELEMENT_ROOT_TYPES`), so an unmodeled type reads as a constant token. - `modifierElements(resource)` is the standalone collector, and a modifier EXTENSION stays on - `unhandledModifierExtensions` and draws nothing here, so one of those is still one report. -- **`MedicationRequest.intent` is surfaced as its code, because it is mandatory.** R4 flags it a - modifier (a `proposal` is not an `order`) and makes it `1..1`, so refusing on presence would refuse - every MedicationRequest. `intents` carries one `{ code, location }` per `MedicationRequest` root, - the resource handed in, `contained` or a `Bundle` entry alike, and a readable one leaves - `safeToSummarize` standing. Only the eight codes of the R4 value set (`proposal`, `plan`, `order`, - `original-order`, `reflex-order`, `filler-order`, `instance-order`, `option`) are surfaced, matched - exactly. Anything else written at `intent` (`"PROPOSAL"`, `" order"`, `"draft"`, a `null`, a - number, the `_intent` form with no value, an array wrapper, the name written twice) surfaces no - code: its location is on `unreadableIntents`, `safeToSummarize` is `false`, and - `assertSafeToSummarize` throws. Nothing is case-folded, trimmed or mapped. An absent `intent` - surfaces nothing and refuses nothing, since a missing mandatory element is the validator's verdict. -- **`use` on an identifier, a name, an address or a contact point is surfaced as its code.** R4 flags - it a modifier on all four datatypes, so that an `old` or `temp` entry is not taken for a current - one. `datatypeUses` carries one `{ code, location }` per covered position: every `identifier` and - `groupIdentifier` at any depth (a Reference's `identifier` included) below a root of - `AllergyIntolerance`, `Condition`, `DiagnosticReport`, `Immunization`, `MedicationRequest`, - `MedicationStatement`, `Observation` or `Patient`, and a `Patient`'s `name`, `telecom` and - `address`, on the patient and on each `contact`; the root may be the resource handed in, - `contained` or a `Bundle` entry, and a position belongs to its nearest enclosing root. Only a code - of the position's own R4 value set is surfaced, matched exactly (identifier `usual` `official` - `temp` `secondary` `old`; name `usual` `official` `temp` `nickname` `anonymous` `old` `maiden`; - address `home` `work` `temp` `old` `billing`; contact point `home` `work` `temp` `old` `mobile`). - **A readable `use` does not lower `safeToSummarize`, `old` and `temp` included:** `use` is written - on most records, so refusing on presence would refuse nearly every patient, and refusing on which - code was written would be interpreting the modifier. Nothing maps a code to "not current", and no - entry is filtered, reordered or picked as the current one; that is the caller's decision. Anything - else written at `use` (`"OLD"`, `" old"`, `""`, a code from another datatype's set such as - `"maiden"` on an identifier, a `null`, a number, the `_use` form with no value, an array wrapper, - the name written twice) surfaces no code: its location is on `unreadableDatatypeUses`, - `safeToSummarize` is `false`, and `assertSafeToSummarize` throws. A position with no `use` - surfaces nothing. **Limits:** a `Practitioner` is not read here (its `identifier.use` stays on - `modifierElements`, reported on presence); a `use` carried in an extension value - (`valueIdentifier`, `valueHumanName`, `valueAddress`, `valueContactPoint`) is not read; a nested - resource of a type outside the eight, such as a contained `Organization`, is not read; and an - ended `period` on an otherwise current entry is a separate element this does not read. -- **A safety verdict is never asserted over a value the document left ambiguous.** Each negation read - runs over every coding on a `CodeableConcept` and every value written for the element it reads - (`resourceType`, `status`, `verificationStatus`, `code`, `doNotPerform`), **including through an - array wrapper around the element**, so a retraction or a refutation cannot hide in the one a - single-value lookup skipped. Where a repeated property name leaves an element with two values, - `safeToSummarize` is `false` with the locations in `shadowedProperties` instead of an affirmative - answer. -- **A single-valued element wrapped in an array is read, and reported.** FHIR JSON writes a `0..1` - element as a name/value pair and uses an array only for a repeating element, so - `{"resourceType":"Observation","status":["entered-in-error"]}` is non-conformant, and a plain - single-value read finds no code in it at all. It is realistic input, because a generic XML-to-JSON - converter array-wraps every element it emits. The negation reads see through the wrapper, an - `ARRAY_WRAPPED_SCALAR` issue (error) says where it was, and `safeToSummarize` is `false` with the - locations in `arrayWrappedScalars`. The check covers `resourceType` and the single-valued safety - elements on a resource root; deciding cardinality elsewhere would need a per-resource model, and R4 - genuinely does define repeating elements under some of the same names (`Questionnaire.code`), so a - name-only rule would report a conformant document as broken. -- **That extends one level down, to a `Coding.system` / `Coding.code` inside a `CodeableConcept`**, - which are `0..1` too and which the same converter wraps the same way, so a refuted allergy, a - recorded "no known allergy" and a retracted Condition all hinge on it. Here the wrapper is read only - where it holds **exactly one array position**, and the restriction is the safety property, not - caution: those two values are paired with each other, so a rule yielding more than one value on - either side would pair a `system` written in one position with a `code` written in another and - **assert a coding the sender never wrote**, including a recorded absence of allergy, which is a - positive clinical claim about a patient. Positions, not values: a JSON `null` inside a primitive - array is a real position whose `_`-sibling may carry an extension, so `["716186003", null]` is two - positions and is not read. A wrapper that is not read is still reported, so a negation is never - quietly skipped: either way the `Coding` draws an `ARRAY_WRAPPED_SCALAR` error and - `safeToSummarize` is `false`. - **Scope:** this covers the codings of `clinicalStatus`, `verificationStatus` and `code`, which are - the elements a safety verdict is read out of. A `Coding` anywhere else (`category`, - `interpretation`, `referenceRange.type`, a `component`'s own `code`, and anything `codingsOf` is - pointed at directly) is read exactly as it was before, without the wrapper. Reading a wrapper the - library does not also report would resolve a clinical code out of an encoding FHIR JSON does not - define and hand it back with no diagnostic anywhere. - **The type scoping stops at the element name, and it does not reach the `Coding` inside it.** The - negation reads are not type-scoped at all, so a `verificationStatus.coding.system` / `.code` - wrapper is reported at **every** resource root of **any** type, including inside `contained` and a - `Bundle.entry` -- read through where it holds one position, left unread where it holds more, and - reported either way. What makes that safe without a per-resource model is that `Coding` is a - _datatype_: its `system` and `code` are `0..1` wherever a `Coding` appears, so no question about - the enclosing resource arises. A `code` carrying SNOMED `716186003` is read only on the types the - cardinality table knows, so its wrapper is reported wherever it is read. - **One read still runs ahead of its report, and it is named rather than smoothed over:** the - `clinicalStatus` convenience field is filled off **any** resource root, so on a type the - cardinality table does not know, that value is unwrapped (or, for a multi-position wrapper, - declined) with no location reported, so `safeToSummarize` stays `true` over a value the library - declined to read. Otherwise it reaches that one field and nothing else: never `negations`, never - `valid`, never `noKnownAllergy`. A declared residual, pinned in both states, not a design. -- **An array inside an array is reported, and its contents are kept but never interpreted.** FHIR - JSON uses an array for a repeating element and for nothing else, so a list of lists has no meaning - at any position and there is no element for the reader to make of it. Left alone the model then - looks exactly like an element the sender legitimately left out: whole resources have gone missing - this way inside a `Bundle.entry`, and a refuted allergy has read back as an ordinary active one. So - the position is named on every channel. `NESTED_ARRAY` on the read (warning) and in - `validateResource` (error), the locations in `nestedArrays`, `safeToSummarize` is `false`, and - `assertSafeToSummarize` throws. `isNestedArray` marks the node for a consumer walking the model - directly. Because the shape is meaningless everywhere, this needs no cardinality rule and cannot - fire on a conformant document, so unlike the two above it the check runs at every position the - model has a node for, at every depth, including a primitive's `extension` metadata. - **The array itself is not lost.** Its exact JSON text is preserved on the node and handed back by - `nestedArrayContent`, so you can inspect or re-parse what the sender wrote (`readRawJson` will - parse it with the same precision guarantees as the rest of the codec). A repeating primitive can - nest in its value array, in its `_`-sibling array, or in both at one position, so the two channels - come back separately rather than merged. `serializeResource` writes the array back, which is the one - place the writer emits something it would not author: the alternatives are to emit the empty - element the model holds, which fabricates an object the sender never wrote, or to omit the - position, which drops content. Writing it back is also what makes the finding survive a round trip - rather than laundering away. The preserved text is the array re-rendered compactly, so member order, - every member of a repeated key and every number's exact source survive, but insignificant - whitespace does not and strings are re-escaped canonically, exactly as everywhere else this library - emits JSON. Such output is deliberately **not** spec-clean. -- **A scalar where FHIR JSON has an object is handed back too, and that one stops the writer authoring - a value.** One position over from the shape above: a string, number, boolean or `null` written where - a complex element belongs is content the reader has no element to make of it either, so it reports - `UNKNOWN_PROPERTY` and the model holds an empty element there. Emitting that element writes `{}`, - which is a **conformant** empty element, so the warning was gone the moment the output was read - back and the writer had presented an object as read at a position nothing was read at. - `serializeResource` now writes the value the sender wrote instead, so the finding survives the round - trip. The scalar is **not** modeled as a primitive, deliberately: putting it in the tree would make - it visible to every walker at a position walkers read as a complex element. It hangs off the node - (`FhirComplex.nonObjectSource`), where only the writer reads it. Such output is deliberately **not** - spec-clean, and `serializeResourceXml` does not carry it (that writer emits the empty element, the - same as it does for an array inside an array). - **The same rule reaches a primitive's `_`-sibling, which is the other place FHIR JSON has an - object.** §2.6.2.3 gives that channel the `id` and/or `extension`, so `{"_status":null}` and - `{"_status":"x"}` carry no metadata to model; the writer emits a `_`-sibling only for metadata it - has, so both used to come back as `{}` with **no diagnostic at all**: the member gone, and - `valid` and `safeToSummarize` both affirming. They now draw the same `UNKNOWN_PROPERTY` and the - text is handed back (`FhirPrimitive.nonObjectMetaSource`), so the finding survives the round trip. - **A `null` padding a repeating primitive's `_`-array is the one shape §2.6.2.3 defines and draws - nothing**; a `null` at a singleton `_` slot is never padding and does. - **Gaps, stated rather than implied:** the rule is bounded by what the reader modeled. A - `_`-sibling the reader discards whole because it is misplaced or unrecognised (one sitting on an - object or a non-primitive array, or a member of a `_`-sibling object that is neither an `id` - **string** nor an - `extension` array) leaves no node behind, so an array inside one is reported against the discarded - sibling and draws no refusal. **Which warning it draws is per member, not one code for all three.** - An unrecognised member of a `_`-sibling object draws `UNKNOWN_PROPERTY`: - `{"birthDate":"1980-01-01","_birthDate":{"foo":[["x"]]}}` reports it at `Patient.birthDate.foo`. A - `_`-sibling on an object and one on a non-primitive array instead draw - `MISPLACED_PRIMITIVE_EXTENSION` for the misplaced sibling and nothing besides: - `{"name":{"family":"Roe"},"_name":[[{"id":"x"}]]}` reports that one code at `Patient.name`, and no - unexpected-property warning. Reaching it would mean reading raw JSON the codec - does not model, which is the same problem as making the value readable. An **empty** `_`-sibling - object or array is a different clause of the spec (§2.6.2.1's "never empty") and is still deleted - silently; a `_`-sibling object's own unreadable **member** is reported but that report still does - not survive emit. Both are open and declared, not closed here. - **What it deliberately does not do is put the array in the tree.** The preserved content is text, - not an element: it is not reachable through a node's properties, items or extensions, so a list - holds exactly the items it held before, of the same kinds, with the same contents, and nothing that - walks a repeating element sees anything new. That boundary is not a matter of taste. This library - has checks that flatten a repeating element into its items and then skip whatever is not the kind - they expect, so a list holding a list would reach them as an absent value: a profile invariant, a - vital-signs unit check or a negation would go unevaluated and the resource would read as valid. - Preserving the text costs none of that. - **One further limitation, stated rather than hidden:** a scalar written beside a nested array in - the same array (`"given":[["Peter"],"James"]`) lands where an object was expected, and that scalar - is still dropped. It is reported as an unexpected property and the resource is still refused, but - unlike the array itself its content is not kept. -- **A `null` the reader reads into a primitive slot is reported and written back, which is the other - half of the rule above and used to be missing.** The bullet above covers the `null` the reader - takes to the **complex** branch. Every other `null` is a different branch, and it read with no - diagnostic at all and was then deleted on emit, so a non-conformant document came back as a clean - conformant one with the member simply gone and every layer affirmed it. - `{"identifier":[{"system":"…","value":null}]}` lost the identifier value; - `{"value":null,"unit":"mg"}` lost the magnitude and **kept the unit**, so a quantity read back as a - bare unit rather than as missing; `"status":null` lost the status. Nothing was _lost_ in the sense - the shapes above lose things (a `null` carries no content), which is precisely why it was - invisible: the output was indistinguishable from a document whose sender had legitimately omitted - the element. - **The rule is what §2.6.2.3 actually defines, not "the document wrote `null`".** FHIR JSON forbids - `null` (§2.6.2.1: "properties never have null values (except for a special case documented below)") - and carves out one exception, which is about a **repeating** primitive: the value array and the - `_`-sibling array are padded with `null` so they align index-by-index. So two conditions are - required for a `null` to be that exception, and both are checked: it sat **inside a repeating - primitive's value array**, and the slot it produced **carries an `id` or a non-empty `extension`** - for it to align with. One that is padding draws nothing and round-trips byte-for-byte exactly as - before. One that is not leaves an element with neither a value nor children, which R4 `ele-1` - requires one of: it draws `UNDEFINED_JSON_NULL` (warning) at that position, `isUndefinedNull` marks - the node, and `serializeResource` writes the `null` back so re-reading the output reproduces the - finding instead of laundering it away. Such output is deliberately **not** spec-clean, on the same - reasoning as the two shapes above. - **A singleton slot is never padding, whatever sits beside it.** §2.6.2.3 states the singleton - encoding positively ("If the primitive has an id attribute or extension, but no value, only the - property with the `_` is rendered"), so a value-absent singleton is `{"_status":{…}}` and both - `{"status":null}` and `{"status":null,"_status":{…}}` are reported. **And the set this walks is - what the reader read as a primitive, not what FHIR types as one:** the model is schema-free, so a - bare `null` at any singleton property reaches the primitive branch whatever the element's FHIR type - would be, and `{"subject":null}` on an `Observation` is reported here rather than as an unexpected - property. Only an array item, and an item of a `_`-sibling's `extension` array, reach the complex - branch. - **What it deliberately does not do, stated rather than implied.** It does not refuse. A `null` is a - non-conformant encoding of an _absent_ value, not content the reader could not read, so unlike an - array inside an array or dropped element text there is nothing unreadable at the position, and - `validateResource` and `safeToSummarize` are unchanged; refusing would also withdraw round trips - that work today, and nothing that round-trips today stops (a value-absent primitive written the - conformant way carries no `null`, so it is never marked). It is scoped to the **value** channel: a - `_`-sibling that is itself not an object (`"_status":null`) is the neighbouring position and draws - `UNKNOWN_PROPERTY` instead, on the reasoning above. **No case has ever moved between the two - codes**, because that channel drew nothing at all until it was closed. And - `serializeResourceXml` does not carry it: XML has no `null`, so it **refuses** rather than emit the - empty element (see `UNSERIALIZABLE_JSON_ONLY_SHAPE` below). -- **A primitive whose value is written as XML element text is reported, not silently read as an - absent value.** FHIR XML carries a primitive's value in the `value` attribute, so - `entered-in-error` puts a code where the model has no slot for one: the character - data is dropped and the element is left holding nothing. That is the same harm as an array inside - an array, reached through the other wire format, and it is the sharper one, because the shape a - retraction takes is an **affirmation**. Measured: a `entered-in-error` read back - as a live record, an `AllergyIntolerance` that lost its `refuted`, and a `doseQuantity` that lost - the dose **number** while its `mg` unit and UCUM code survived, all under `valid: true`. So the - position is named: `DROPPED_ELEMENT_TEXT` in `validateResource` (error), the locations in - `droppedText`, `safeToSummarize` is `false`, and `assertSafeToSummarize` throws. `isDroppedText` - marks the node for a consumer walking the model directly, and the reader's existing - `UNEXPECTED_XML_CONTENT` warning is kept alongside it rather than replaced. Like the rule above it - needs no cardinality table and cannot fire on a conformant document, so it runs at every position - the model has a node for, at every depth, and never on a document read from JSON. - **The text is not read back as the value, deliberately.** Recovering it would be a _tolerance_ for - a non-conformant encoding rather than a report of one, and this library encodes a tolerance only - when a real document shows the shape in the wild. So the value stays unread and the verdict is a - refusal rather than a repair. - **Two limitations, stated rather than implied.** Whitespace between elements is not character data - in this sense and is untouched, so ordinary indented XML is unaffected; but text written beside a - value that _did_ arrive (`entered-in-error`) is dropped too and - draws the same refusal. The rule is keyed on the reader **dropping** character data, not on the - text differing from the value: the reader never compares the two, so - `final` refuses as well, even though nothing is missing there. That - is deliberate. Deciding the document meant no harm would mean reading the text, which is the - tolerance this half does not take. -- **A boolean value outside the datatype's lexical space is reported, not read as an absent - instruction.** R4 spells a `boolean` as `true` or `false` and nothing else, so - `` and `value="Y"` (ordinary v2 and C-CDA converter output, which is how - a great deal of data reaches a FHIR surface) carry no boolean this library may read. They are not - `false`; they are **unreadable**, and the difference is the whole point: measured, a `value="1"` - read back exactly like `value="0"`, so a prescriber's "yes, do not administer" and another's "no" - produced the same answer, with nothing on any channel to say a value had been written and dropped. - So the position is named: the locations in `unreadableBooleans`, `safeToSummarize` is `false`, and - `assertSafeToSummarize` throws. **The value is still not read**, deliberately: coercing `"1"` or - `"Y"` would invent a reading the spec does not license, and it would turn `value="0"` into a JS - `false` that `serializeResource` then emits, laundering an authored value across a format change. - The channel covers `doNotPerform`, the only `boolean` `readSafety` takes off a document, on any - resource type. Its window is every resource root (so a `contained` or `Bundle.entry` order counts), - which is **the negation read's own window**: the value that is read and the value that cannot be - read are decided together, in one pass, so neither can cover a document the other does not. It is - **not** the whole of `arrayWrappedScalars`' window, and the gap is a declared residual rather than - a nicety -- that report's _element-level_ half stays on the resource types this library models a - cardinality for, so `{"resourceType":"ServiceRequest","doNotPerform":[true]}` is read here and on - `negations`, while the array wrapper it arrived in draws no `ARRAY_WRAPPED_SCALAR`. A primitive carrying only `id` / `extension` and - no value is untouched, since nothing was written there to be unread, so this cannot fire on a - conformant document in either wire format. - **Unlike the five findings above it, this one raises no `ValidationIssue` of its own**, for a narrow - and measured reason rather than a general one: the request resources that define `doNotPerform` - have no built-in schema, so the validator is silent about this element's **datatype** unless a - caller supplies one, and the readout has to hold either way. The safety layer knows the datatype - unconditionally, which is why the report lives there. -- **A code that spells a negation bar its case or its surrounding whitespace is reported, not read as - one.** A negation is matched by its exact `code`, because FHIR `code` is case-sensitive and the - datatype's lexical space has no room for surrounding whitespace (`[^\s]+(\s[^\s]+)*`). So - `{"resourceType":"Procedure","status":"NOT-DONE"}` (an upper-casing source system) and a `status` - of `" not-done"` (a padded fixed-width or CSV extract) assert no negation, and **that reading is - correct and unchanged**: folding them in would accept a non-conformant document as though it were - conformant and hand you a negation the sender never spelled. **What was wrong was that the refusal - was silent.** Measured: every such document returned `negations: []` under - `safeToSummarize: true`, with nothing on any channel to say a value had been looked at and - declined, so a caller doing exactly what this readout says to do (branch on `negations`, not on - the raw status string) read a procedure recorded as `"NOT-DONE"` as a procedure with nothing to - say about it. So the position is named: the locations in `nearMissNegationCodes`, - `safeToSummarize` is `false`, and `assertSafeToSummarize` throws. **The value is still not - coerced, trimmed or case-folded.** Unlike the location channels above it nothing is - dropped at parse time either: the value is in the model, at the element the location names, and - what the library declines is the _classification_. That is **not** a promise the value reaches a - convenience field, and the difference bites in two ordinary shapes: `status` / - `verificationStatus` are root-scoped and single-valued while this channel is document-wide, so a - near miss inside `contained` or a `Bundle.entry` leaves them holding the root's value or - `undefined`; and `verificationStatus` surfaces the preferred-system coding, so a near miss in a - second coding is not the code it shows. Walk the model at the location. - **A near miss is suppressed where the same element also spells that code exactly**, since the - negation is then classified: R4 permits translation codings beside the one from a required - binding's value set, so a `verificationStatus` carrying `refuted` from the standard system and - `REFUTED` from a local one is conformant and draws nothing. Suppressed per code, so a near miss of - a _different_ code at that element still reports. The elements are the `code`-valued ones the negation read - looks at, `status` and `verificationStatus`, at every resource root, which is the negation read's - own window. The pairs come from the same table the matches are made from, so the report cannot - cover a pair the read does not. `AllergyIntolerance.code` is deliberately outside it: SNOMED - `716186003` "no known allergy" is a _positive_ assertion whose read is root- and type-scoped, and - disclosing a near miss at every root would report the miss where an exact hit is read by nothing. - Like the boolean channel above, it raises no `ValidationIssue` of its own, so it cannot move - `valid` in either direction. **Empty on every conformant document read from JSON, bar one shape - admitted rather than claimed away**: a `CodeableConcept` may carry translation codings beside the - one a required binding's value set supplies, and R4 asks only that _one_ coding come from that set, - so a translation whose code differs from a negation code **only by case** is conformant under that - reading and is disclosed. Only the case half can be: a surrounding-whitespace value is outside - `code`'s lexical space whatever coding carries it. Over-disclosure is the fail-safe direction. **In - XML the - whitespace half is a declared limit rather than a claim**: R4 derives `code` from `xs:token`, - whose `whiteSpace=collapse` facet strips surrounding whitespace before validation, so - `` is schema-valid and a schema-validating consumer reads it as the - code. This reader is schema-free and does not collapse, so it discloses rather than reads, which is - the fail-safe direction. -- **Content written where a `code` belongs is reported, not read as an absent element.** FHIR JSON - spells a `code` as a JSON string, so `{"resourceType":"Procedure","status":{"value":"not-done"}}` - (a generic converter carrying FHIR XML's `value` attribute across as a member) and a `status` - written as a number or a boolean hold no code this library may read. Measured, every one of them - returned `negations: []` under `safeToSummarize: true`, so a procedure recorded as not done was - indistinguishable from one that was carried out. **This is a shape, which is why the two channels - above it did not catch it**: both ask about a written _value_, and an object holds no value at all, - so both answered "no" about it truthfully and the element read exactly like one the sender left - out. So the position is named: the locations in `unreadableNegationCodes`, `safeToSummarize` is - `false`, and `assertSafeToSummarize` throws. **Nothing is read through the position**, deliberately: - `{"value":"…"}` is FHIR _XML_'s spelling of a primitive, so descending into it would resolve a - negation out of an encoding no version of FHIR defines for JSON. The element is `status`, at every - resource root, which is the negation read's own window; the complement is carried in the same table - the matches are made from and applied in the same loop, so it cannot cover an element the read does - not. **Two datatypes reach a root `status`, and the question asked is about the shape rather than - about which read succeeded, so that both are cleared**: R4 spells `status` a `code` on the - overwhelming majority of types and a `CodeableConcept` on `MedicinalProductAuthorization` and - `SubstanceSpecification`, R5 adds several more including a mandatory `DeviceAssociation.status`, - and DSTU2 spells every one a `code`. So a complex **all of whose members** FHIR spells here (`coding`, - `text`, `id`, `extension`) is left alone, whether or not a code came out of it, while **any** - member outside that set is reported: `{"status":{"id":"s1","value":"not-done"}}` is the same - converter output and is reported too. An object with **no** member at all is reported as well, - `ele-1` requiring an element present in a resource to carry a value, children, or an extension. Keyed instead on "no string was read", this would refuse the - published R4 `MedicinalProductAuthorization` example. The converse is a declared limit: - a code buried under `{"coding":{…}}` at a type whose `status` is a `code` stays silent. - **`verificationStatus` is deliberately outside it** for a related reason: its shape complement is a - _primitive_ at the element, and `Condition.verificationStatus` is a `code` in DSTU2, so the same - rule would report a conformant DSTU2 document. `AllergyIntolerance.code` is outside it for the - reason that keeps "no known allergy" root- and type-scoped. Like the two channels above it this one - raises no `ValidationIssue`, so it cannot move `valid` in either direction. **Empty on every - conformant document this library has been measured against, in either wire format**: the XML reader - models a `value` attribute beside `id` and `extension` children as a primitive, so a conformant - `` is read; and a primitive whose value is _absent_ - is untouched, that being the conformant `data-absent-reason` shape and content the read never - stepped over. -- **An element a sender explicitly does not know is now distinguishable from one it never sent.** - A source system with no data for an element whose minimum cardinality is greater than zero cannot - omit it, so it writes the element present, with no value, carrying the R4 DataAbsentReason - **extension** and a reason code. Measured, both shapes reached a caller as the same answer: the - element counts as present so no required-element finding fires, and every value read returns - `undefined`, exactly as it does for an element nobody wrote. `absenceMarkers` is the read that - recovers the difference, one `{ code, location }` per declared absence, on a complex element or on - a primitive's extension metadata, in either wire format, at every depth (`contained` and - `Bundle.entry` included). It **carries the reason the sender spelled**, so `unknown` is - distinguishable from `masked`, `not-applicable` and `not-performed` on otherwise identical - instances. `absenceMarkers(resource, path)` is the standalone collector. - **This is the one location-bearing channel that leaves `safeToSummarize` standing**, and the - exception is the point rather than a hole: a declaration the caller can now read is a disclosure, - and refusing over it would withdraw an affirmation from a conformant document. **A report carries - the reason and the location and nothing else**, the reason being one of fifteen literal strings - this package spells (`ABSENCE_CODES`, the extension's own required-strength value set) rather than - anything taken off the document. - Its two neighbours behave like every channel above. **A reason outside that value set is refused, - never folded into `unknown`**: FHIR `code` is case-sensitive and excludes surrounding whitespace, - so `UNKNOWN` and `" unknown"` are not the code and coercing them would author a reason the sender - did not spell. The element is not read as populated either; the location goes on - `unreadableAbsenceMarkers`, `safeToSummarize` is `false`, and the validator raises - `ABSENCE_MARKER_UNREADABLE` (error). **A marker beside a value on one element is a contradiction - this library does not resolve**: both survive on the model and on the readout, the location goes on - `conflictingAbsenceMarkers`, and `ABSENCE_MARKER_CONFLICT` (error) says they disagree rather than - letting a consumer prefer whichever its own read reached first. A complex element "carries a value" - when it holds any member beyond `id`, `url`, `extension`, `modifierExtension` and the JSON - encoding's `resourceType`, none of which is a value a marker denies. - **Two neighbouring shapes are deliberately NOT this.** The same concepts used as a `Coding` inside - a coded element (`system` = the DataAbsentReason code system, `code` = `unknown`) are a present, - conformant coded VALUE, not an absent element, and draw nothing here. Nor is the - `Observation.dataAbsentReason` ELEMENT, an ordinary `CodeableConcept` whose `obs-6` invariant is - unchanged: the element is not the extension, so an Observation carrying one draws exactly what it - drew before and never both findings. Recognition is by the extension's **canonical URL** and - nothing that resembles it, so an extension whose `url` is the code system URI instead is not a - marker. -- **Neither writer will re-emit a document the reader MARKED** (`FhirSerializeError`, code - `DROPPED_ELEMENT_TEXT`). Say "marked", not "whose text was dropped": character data that is - `String.trim()`-empty is dropped with no flag, no marker and no finding, so a `` holding - only whitespace still emits `` and still re-reads clean. That gap is real, unchanged here, - and noted below. This is the other half of the refusal, and it exists because the finding - used to disappear across a round trip. `serializeResourceXml` emitted `` and a re-read of - that output came back clean; `serializeResource` was worse, dropping the member outright, so a - retracted `Observation` re-read as one that had never named a status. The error is value-free and - carries the bounded FHIRPath `locations` it refused over, never the text it could not encode. - Be precise about why `` is not a neutral fallback: xml.html §2.6.1 says _"FHIR elements are - never empty. If an element is present in the resource, it SHALL have either a value attribute, - child elements as defined for its type, or 1 or more extensions"_, so emitting it violates that - SHALL. - **The refusal is scoped to a model the reader MARKED, and to nothing else.** A document read from - JSON has no character-data channel and is never affected; a conformant XML document round-trips - byte-for-byte exactly as before. In particular, writing a value-absent primitive that carries **no - extension** is still permitted and still emits ``: §2.6.1's third arm ("or 1 or more - extensions") is satisfied by ``, which is what a - `data-absent-reason` emits, but an `id`-only primitive (``) has none of the three - permitted contents and remains a **pre-existing** violation this change does not address. Keep the - original document if you need the text itself; the library will not invent it for you. -- **Fail-closed on an unknown `modifierExtension`** (`UNHANDLED_MODIFIER_EXTENSION`, error): FHIR's - `?!` rule; and **`entered-in-error` surfaced** as `RETRACTED_RESOURCE` (retracted, not data). -- **Invariants** `ait-1`/`ait-2`, `con-3`/`con-4`/`con-5`, `obs-6`/`obs-7`, hand-evaluated from their - exact R4 FHIRPath by the always-on safety layer. This layer surfaces and enforces. It - never reconciles contradictions or infers clinical meaning. Every **other** profile `constraint[]` - invariant is evaluated by the FHIRPath engine (below). -- **Base constraints, with no profile**: every other error-severity constraint R4 4.0.1 declares on - the eight modeled types (`AllergyIntolerance`, `Condition`, `DiagnosticReport`, `Immunization`, - `MedicationRequest`, `MedicationStatement`, `Observation`, `Patient`) and on their - `DomainResource`, `Element` and `Extension` base: `pat-1`, `obs-3`, `con-1`, `con-2`, `imm-1`, - `dom-2` to `dom-5`, `ele-1` and `ext-1`, each transcribed verbatim and evaluated by the same - FHIRPath engine. A violation is `INVARIANT_VIOLATED` at `error`, located at the violating - occurrence. `dom-3` is decided by its own terms when nothing is contained, a supplied profile - carrying R4's `dom-3` as written included, and reported `INVARIANT_UNCHECKED` when something is, - because checking that every contained resource is referenced needs reference resolution the - subset does not have. `dom-6` (a narrative SHOULD be - present, a `warning`) is not evaluated without a profile. A contained resource is checked only - through `dom-2` to `dom-5`; its own type's constraints and its elements' `ele-1` / `ext-1` are not. - Any other resource type draws none of these. - -And Quantity / UCUM fidelity: read a measured value by the type it actually is, and its unit by the -UCUM **`code`** a machine may act on (never the display string, and **never converted**): - -```ts -import { parseResource, readObservationValue, validateResource } from "@cosyte/fhir"; - -// value[x] is an 11-way choice: a non-numeric result is never read as a number. -const { resource: titer } = parseResource( - '{"resourceType":"Observation","status":"final","valueString":"POSITIVE"}', -); -const v = readObservationValue(titer); -v?.type; // → "String" (NOT "Quantity") -v?.quantity; // → undefined (no number is fabricated) - -// A vital sign's unit is checked on the UCUM code, case- and bracket-exact: "mmHg" is not "mm[Hg]". -const { resource: bp } = parseResource( - '{"resourceType":"Observation","status":"final",' + - '"category":[{"coding":[{"system":"http://terminology.hl7.org/CodeSystem/observation-category","code":"vital-signs"}]}],' + - '"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]},' + - '"valueQuantity":{"value":120,"unit":"mmHg","system":"http://unitsofmeasure.org","code":"mmHg"}}', -); -validateResource(bp).issues.map((i) => i.code); // → ["VITAL_SIGN_UNIT_NONCONFORMANT"] (should be "mm[Hg]") -``` - -- **`readObservationValue`** discriminates the 11 `value[x]` variants (`Quantity`, `CodeableConcept`, - `String`, `Boolean`, `Integer`, `Range`, `Ratio`, `SampledData`, `Time`, `DateTime`, `Period`) by - the one present. `quantity` is populated **only** for a `Quantity`. `readQuantity` keeps the coded - unit (`code`/`system`) distinct from the human `unit`; `validateUcumShape` checks a code's shape. -- **Vital-signs required-unit** conformance (`VITAL_SIGN_UNIT_NONCONFORMANT`, error) against the FHIR - profile's closed table, compared on the UCUM `code`; a UCUM-declared unit that is absent or malformed - is `UCUM_UNIT_UNRECOGNIZED` (warning, preserved verbatim); a vital sign whose value is not a Quantity - is `VALUE_TYPE_UNEXPECTED` (warning). -- **Dose `Quantity`** (`readMedicationDoses`) for MedicationRequest/Statement, and - `interpretation` / `referenceRange` surfaced (`readInterpretations` / `readReferenceRanges`): - **never** used to auto-convert a unit or compute an abnormal flag. - -And terminology binding validation, strength-aware and **content-free**: validate a coding's code -`system` and its binding **strength** without bundling any SNOMED / CPT / LOINC concept tables, and -never raise a false error when no terminology service is configured: - -```ts -import { parseResource, validateResource, type TerminologyService } from "@cosyte/fhir"; - -// AllergyIntolerance.code binds extensibly to a multi-system value set (RxNorm + SNOMED). An -// ICD-10-CM code is a KNOWN but unexpected system for this binding → a warning, never an error. -const { resource: allergy } = parseResource( - '{"resourceType":"AllergyIntolerance",' + - '"clinicalStatus":{"coding":[{"system":"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical","code":"active"}]},' + - '"code":{"coding":[{"system":"http://hl7.org/fhir/sid/icd-10-cm","code":"T78.40XA"}]}}', -); -validateResource(allergy).issues.map((i) => `${i.code}/${i.severity}`); -// → ["RESOURCE_NOT_MODELED/information", "CODE_SYSTEM_UNEXPECTED/warning"] (valid stays true) - -// Value-set membership needs content the library does not bundle. Supply a terminology service. -const svc: TerminologyService = { - validateCode: ({ code }) => ({ membership: code === "7980" ? "in" : "not-in" }), -}; -validateResource(allergy, { terminology: svc }); // now membership is checked against your service -``` - -- **Frozen known-systems registry** (`KNOWN_SYSTEMS`, `isKnownSystem`): the verified `system` - URIs (LOINC, SNOMED, RxNorm, ICD-10-CM/9-CM, CPT, UCUM, NDC, CVX) as **identities, not content**. - An unrecognized system is `CODE_SYSTEM_UNKNOWN` (`information`): not a defect, just unvalidatable. -- **Binding-strength severity:** `required` → error, `extensible` → error-unless, `preferred` → - warning, `example` → information (an example binding **never** errors). A known system outside a - binding's value set is `CODE_SYSTEM_UNEXPECTED` (strength-scaled); a service's definitive `not-in` - is `CODE_NOT_IN_VALUESET`. Built-in **multi-system** bindings: allergy substance (RxNorm + SNOMED), - medication (RxNorm). -- **Pluggable terminology service** (`TerminologyService`): the one seam for value-set content, and - **none is bundled** (licensing). With none supplied, checks degrade to the content-free system - level and **never false-error**; the service receives only identities, never a resource value. - -And StructureDefinition-driven **profile validation** (US Core the target): snapshot generation, -slicing, `fixed[x]` / `pattern[x]`, and **must-support as a system obligation**. Like the terminology -layer it ships the _engine_, not the _content_: you supply the profiles (the published US Core / -vendor `StructureDefinition`s), and **nothing is bundled**. - -```ts -import { loadStructureDefinition, parseResource, validateResource } from "@cosyte/fhir"; - -// Load a US Core profile (its published JSON) into a StructureDefinition. -const profile = loadStructureDefinition(parseResource(usCoreAllergyProfileJson).resource); - -const { resource: allergy } = parseResource( - '{"resourceType":"AllergyIntolerance",' + - '"clinicalStatus":{"coding":[{"code":"active"}]},' + - '"code":{"coding":[{"code":"227493005"}]},"patient":{"reference":"Patient/1"}}', -); - -// verificationStatus is must-support and absent → information, NEVER an error (the resource stays valid). -const { issues, valid } = validateResource(allergy, { profiles: profile ? [profile] : [] }); -valid; // → true -issues.map((i) => `${i.code}/${i.severity}`); // → ["MUST_SUPPORT_ABSENT/information", …] -``` - -- **Snapshot generation** (`generateSnapshot` / `snapshotElements`) walks `baseDefinition` and merges - the differential onto the base snapshot: tightening matched elements by id, inserting slices, and - failing closed (`FhirProfileError`) on an unresolvable base or a `baseDefinition` cycle. A caller - supplies the base via a resolver; a profile that already ships a snapshot is used as-is. -- **Slicing** matches each occurrence of a sliced element to a slice by its discriminators. The R4 - set is `value | exists | pattern | type | profile` (**`position` is R5-only** and excluded). What - needs a FHIRPath engine (`type` / `profile` discriminators, reslicing) is reported - `PROFILE_SLICE_UNCHECKED` (`information`): **never silently passed**. An unmatched occurrence under - `closed` slicing is `PROFILE_SLICE_UNMATCHED` (error); a missing required slice is `CARDINALITY_MIN`. -- **`fixed[x]` vs `pattern[x]`** (`matchesFixed` / `matchesPattern`): `fixed` is exact equality - (nothing extra), `pattern` is a subset (extras allowed); decimals compared precision-exactly, never - via a float. A mismatch is `PROFILE_FIXED_MISMATCH` / `PROFILE_PATTERN_MISMATCH` (error). -- **Must-support is a system obligation, not instance-presence**: an absent must-support element is - `MUST_SUPPORT_ABSENT` at **`information`, never an error**. A strict client that rejects an absent - must-support element is the classic interop bug this rule exists to prevent. -- **Multi-version**: a `meta.profile` `canonical|version` pin the supplied set carries at a different - version is `PROFILE_VERSION_MISMATCH` (warning) rather than a silent best-effort validation. -- **Invariants**: the profile's `constraint[]` (FHIRPath) are evaluated by a **bounded, vendored - FHIRPath engine** (`tokenize` / `parseFhirPath` / `evaluateInvariant`; no runtime - dependency). A violated constraint is `INVARIANT_VIOLATED` (severity mirroring its `error` | - `warning`); an expression outside the subset raises `UnsupportedFhirPathError` and is reported - `INVARIANT_UNCHECKED` (`information`): **surfaced, never assumed to pass**. The seven named safety - invariants stay owned by the always-on safety layer; the engine covers every other constraint. - The R4 base constraints are evaluated whether or not a profile is supplied (above), and a profile - whose snapshot repeats one is reported once per occurrence, not twice. `collectInvariantIssues`, - called directly, still reports every constraint the profile carries, base ones included. -- **Deferred:** the bundled multi-version US Core IG corpus and the `validator_cli.jar` differential - (a JVM dev/CI job); the `type` / `profile` slicing discriminators and reslicing (still - `PROFILE_SLICE_UNCHECKED`: a genuine fail-safe deferral, they need per-occurrence type carriage / - recursive profile resolution). Every finding is **value-free** (a code + a FHIRPath location, - never the value). - -```ts -import { evaluateInvariant, parseResource } from "@cosyte/fhir"; - -// The bounded FHIRPath engine, judged by the reference validator's boolean coercion. -const { resource } = parseResource( - '{"resourceType":"Observation","valueString":"x","dataAbsentReason":{"text":"n"}}', -); -evaluateInvariant("dataAbsentReason.empty() or value.empty()", resource, resource); -// → { unchecked: false, satisfied: false } (obs-6 violated: value AND dataAbsentReason both present) - -evaluateInvariant("descendants().count() > 0", resource, resource); -// → { unchecked: true, satisfied: false } (descendants() is outside the subset, never a false pass) -``` - -**Authoring a profile in code: `defineProfile()`.** You don't have to hand-write -`StructureDefinition` JSON. `defineProfile(spec)` builds one from an ergonomic spec and returns the -**same model** `loadStructureDefinition` produces, so it flows straight into -`validateResource({ profiles })`. There is **one authoring path, no privileged internal shape**: the -built-in starter profiles are `defineProfile()` calls, exactly what you write. As a conservative -writer it throws a value-free `InvalidProfileError` on an author mistake (a missing `url` / `type` / -element `path`, a bad cardinality, a `max` below `min`). - -```ts -import { defineProfile, parseResource, primitive, validateResource } from "@cosyte/fhir"; - -const finalOnly = defineProfile({ - url: "https://example.org/StructureDefinition/final-observation", - type: "Observation", - differential: [ - { path: "Observation.status", fixed: { type: "Code", value: primitive("final") } }, - ], -}); - -const { resource } = parseResource('{"resourceType":"Observation","status":"preliminary"}'); -validateResource(resource, { profiles: [finalOnly] }).issues.map((i) => i.code); -// → ["PROFILE_FIXED_MISMATCH", …] -``` - -A publishable **profile starter kit** ships as worked examples / templates you extend: -`VITAL_SIGN_OBSERVATION_STARTER` (required `status`, must-support `code`, and a **sliced** `category`: -a required `VSCat` slice pins the `vital-signs` coding while the open slicing still allows other -categories, the way the real profile does) and `PATIENT_IDENTIFIER_STARTER` (`identifier` / `.system` -/ `.value` required + must-support, deliberately **no** MRN slice), plus `STARTER_PROFILES`, -`starterProfile(url)`, and `STARTER_PROFILE_BASE_URL`. Each is grounded in a public FHIR / US Core -spec page, self-contained (differential-only, no bundled base), and clearly a template, **not** an -authoritative vendor conformance statement. - -```ts -import { STARTER_PROFILES, parseResource, validateResource } from "@cosyte/fhir"; - -const { resource } = parseResource(vitalSignObservationJson); -validateResource(resource, { profiles: [...STARTER_PROFILES] }); -``` - -- **Real-world quirk corpus + differential.** Five quirk fixtures - (`test/__fixtures__/quirk-*.json`), each **grounded in a public artifact** and cited in - `test/quirk-corpus.test.ts`: a non-first `resourceType` (json.html), a scientific-notation decimal - preserved byte-exact (Synthea #675), a primitive-extension `_`-sibling misalignment that **fails - closed** (HAPI #5738), a searchset Bundle `link[next]` that survives the round-trip - (bundle-example.json), and US Core race + birthsex extensions preserved on a base Patient. The - `validator_cli.jar` differential (CI-only) runs over this corpus too. **Values are synthetic;** a - genuinely vendor-**proprietary** deviation absent from every public sample stays grounded-only. It is - never invented. Missing-must-support and version-drift quirks are covered by the profile suite. - -### XML codec and cross-format equivalence - -A **zero-dependency** FHIR XML codec that reads and writes the **same schema-free model** as the JSON -codec, so a resource is equivalent whichever wire format it arrived in. The hand-written reader is -**XXE- and billion-laughs-proof by refusal**: it refuses any `…`, never dropped). Lenient: an - unexpected namespace or stray text draws `UNEXPECTED_XML_CONTENT` and the document is never rejected. - **Lenient is not lossless there:** an element in another namespace is modeled and flagged, but - character data written directly on a FHIR element is dropped and flagged, because a FHIR element - carries its value in the `value` attribute and the model has no slot for text. - **Names are namespace-resolved, so a prefix is a spelling and not part of the name.** FHIR XML is - defined in the `http://hl7.org/fhir` namespace, and a document may bind that namespace to a prefix - instead of making it the default, so `` and - `` are the same resource and read to the same model. The - in-scope declarations are tracked as the reader descends, including a prefix rebound partway down. - A prefix nothing in scope binds is not resolvable, so the tag is kept exactly as written and - flagged rather than guessed at. **The narrative `
` is the one element FHIR requires in a - namespace other than its parent's, so it is recognised by its expanded name - (`{http://www.w3.org/1999/xhtml}div`) under every spelling**, and is not flagged for being there. - It is carried as an opaque string together with the namespace declarations it inherited from its - ancestors **and uses**, so the fragment stands on its own; the document's own spelling is preserved - rather than rewritten, which makes a prefixed narrative namespace-equivalent to the default - spelling and not byte-identical to it. A `div` in another namespace is kept out of `Narrative.div` - only where its tag carries a **prefix**: an unprefixed `
` is spelled exactly - like the FHIR one, so it reaches that slot and is reported, not separated. **The narrative is - recognised before a resource-valued element is unwrapped**, because the content of `Narrative.div` - is XHTML and the unwrap's UpperCamelCase test is a way of spelling a FHIR resource type: applied - inside a narrative it read `
Take 5 mg
` as a contained `BR` resource - and destroyed the prose, and HTML-4-era generators do emit `
`, ``, `

`. Nothing is - shadowed by that order: `div` names exactly one element in R4. Reading the narrative - means its contents are no longer modeled as FHIR, so a narrative spelled with a prefix, or holding - a capitalized child, reads as the same document written the other way reads, including where that - is quieter. The one way it reads - differently is **louder**: a document holding the narrative under both spellings at once is one - element written twice, so it draws `MIXED_XML_SPELLING`, which the all-default twin does not. - Every element the reader **models** is tested once for being in a namespace other than its parent's, - and reported when it is. A **prefixed** one additionally keeps its tag, and since no FHIR element - is spelled `v:code`, that is what keeps it out of the FHIR element beside it. Content reached by a - **default** declaration (``) is spelled exactly like the FHIR element, - so it is modeled as one and reported rather than separated. A child element written beside a - `value` attribute is not modeled at all: it is discarded and reported `UNKNOWN_PROPERTY`, so a - foreign one there draws no namespace report. - Two prefixes bound to the same namespace are two spellings of one name, so an element written twice - that way reads as the repeat it is. The model matches the same document spelled one way; only the - number of occurrences differs, so that element carries `MIXED_XML_SPELLING`. Nothing is lost, but a - check that reads a `0..1` element as a single value gets nothing from a repeat, and that should - never be silent. **That report compares the expanded name, not the tag alone**, so it also covers - the merges where the tag is the same and the namespace is not. Two of those are worth naming - because a document can reach them while otherwise reading as conformant: a prefix rebound between - siblings (`` beside ``), and a `

` in the FHIR - namespace landing in `Narrative.div` beside the real XHTML narrative, which is the one that costs - the most because `Narrative.div` is `0..1`. A **foreign** element reached by a **default** `xmlns` - re-declaration groups with its FHIR namesake the same way, and there the group already carried - `UNEXPECTED_XML_CONTENT`. A FHIR-namespace one carries no such flag, which is exactly why this - report is the one that covers the narrative case. -- **`serializeResourceXml`** emits compact FHIR XML that round-trips a spec-clean document - **byte-for-byte** (decimals byte-exact, never through a `number`). **Its output is not - _unconditionally_ spec-clean**: a tag name that is not an XML 1.0 `Name` and a character outside - XML 1.0 `Char` are refused rather than written (below), but `` and a name inside a `div` - string that is not a `Name` are still written, - and the byte-for-byte claim is scoped to a spec-clean input (a `
x
` carrying no XHTML namespace - comes back as `
x
`, the FHIR namespace rather than the XHTML - one the conformant repair would use). It throws `FhirSerializeError` - rather than emit a model the reader marked as having lost character data, so that finding cannot - vanish across a round trip; `serializeResource` refuses the same models for the same reason. Text - the reader drops **without** marking (whitespace only) is not covered, because there is no marker. -- **A `div` property is written back as raw markup, and that markup is checked at the branch that - writes it** (`UNSERIALIZABLE_DIV_MARKUP`). `Narrative.div` is carried as an opaque XHTML string and - emitted verbatim, so whatever the string spells becomes markup in the output. It is written only - when it parses as exactly one element whose local name is `div`; anything else is refused, because - a string that closes its own element and opens siblings puts elements into the document that the - sender never wrote. The shape that decided it: a `div` on an `AllergyIntolerance` spelled - `
ok
…716186003…` used to emit - spec-clean FHIR XML that re-read with `noKnownAllergy: true` and a `no-known-allergy` negation over - a record that had asserted nothing, with no diagnostic at either end. **Well-formedness alone is - not the line**: `` is one well-formed element, and writing it for a property - named `div` authors a status. `serializeResource` carries the string as a string and is the route - that stays open. **A string that passes is asked one more question, on a code of its own** - (`UNSERIALIZABLE_DIV_PREFIX`): whether every namespace prefix its markup names, on an element or on - an attribute, is bound by a declaration inside the string. `x` used to be written - with nothing binding `v`, which a conformant parser rejects, and this library re-read it as a - property named `v:div` rather than as the narrative. It is refused now, and so is an unbound prefix - on an inner element or an attribute, which withdraws an XML write from models that read - `valid: true`, the cost the colon refusal below already pays. The `xml` prefix is bound by - definition, and a prefix a conformant document bound on an ancestor of the `div` is not refused, - because the reader writes that declaration into the string it hands back. Passing both checks is - still not a claim that the round trip is lossless from there. -- **A shape only FHIR JSON can spell is refused rather than emitted as an empty element** - (`UNSERIALIZABLE_JSON_ONLY_SHAPE`). The JSON reader marks four positions FHIR JSON gives no meaning - to and keeps what the sender wrote there, so `serializeResource` hands it back and re-reading the - output reproduces the finding: an array inside an array, a scalar or `null` where FHIR JSON has an - object, that same shape in a primitive's `_`-sibling, and a `null` in a primitive's value channel - that padded nothing. **XML has none of those channels**: no array of arrays, no `_`-sibling (a - primitive's metadata is co-located as an `id` attribute and child `` elements), and no - `null` at all. So this writer used to emit the node the reader was left holding, and that output - re-reads with an empty issue list, and three of the four also `valid: true`. `{"value":null,"unit":"mg"}` came back through XML as a `Quantity` carrying a unit - and **no magnitude**, with an empty issue list; `{"name":[[{"family":"Roe"}]]}` came back with the - name gone and `safeToSummarize` flipped from `false` to `true`. Refusing, because there is nothing - to hand back into and inventing an XML spelling would author markup nobody wrote. **Only a model - read from JSON reaches it**: XML cannot write any of those shapes, so a document read from XML - carries no marker and nothing that round-trips today stops. `serializeResource` writes all four - back at every position that writer walks, and the re-read reproduces the finding. **It does not - walk a member a repeated property name shadowed, and this refusal does reach one**, so that is the - refusal's own limit rather than a route the shape always survives; `DUPLICATE_PROPERTY` and the - safety refusal are what carry such a document, which is refused outright now rather than - narrowed. **Value-exact, not byte-exact**, which is the same - limit the preserved text carries everywhere else in this README: - `{"performer":[{"reference":"Practitioner/1"},"Practitioner\/2"]}` comes back with the second - member spelled `"Practitioner/2"`, the same string in different bytes. Only the value-channel - `null` family is byte-identical, because a `null` has no escaping to lose. That the route stays - open is a statement about these shapes, not about the whole model. **Not** closed by it, and pinned rather than implied: - a JSON decimal comes back from XML as a string because XML carries no JSON type. -- **An array wrapper XML cannot spell back is refused rather than flattened away** - (`UNSERIALIZABLE_ARRAY_WRAPPER`). FHIR JSON writes a single-valued element as a name/value pair and - reserves the array for a repeating one (json.html §2.6.2.2), so `{"status":["entered-in-error"]}` is - a shape the spec does not define: it is reported as an error-severity `ARRAY_WRAPPED_SCALAR` and - `safeToSummarize` is `false` over it, because a single-value read finds no string in it at all. FHIR - XML spells a repeat by **repeating the element** and carries no other mark for one, so a wrapper of - fewer than two items used to emit at most one element and re-read as an ordinary single-valued - element: that document came back with `arrayWrappedScalars: []`, `safeToSummarize: true`, - `valid: true` and an empty issue list, and a wrapped `resourceType` came back as ``, the - type gate every type-scoped read stands behind gone with it. **A writer cannot decide cardinality - in general and this one does not - try**: there is no per-resource model here, and a name-only rule would emit a false error on a - conformant document (`Questionnaire.code` and `ElementDefinition.code` are `0..*` in R4). So this - takes its cardinality from the one window that already has one, the locations - `arrayWrappedScalars` reports, and inside it refuses the wrappers XML cannot write back as a - wrapper: **fewer than two items**, plus **any** wrapper on `resourceType`, where the type is the tag - and a tag cannot be repeated. **A wrapper of two or more items elsewhere is deliberately left - alone**: it is written as repeated elements, the re-read groups them into a list and the location is - reported again, so refusing it would withdraw a round trip that works today _and_ keeps the finding. - That is a statement about which wrappers this refuses, not a claim that every wrapper it lets - through survives. `serializeResource` writes the wrapper back and is the route that stays open. - **Not** closed by it: a wrapper that only a **shadowed** member carried is the repeated-property-name - case rather than this one, and both writers now refuse it (on whichever of the two codes is raised - first, which is this one where the wrapper is itself unspellable); and the window does not reach - `Observation.value[x]`, a `0..1` choice, which has a refusal of its own on a cardinality of its own - (below) rather than a wider window on this one. -- **An array wrapper around an `Observation.value[x]` choice is reported and refused** - (`ARRAY_WRAPPED_CHOICE` on the read, `UNSERIALIZABLE_CHOICE_WRAPPER` on the XML write). The same - generic-converter shape as the bullet above, at the position where the number is a **dose**. - `{"resourceType":"Observation","status":"final","valueQuantity":[{"value":5,"system":"http://unitsofmeasure.org","code":"mg"}]}` - used to read with the variant reported and no magnitude handed out, and nothing said the encoding - had been ambiguous, so one write and one re-read produced an unambiguous 5 mg under `valid: true`. - Now `readObservationValue` carries the code on `ObservationValue.encodingIssue`, `validateResource` - raises the same code at the same location at error severity, and the XML writer refuses. **The - cardinality is the value readout's own**, not a wider table: it is scoped to `Observation.value[x]` - and `Observation.component.value[x]`, both `0..1` in R4 (observation.html), at every Observation - resource root including a `contained` or `Bundle.entry` one, so a conformant document cannot draw - it and the safety layer's own wrapper window is untouched. **A wrapper of two or more items is - deliberately left alone** for the same reason as above: it writes as repeated elements, the re-read - groups them into a list and the report is raised again, byte-exact. **Nothing is read out of the - wrapper**: picking a member would author a magnitude the sender spelled ambiguously. - `serializeResource` writes the wrapper back and is the route that stays open. -- **A member a repeated property name shadowed is refused, by BOTH writers** - (`UNSERIALIZABLE_SHADOWED_PROPERTY`). The reader keeps it, validation raises an error over it and - `safeToSummarize` is `false`: **all three about the input**. Each writer walks the surviving - members only, so `{"resourceType":"Observation","status":"final","status":"entered-in-error"}` - used to come back as `{"resourceType":"Observation","status":"final"}` and - ``: both re-read with - an empty issue list, `valid` and `safeToSummarize` both `false → true`, and **the retraction in - neither output**. Which member is lost depends only on the order the sender wrote them in. - **Handing both back is not the alternative it looks like**: `JSON.parse` resolves a repeated name - last-wins where this library reads first-wins, so emitting both members hands every other consumer - the member this one calls shadowed. XML can repeat an element, but two repeated elements re-read as - a **list**, a repeating element nobody wrote. The window is `shadowedProperties`, the same call - validation raises its error from, so a model refused here already reads `valid: false`. **Not** - closed by it, and measured rather than implied: a repeated name inside a primitive's `_element` - metadata is not modeled at all, and one inside a complex in a primitive's `extension` is still - dropped by both writers, and that document reads `valid: true`, so refusing it would withdraw a round - trip from a model this library reports as clean. -- **A `resourceType` with no string in it is refused rather than deleted and the tag substituted** - (`UNSERIALIZABLE_RESOURCE_TYPE`). FHIR XML has no `resourceType` element: the type IS the tag - (xml.html). So this writer skips that property at every element it walks and takes the tag from the - property's string value, and where there is no string to take the root used to fall back to - `Resource`. `{"resourceType":{"modifierExtension":[{"url":"http://example.org/x"}]},"status":"final"}` - reads `RESOURCE_TYPE_UNKNOWN` at error severity with `valid: false`, and `safeToSummarize: false` - for the unhandled modifier extension the type gate carries; it came back as - ``, which re-reads with an - empty issue list, `valid` and `safeToSummarize` both `true`, and no modifier extension anywhere. The - property is gone from the output and the element claims a type nobody wrote. **Neither repair is - available**: writing `` is what launders, and it authors the type gate every type-scoped - safety read runs behind; coercing the value to a string authors a different type out of content the - sender wrote at another shape. **The predicate reads the FIRST `resourceType` an element wrote**, - because that is the one the writer names the tag from. **Two shapes are deliberately left**: an - element that wrote **no** `resourceType` is untouched, because a typeless complex is named - `Resource` by documented fallback and nothing is deleted there; and an element whose **first** one - IS a string keeps its tag, so the substitution never happens and what drops there is the - repeated-property-name case. The - bound is structural rather than a verdict: at the root this costs a round trip only for a model - already `valid: false`, but deeper no layer checks a nested element's type and a document read from - XML reaches it, so what is withdrawn at every refused location is a **deletion** rather than a round - trip. `serializeResource` emits a non-string `resourceType` through its ordinary path and is the - route that stays open. **Not** closed by it: a JSON decimal still comes back from XML as a string, - and `Observation.value[x]` is still outside the array-wrapper window. -- **A name carrying a colon is refused rather than written with its prefix unbound** - (`UNSERIALIZABLE_PREFIXED_NAME`). XML reads a colon in an element name as a namespace prefix, and - Namespaces in XML 1.0 requires every prefix other than `xml` and `xmlns` to be declared. The model - carries no namespace bindings, so this writer has nothing to declare one with, and it used to write - the name verbatim: ``, `<:x/>`, ``, ``, and a root read as - `` written back as ``. A conformant parser - rejects every one of those documents. This library's own re-read accepted them, and lost something - on the way: a prefix rebound between siblings (`` beside - ``) reads with `MIXED_XML_SPELLING`, and after one write and one re-read that - report was gone. The check runs at every tag position at every depth, including a resource - composed into `contained` or `Bundle.entry.resource` after it was read. **One exemption**: `xml:` - followed by a local part with no colon in it is written, because the `xml` prefix is bound by - definition; `xmlns:x` is refused. **It withdraws an XML write from documents that read - `valid: true`**, which the foreign-root refusal already does: `{"resourceType":"Patient","p:x":"v"}` - reads with an empty issue list, validates `valid: true`, and is refused. Declaring a binding instead would author a namespace the sender - never sent, and for a root whose prefix nothing bound there is no namespace to declare at all. - Every earlier refusal is raised first, so a name that both carries a colon and breaks the tag stays - `UNSERIALIZABLE_ELEMENT_NAME`. The read is unchanged, and `serializeResource` writes these names - as JSON strings and is the route that stays open. **Not** closed by it: a name with no colon that is - not an XML 1.0 `Name` (`a&b`, `1abc`) is refused on a code of its own, below, not this one, and the - local part after `xml:` is not checked, so `` is still written. A `div` string whose own - markup carries an unbound prefix never reaches this check, and is refused by the `div` branch on - `UNSERIALIZABLE_DIV_PREFIX` instead, as above. -- **A tag name that is not an XML 1.0 `Name`, and a character outside XML 1.0 `Char`, are refused - rather than written** (`UNSERIALIZABLE_XML_NAME`, `UNSERIALIZABLE_XML_CHARACTER`). The line is the - XML 1.0 grammar a conforming processor applies, not this library's own round trip: ``, - `<1abc/>`, `<-x/>` and a tag carrying U+0000 all re-read here unchanged and are all rejected by a - third-party parser, and a U+0000 in a string value used to be written raw into its `value` - attribute. The name check runs at every tag position at every depth (a property name, a name inside - `contained`, `Bundle.entry.resource` or an extension, and a `resourceType` naming a root or a - nested resource), after the tag-breaking and colon questions, so a name failing one of those keeps - its code. The character check runs on every value written as an attribute (a primitive's `value`, an - `id` written as an attribute, an `Extension.url`) and on every `div` string that passes both `div` - checks, where a numeric character reference to a non-`Char` (`�`) counts as the raw character - does and a reference inside a comment, which is never decoded, does not. **Refused, never - repaired**: no name is mangled, and no character is escaped into a reference (a reference to a - non-`Char` is itself a fatal error), replaced or dropped. The name code is raised after every - earlier refusal and the character code last of all, so no model that drew a code before moves off - it, and a model carrying both draws the name code. The message and the locations carry no name, no - value and no character; a refused name's own segment reads ``. **It withdraws an XML - write from documents that read `valid: true`**: `{"resourceType":"Patient","1abc":"v"}` reads with - an empty issue list and is refused. The discouraged code points `Char` admits (U+007F to U+009F, - U+FDD0 to U+FDEF) are written. `serializeResource` is unchanged and is the route that stays open. - **Not** closed by it: `` is a `Name` that is not namespace-well-formed and is still - written; an element or attribute name inside a `div` string is checked for `Char` and never for - `Name`, so `
<1p>x
` is still written; the reader still reads `<1abc>` - and `` back; and `validateResource` still returns `valid: true` for a `string` carrying - U+0000. -- **`nodesEquivalent`** is the JSON↔XML equivalence oracle, equal _modulo_ the two irreducible - schema-free ambiguities and only those: primitive lexical form (JSON `true`/number tokens ≡ XML - `value`-attribute strings) and singleton lists (an array-of-one ≡ a single repeated element). - -```ts -import { - parseResource, - parseResourceXml, - serializeResource, - serializeResourceXml, - nodesEquivalent, -} from "@cosyte/fhir"; - -const xml = - '' + - ''; - -const fromXml = parseResourceXml(xml).resource; -const fromJson = parseResource( - '{"resourceType":"Patient","active":true,"name":[{"given":["Jane"]}]}', -).resource; -nodesEquivalent(fromXml, fromJson); // true: equivalent, not identical (see the two moduli above) -serializeResourceXml(fromXml) === xml; // true: spec-clean round-trip - -// Equivalent is not identical, and re-serializing to JSON shows both moduli at once: `active` is -// the string "true" (XML carries every primitive as attribute text and the reader is schema-free, -// so a decimal is likewise its exact text where JSON gives a FhirDecimal), and `name` / `given` are -// single nodes rather than arrays. Precision survives either way, and `readQuantity` reads a -// magnitude in either form, but the datatype validator reads a lexical boolean as a type mismatch. -serializeResource(fromXml); // → '{"resourceType":"Patient","active":"true","name":{"given":"Jane"}}' - -// The reader refuses an XXE / entity-expansion attack loudly, never resolving or expanding it: -parseResourceXml(' ]>'); -// throws FhirXmlError { code: "DTD_FORBIDDEN" } -``` - -### Bundles, references, and Bulk NDJSON streaming - -Read a `Bundle` into an explicit readout with the one semantic distinction a consumer must never blur -(**`transaction` is all-or-nothing, `batch` is independent**), resolve the references inside it with a -**DoS-safe cycle guard**, and stream a Bulk Data `$export` line by line with **per-line error isolation -and no whole-file load**. The Bundle _artifact_ and its semantics are modeled; a transaction is **never -executed** (there is no server here). - -- **`readBundle` / `entryProcessing` / `isAtomicBundle`**: the `Bundle.type` (`BUNDLE_TYPES`) and its - entry-processing contract: `transaction` → `"atomic"` (all-or-nothing), `batch` → `"independent"`, - everything else → `"none"`. `Bundle.total` is a lexical string, never a JS `number`. -- **`resolveReference` / `buildBundleIndex` / `containedIndex`**: resolve relative / absolute / - logical / `#fragment` references against a Bundle + `contained` closure. A local miss is - `"unresolved"` (flagged, preserved); an external target is `"external"` (never false-flagged). -- **`hasContainedCycle` / `MAX_REFERENCE_DEPTH`**, a bounded, iterative (heap-based) cycle guard: a - `contained` reference cycle is **detected and reported, never followed**: no infinite loop, no stack - blow-up, no false positive on a legitimate DAG. -- **`streamNdjson` / `parseNdjsonLine`**: a streaming `application/fhir+ndjson` reader over any chunk - iterable, one resource per line, each read through the precision-preserving codec (a decimal never - through a `number`). A malformed line is isolated (reported by **line number, never content**), the - stream continues, and memory stays bounded (`LINE_TOO_LONG`). -- **New findings** (in `validateResource` for a `Bundle`): `REFERENCE_UNRESOLVED` (warning, preserved), - `CONTAINED_CYCLE` (error), `FULLURL_ID_MISMATCH` (error: a `urn:uuid` fullUrl is exempt). All - value-free (a FHIRPath location, never a value, reference, or id). - -```ts -import { parseResource, readBundle, validateResource, streamNdjson } from "@cosyte/fhir"; - -const { resource } = parseResource( - '{"resourceType":"Bundle","type":"transaction","entry":[' + - '{"fullUrl":"urn:uuid:1","resource":{"resourceType":"Patient","id":"1"},' + - '"request":{"method":"POST","url":"Patient"}}]}', -); - -readBundle(resource).atomic; // true: a transaction is all-or-nothing (a batch would be false) - -// A contained reference cycle is a bounded, typed finding, never an infinite loop: -const { issues } = validateResource( - parseResource( - '{"resourceType":"Bundle","type":"collection","entry":[{"resource":' + - '{"resourceType":"Observation","id":"o","contained":[' + - '{"resourceType":"Observation","id":"a","hasMember":[{"reference":"#b"}]},' + - '{"resourceType":"Observation","id":"b","hasMember":[{"reference":"#a"}]}]}}]}', - ).resource, -); -issues.some((i) => i.code === "CONTAINED_CYCLE"); // true - -// Stream a Bulk NDJSON export without loading the file; a bad line is isolated, not fatal: -for await (const record of streamNdjson(readableChunks)) { - if (record.error) - console.warn("bad line", record.error.line); // line number, never content - else handle(record.resource); -} -``` - -## What this will be - -FHIR is HL7's modern, resource-oriented interoperability standard, the format behind the US -regulatory push (ONC HTI-1 binds §170.315(g)(10) to **FHIR R4 + US Core + SMART on FHIR**). -`@cosyte/fhir` is the FHIR member of the cosyte parser family: a small, zero-runtime-dependency -TypeScript library that reads and writes FHIR, models its resources with correct primitive -semantics, and validates against structural rules and US Core profiles, mirroring the API shape of -[`@cosyte/hl7`](https://github.com/cosyte/hl7), the reference parser. - -## Architecture decisions - -The decisions that shape everything downstream: - -- `decimal` / `integer64` are **string-backed** and preserve lexical precision. `0.010` is never - silently normalized to `0.01`, and these primitives never round-trip through JS `number`. -- **FHIRPath**: a bounded, vendored subset in-repo, no runtime dependency, no full third-party - engine. -- **R4-first** (`4.0.1`), the US regulatory anchor. R5 and DSTU2 are **read-tolerance only**. - -## Tech stack - -Inherited from the shared `@cosyte/*` standard, by depending on the published `@cosyte/*` config -packages, not by copying files: - -- **TypeScript** (strict) via `@cosyte/tsconfig`, target **ES2023**, `NodeNext`. -- **Dual ESM + CJS + `.d.ts`** build via `tsup` (`@cosyte/tsup-config`); `attw` is a publish gate, - run through `scripts/attw.mjs`. The wrapper is there because the `attw` CLI prints "This package - does not contain types." and then exits **0**, so a tarball that lost its declarations passed the - gate. It checks that every artifact path `package.json` promises exists before the run, and - treats an untyped report afterwards as a failure. -- **Node >= 22**; package manager **pnpm 10**. -- **ESLint 10** (`@cosyte/eslint-config`) + **Prettier** (`@cosyte/prettier-config`), lint at - `--max-warnings=0`. -- **Vitest 4** + v8 coverage (`@cosyte/vitest-config`). -- **Zero runtime dependencies.** -- **License:** MIT. - -## Development +The quickstart on [docs.cosyte.com/fhir](https://docs.cosyte.com/fhir) walks the same document step +by step, and [`examples/`](https://github.com/cosyte/fhir/tree/main/examples) holds four runnable +programs: read and validate, the safety readout, JSON and XML, and Bundles with Bulk Data NDJSON. + +## API + +Everything ships from one entry point; there are no subpath imports. + +- **Read and write.** `parseResource` and `serializeResource` for JSON, `parseResourceXml` and + `serializeResourceXml` for XML, over one schema-free model, and `nodesEquivalent` to compare a + model read from one format with a model read from the other. A `decimal` or an `integer64` keeps + its exact text (`FhirDecimal`, `FhirInteger64`) and never passes through a JavaScript number. +- **Validate.** `validateResource` returns `valid`, the issues, and `toOperationOutcome()` to render + them as an `OperationOutcome`. Pass the `profiles` you load with `loadStructureDefinition` or + author with `defineProfile`, and a `terminology` service of your own for code validity. +- **Read status and negation.** `readSafety` surfaces status, modifiers and negations. + `assertSafeToSummarize` throws `FhirSafetyError`, carrying locations only, when a resource holds + something it cannot summarize honestly, such as a `modifierExtension` it does not understand. +- **Read measured values.** `readObservationValue` branches on the `value[x]` type and keeps the + UCUM `code`, and `readMedicationDoses` reads dose quantities. No unit is ever converted. +- **Bundles and NDJSON.** `readBundle` tells a transaction (all or nothing) from a batch and + executes neither, `resolveReference` resolves relative, absolute, logical and `#fragment` + references, and `streamNdjson` reads a Bulk Data export line by line, reporting a malformed line + and carrying on. + +## PHI and safety + +FHIR resources carry protected health information, so treat every document you hand this library +as PHI. + +- **It works in memory.** It reads, validates and writes in memory and nothing else. It opens no + socket, writes nothing to disk and fetches nothing at run time; it has zero runtime dependencies, + imports no Node built-in, and calls no terminology service unless you pass one in. +- **Diagnostics carry no values.** An issue is a code and a FHIRPath location, never the value it + was raised over. A location can repeat an element name the document supplied when that name has + the published form of a FHIR name, so treat a location as derived from the document. +- **The XML reader accepts plain XML only.** Anything else is refused with a typed `FhirXmlError` + that carries a code and a byte offset, never a slice of the document. The reader performs no I/O + and resolves no URI. The troubleshooting pages on + [docs.cosyte.com/fhir](https://docs.cosyte.com/fhir) list each refusal and its code. +- **Nesting is bounded.** Both readers stop at a fixed depth with a typed error, so a deeply nested + document fails cleanly rather than exhausting the stack. +- **A status or a negation is not skipped silently.** `assertSafeToSummarize` refuses a resource + carrying a `modifierExtension` it does not understand, and each other shape it cannot summarize + honestly, rather than letting a summary walk past it. +- **What you still own.** Transport, storage, retention, access control, audit logging, and every + log line your own code writes. This library makes no compliance claim on your behalf. + +## Documentation + +- [docs.cosyte.com/fhir](https://docs.cosyte.com/fhir): installation, the quickstart, core + concepts, guides, current limits and troubleshooting. +- [`documentation/capabilities.md`](https://github.com/cosyte/fhir/blob/main/documentation/capabilities.md): + the full capability readout, layer by layer, with what each one checks and what it leaves open. +- [`documentation/`](https://github.com/cosyte/fhir/tree/main/documentation) in the repository: the + capability readout, the FHIRPath coverage measured against the shared R4 conformance suite, and + the architecture decisions. +- [`CHANGELOG.md`](https://github.com/cosyte/fhir/blob/main/CHANGELOG.md): every change, with the + breaking ones called out. + +## Contributing + +Questions, bug reports and FHIR quirks from the servers and EHRs you connect to all belong in +[GitHub issues](https://github.com/cosyte/fhir/issues). Pull requests are welcome; every fixture +must be synthetic, never a real patient's data. ```bash pnpm install @@ -1257,8 +176,8 @@ pnpm test Every meaningful change gets a Changeset (`pnpm changeset`: `minor` for new capability or a breaking change while the version is below 1.0, `patch` for a fix) and a `CHANGELOG.md` `[Unreleased]` -entry. See [`CONTRIBUTING.md`](CONTRIBUTING.md). +entry. See [`CONTRIBUTING.md`](https://github.com/cosyte/fhir/blob/main/CONTRIBUTING.md). ## License -[MIT](LICENSE) © Cosyte +MIT, copyright Cosyte. See [LICENSE](./LICENSE). diff --git a/documentation/agent-notes.md b/documentation/agent-notes.md index 1447ae3..0b075d6 100644 --- a/documentation/agent-notes.md +++ b/documentation/agent-notes.md @@ -545,6 +545,31 @@ umbrella's own always-read `CLAUDE.md` and the `work-on` skill state both of the ## Publish state (`FHIR-NPM-NAME`) +- **`0.1.0` WAS REFUSED ON 2026-09-25, AND THE NARROWED SHAPE IS RESTORED FOR THE NEXT ATTEMPT AS THE + CONTROL THE ENTRY BELOW CALLS FOR.** The shared Release run + [36193551694](https://github.com/cosyte/fhir/actions/runs/36193551694) staged `@cosyte/fhir@0.1.0` + with `npm stage publish` (npm 11.19.0) and drew `E403` on + `POST https://registry.npmjs.org/-/stage/package/@cosyte%2ffhir`, the provenance statement reaching + rekor first (logIndex `2962104147`), exactly as before. Nothing was published and `0.1.0` is + unused. That tarball was the full one: 10 files, 1.2 MB packed, 4.6 MB unpacked, with the README + at 99.6 KB, the changelog at 316.9 KB and both sourcemaps. + - **The shape now on `main`.** `files` names `dist/index.mjs`, `dist/index.cjs`, `dist/index.d.ts`, + `dist/index.d.cts` and `LICENSE`, so the maps and `CHANGELOG.md` stay out: 7 files, 416.4 KB + packed, 1.5 MB unpacked, the same file list `0.0.10` shipped. The README is NOT a stub this time: + it is a slim page (about 10 KB) with the install block and the executed quickstart, and it + carries none of `XXE`, `billion laughs`, `ENTITY`, `DOCTYPE`, `exploit`, `payload`, `attack` or + `malicious`, the XML reader being described as accepting plain XML only. The full capability + readout moved to `documentation/capabilities.md`, which does not ship. Counted over the packed + bytes, the two bundles and the two declaration rollups carry the SAME whole-word counts as + `0.0.10`'s did (per rollup 7 `XXE`, 7 `billion-laughs`, 20 `entity`, 5 `DOCTYPE`; per bundle 3, + 3, 7 and 5), because that vocabulary is in `src/` doc comments and one error message, and + stripping it is a regression that was not done. + - **What the next attempt separates, and what it does not.** `0.0.10` was a direct `PUT` that + created the package; `0.0.11` a direct `PUT` adding a version; `0.1.0` a staged `POST` adding a + version. If the narrowed staged publish is accepted, the contents are the variable for adding a + version through this path. If it is refused, the contents alone are not the cause, and the + operation (adding a version, or staging it) is the live question. Either way it is ONE + observation: the rule below about single publishes still binds. - **NOT RESOLVED. `0.0.10` PUBLISHED, `0.0.11` WAS REFUSED, AND THIS ENTRY PREVIOUSLY SAID "RESOLVED".** That is the first thing to read here, because the wrong version of this paragraph was written from a single successful publish and the very next publish refuted it inside 48 diff --git a/documentation/capabilities.md b/documentation/capabilities.md new file mode 100644 index 0000000..35e9141 --- /dev/null +++ b/documentation/capabilities.md @@ -0,0 +1,1193 @@ +# @cosyte/fhir capabilities + +The full capability readout for `@cosyte/fhir`: what each layer reads, checks and refuses, and what it +leaves open. The [README](../README.md) carries the install command, the quickstart and the short +status, and [docs.cosyte.com/fhir](https://docs.cosyte.com/fhir) carries the task-oriented +documentation: installation, the quickstart, core concepts, guides, current limits and +troubleshooting. + +## Status + +**Status: 0.1.** While the package is below 1.0, a breaking change ships in a minor version and is +called out in the changelog. What is built: the no-data-loss core (a +precision-preserving JSON codec and typed primitive model), the first three validation layers +(structure, cardinality, and primitive/enumerated-`code` value-domain) with value-free +`OperationOutcome` output, the **safety-critical status & negation model** (`readSafety`, +fail-closed on unknown `modifierExtension`, the `ait`/`con`/`obs` invariants), the **R4 base +constraints of the eight modeled resource types** (`pat-1`, `obs-3`, `con-1`, `con-2`, `imm-1`, +`dom-2` to `dom-5`, `ele-1`, `ext-1`), evaluated with no profile supplied, **Quantity / UCUM +fidelity** (the 11-way `Observation.value[x]` discrimination, UCUM-`code` unit fidelity, vital-signs +required-unit conformance, dose quantities), **strength-aware, content-free terminology binding +validation** (a frozen known-systems registry, binding-strength severity, the multi-system allergy / +medication bindings, and a pluggable terminology-service interface, none bundled), +**StructureDefinition-driven profile validation** (snapshot generation, slicing, `fixed[x]` / +`pattern[x]`, and must-support as a system obligation, against caller-supplied US Core / vendor +profiles, none bundled), and **profile-invariant validation through a bounded, vendored FHIRPath +subset** (an in-repo lexer → parser → evaluator that evaluates a profile's `constraint[]`, reporting +anything outside the subset `INVARIANT_UNCHECKED` rather than passing it), and a **zero-dependency XML +codec** (`parseResourceXml` / `serializeResourceXml`) that reads and writes the same schema-free model +as the JSON codec, with a reader that is **XXE- and billion-laughs-proof by refusal** (any DTD or +non-predefined entity is refused loudly, never resolved or expanded) and a `nodesEquivalent` oracle for +JSON↔XML model equivalence, and **Bundles + references + Bulk NDJSON streaming** (`readBundle` with +**transaction = all-or-nothing vs batch = independent** semantics: modeled, never executed; reference +resolution for relative / absolute / logical / `#fragment` with a **DoS-safe cycle guard**; and a +`streamNdjson` reader with **per-line error isolation** and **no whole-file load**), and a +**programmatic profile-authoring API** (`defineProfile()` builds a `StructureDefinition` in code: the +same model `loadStructureDefinition` reads from JSON, one path with no privileged internal shape, plus +a spec-grounded **starter kit** of example profiles that dogfood it), and **conformance hardening**: +JSON + XML + NDJSON **fuzz targets** proving adversarial input never +crashes / hangs / OOMs (only a _typed_ error or a bounded rejection; the JSON reader now bounds nesting +with a `MAX_DEPTH_EXCEEDED` fatal, matching the XML reader), a **PHI-leak test tier** gating the +value-free-diagnostics contract, and **type-level (`expect-type`) tests** on the public surface. See +[What works today](#what-works-today). It **reads, round-trips, +structurally validates, never drops a modifier / status / negation, surfaces measured values by their +true type with the UCUM `code`** (never the display string, never converted), **validates code systems +and binding strength without vendoring any SNOMED / CPT / LOINC content, validates against US Core +profiles you supply, and evaluates their FHIRPath invariants** (failing safe to `INVARIANT_UNCHECKED` +on any unsupported expression); it does **not** yet do `type`·`profile` slicing discriminator or +reslicing validation (still `PROFILE_SLICE_UNCHECKED`), and it bundles **no** US Core +IG corpus. The `validator_cli.jar` **differential is authored but CI-only** (a JVM oracle job: there is +no Java in the dev container, so it has not been observed green there) and now runs over **both** the +synthetic spec-clean corpus **and** the real-world quirk corpus. The built-in structural schema set is the base-resource elements plus +`Patient` as a worked demonstrator; other resource types validate only against a caller-supplied schema +or profile. Without a supplied terminology service there is **no code-validity / value-set-membership** +guarantee beyond `system` + strength (no terminology content is bundled: licensing). Its XML codec is +schema-free like the JSON one, so an XML-sourced primitive is kept as its lexical string and **typed +cross-format transcoding** (emitting spec-clean JSON booleans/numbers from an XML model) needs the +datatype schema and is not yet done; the XHTML **structure** inside `Narrative.div` is not modeled or +validated (carried opaquely as a string (the JSON codec's fidelity), never dropped), and RDF/Turtle is +out of scope. It has no typed per-resource models +yet, and it **never converts a unit** or evaluates a reference range. + +## What works today + +The no-data-loss core: read FHIR R4 JSON into an immutable model and serialize it back, **without +ever losing a decimal, a primitive extension, or an exact 64-bit value**. + +```ts +import { parseResource, serializeResource } from "@cosyte/fhir"; + +const { resource, issues } = parseResource( + '{"resourceType":"Observation","valueQuantity":{"value":0.010,"unit":"mg"}}', +); + +// The trailing zero survives: a naive JSON.parse would have made this 0.01. +serializeResource(resource); // → {"resourceType":"Observation","valueQuantity":{"value":0.010,"unit":"mg"}} + +// Diagnostics are value-free: a code + a FHIRPath location, never the value. The location is +// built from the names the document supplies, and those are bounded to their published form. +issues; // → [{ code: "DECIMAL_PRECISION_AT_RISK", severity: "information", expression: "Observation.valueQuantity.value" }] +``` + +- **`decimal` / `integer64`** are string-backed (`FhirDecimal`, `FhirInteger64`) and never routed + through the JS `number` type. `FhirDecimal.equals` is precision-sensitive (`0.010 ≠ 0.01`); + `.equalsValue` compares quantity only. +- **Primitive extensions** (the `_element` sibling) are modeled first-class with **null-padded array + alignment**; a misaligned value/`_`-array **fails closed** rather than mis-attaching an extension. + A `_`-sibling written beside an element that is **not** a primitive has no defined meaning (a + complex element carries its `id`/`extension` inline), and the reader does not model what was in it, + so that position raises `MISPLACED_PRIMITIVE_EXTENSION`: its own code, because unlike an unexpected + property it says content is **not readable** there rather than merely tolerated. +- **A diagnostic's `expression` is a location and nothing else.** R4 defines + `OperationOutcome.issue.expression` as a FHIRPath subset that resolves to a node, so an issue says + _where_ in the path and _why_ in the `code`, and never explains itself in prose inside the path. + Two forms are deliberately not resolvable and are the only two: a `` segment, which is + what a name that fails the bounded-echo shape test prints as, and the XML reader's `.@name`, which + is an XML attribute FHIR gives no element to address. +- **Lenient read, conservative write** (Postel's Law), `resourceType` resolvable in any position, and + a `parseReference` classifier (relative / absolute / logical / fragment). The writer authors no + value of its own, and it emits spec-clean FHIR for every model FHIR can express; the shapes it + cannot express (an array inside an array, a scalar or `null` where FHIR JSON has an object (at a + complex element's own position, or in a primitive's `_`-sibling), a `null` + in a **primitive's** value channel that padded nothing, and a + non-string `resourceType`) are handed back as written + rather than repaired, because repairing them means inventing or dropping content. See the + no-data-loss notes below. **The two `null` entries are different branches**, and for a long time + only the first was listed while the second was silently deleted on emit. +- **A repeated property name is read, not resolved.** FHIR requires unique property names and JSON + leaves the winner undefined, so the first value wins everywhere and a `DUPLICATE_PROPERTY` issue + says where. On an **object** element both values are kept (`getAllProperties` reads them, + `getProperty` still returns the first), the element is treated as genuinely ambiguous, and nothing + downstream pretends otherwise: it validates as an error, the safety readout declines to + summarize it rather than answering from one arbitrary half of the document, and **both writers + refuse** rather than emit the surviving member alone. Inside a **primitive's + `_element` metadata** (`id` and `extension`, which no safety verdict reads) the issue is raised but + the shadowed member is not kept, and validation and the safety readout are unaffected. + +And the first three validation layers (structure, cardinality, and primitive/enumerated-`code` +value-domain) with a value-free `OperationOutcome`: + +```ts +import { parseResource, validateResource, serializeResource } from "@cosyte/fhir"; + +const { resource } = parseResource('{"resourceType":"Patient","gender":"masculine","wibble":1}'); +const { issues, valid } = validateResource(resource); // lenient (read) mode by default + +valid; // → false +issues; +// → [ +// { code: "UNKNOWN_ELEMENT", severity: "warning", type: "structure", expression: "Patient.wibble" }, +// { code: "CODE_INVALID", severity: "error", type: "code-invalid", expression: "Patient.gender" }, +// ] + +// Render an OperationOutcome: the diagnostics are value-free (a coded reason + a location, never +// the offending value "masculine"), the PHI redaction chokepoint. +serializeResource(validateResource(resource).toOperationOutcome()); +``` + +- **Layered, severity-tagged** (validation.html): structure (`UNKNOWN_ELEMENT`, `TYPE_MISMATCH`, + `CHOICE_AMBIGUOUS`), cardinality (`CARDINALITY_MIN`/`_MAX`), value-domain (`PRIMITIVE_INVALID` with + the R4 datatype regexes, `CODE_INVALID` for required-strength enumerations). +- **Lenient vs strict:** an unknown element is a `warning` on read and an `error` under `mode: "strict"`. +- **Fail-safe:** never a false error: a resource type with no schema degrades to one informational + `RESOURCE_NOT_MODELED`, not a wall of false unknowns. Built-in schemas: base-resource elements + + `Patient` + `Observation`, and that list is the whole set; supply your own via + `validateResource(resource, { schemas: [...] })`. The set grows one fully-verified element table at + a time, because a table missing a row R4 defines would turn a conformant document into a wall of + false unknowns, which is the failure the degrade above exists to avoid. + +And the safety spine: FHIR's modifier (`?!`) elements, surfaced so they can never be silently dropped +or inverted, and the invariants that harm a patient when read wrong: + +```ts +import { parseResource, readSafety, validateResource } from "@cosyte/fhir"; + +const { resource } = parseResource( + '{"resourceType":"AllergyIntolerance",' + + '"clinicalStatus":{"coding":[{"system":"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical","code":"active"}]},' + + '"code":{"coding":[{"system":"http://snomed.info/sct","code":"716186003"}]}}', +); + +readSafety(resource).negations; // → ["no-known-allergy"] (a recorded "no allergy", not an allergy TO it) + +// An unknown modifierExtension fails closed: the resource cannot be safely processed. +const { resource: quirky } = parseResource( + '{"resourceType":"Observation","status":"final","modifierExtension":[{"url":"http://vendor.example/x"}]}', +); +validateResource(quirky).issues.map((i) => i.code); // → ["UNHANDLED_MODIFIER_EXTENSION"] +``` + +- **Never-droppable status/negation:** `readSafety` carries `status` / `clinicalStatus` / + `verificationStatus` / `doNotPerform` / retraction and a classified `negations` list (`refuted`, + `no-known-allergy`, `do-not-perform`, `not-taken`, `not-done`, `entered-in-error`). The + type-scoped slots are filled for the types `SAFETY_RESOURCE_TYPES` names; **the negation reads that + can only add a finding are not type-scoped at all**: a retraction, a refutation, an instruction + **not** to perform, and a `status` of `not-done` / `not-taken` are surfaced whatever the resource + type, because a gate does not merely fail to read the types it omits, it never looks, so nothing is + reported for them either. Those same reads run at **every resource root**, so a retracted + `Observation`, a `Procedure` recorded as not performed or an order marked "do not perform" inside a + `Bundle` entry or `contained` reaches `negations` too. **The readout's location channels + (`unhandledModifierExtensions`, `modifierElements`, `intents`, `datatypeUses`, + `shadowedProperties`, `arrayWrappedScalars`, `nestedArrays`, `droppedText`, `unreadableBooleans`, + `nearMissNegationCodes`, `unreadableNegationCodes`, `unreadableIntents`, + `unreadableDatatypeUses`, `absenceMarkers`, `unreadableAbsenceMarkers`, + `conflictingAbsenceMarkers`) and `safeToSummarize` are document-wide.** Every one of them but + `absenceMarkers`, `intents` and `datatypeUses`, which disclose rather than refuse, sets + `safeToSummarize` to `false` when it is not empty. The **single-valued** fields (`status`, `retracted`, `doNotPerform`, `noKnownAllergy` + and the rest) answer about the resource you handed in, because one value cannot say which resource + it came from, so branch on `negations` whenever a resource may carry others. + `no-known-allergy` is the exception and + stays a root, type-scoped read: it is a _positive_ clinical assertion, read off an element R4 does + not flag `?!`, and surfacing one from somewhere inside a document could make a caller less careful + where leaving it unsurfaced reads as _unknown_. + `assertSafeToSummarize` **refuses** (throws) rather than flatten past an unhandled modifier. +- **A modifier is not only a `modifierExtension`, and the ordinary base elements R4 flags `?!` reach + the readout too.** `modifierElements` carries one entry per location, each + `{ element, location }`: `comparator` wherever a node the walk reaches carries it, `implicitRules` + likewise, `active`, `deceased` (R4's `deceased[x]`, located at the member as written, such as + `Patient.deceasedDateTime`) and `link` (once, at `link`, however many entries) on a `Patient` root, + `isSubpotent` on an `Immunization` root, and `use` on a `Practitioner`'s `identifier` entries + (on the eight types below, `use` is surfaced as its code instead). Each + sets `safeToSummarize` to `false` and makes `assertSafeToSummarize` throw, **on presence and at any + value**, because `{"valueQuantity":{"value":0.01,"comparator":"<","unit":"mg"}}` summarized as a + point value is `0.01 mg` reported for a result the sender wrote as `< 0.01 mg`, a deceased patient + or a record replaced by another is not the live record a summary would show, and a subpotent dose + does not protect the way a potent one does. So `"deceasedBoolean": false` and + `"isSubpotent": false` refuse too, exactly as `"active": true` does: deciding from the value would + be interpreting the modifier. **Reporting only:** the element is + surfaced and never interpreted, so no bound, range or inequality is read out of a `comparator`, no + unit is ever converted, no death date is read and no `link` is followed or its `type` read. + Recognition is by KEY NAME (and, for the type-gated elements, by + literal `resourceType` equality), which OVER-reports by construction: any object carrying a + `comparator` member is an occurrence, a vendor payload that reuses the name included. That trade is + deliberate, since a false positive costs a refusal and a false negative costs a wrong clinical + value. A report carries the element and the location and nothing from the document: no value, no + unit, no code, no URI; and a location roots at a resource type name only when the name is one this + library defines (`MODIFIER_ELEMENT_ROOT_TYPES`), so an unmodeled type reads as a constant token. + `modifierElements(resource)` is the standalone collector, and a modifier EXTENSION stays on + `unhandledModifierExtensions` and draws nothing here, so one of those is still one report. +- **`MedicationRequest.intent` is surfaced as its code, because it is mandatory.** R4 flags it a + modifier (a `proposal` is not an `order`) and makes it `1..1`, so refusing on presence would refuse + every MedicationRequest. `intents` carries one `{ code, location }` per `MedicationRequest` root, + the resource handed in, `contained` or a `Bundle` entry alike, and a readable one leaves + `safeToSummarize` standing. Only the eight codes of the R4 value set (`proposal`, `plan`, `order`, + `original-order`, `reflex-order`, `filler-order`, `instance-order`, `option`) are surfaced, matched + exactly. Anything else written at `intent` (`"PROPOSAL"`, `" order"`, `"draft"`, a `null`, a + number, the `_intent` form with no value, an array wrapper, the name written twice) surfaces no + code: its location is on `unreadableIntents`, `safeToSummarize` is `false`, and + `assertSafeToSummarize` throws. Nothing is case-folded, trimmed or mapped. An absent `intent` + surfaces nothing and refuses nothing, since a missing mandatory element is the validator's verdict. +- **`use` on an identifier, a name, an address or a contact point is surfaced as its code.** R4 flags + it a modifier on all four datatypes, so that an `old` or `temp` entry is not taken for a current + one. `datatypeUses` carries one `{ code, location }` per covered position: every `identifier` and + `groupIdentifier` at any depth (a Reference's `identifier` included) below a root of + `AllergyIntolerance`, `Condition`, `DiagnosticReport`, `Immunization`, `MedicationRequest`, + `MedicationStatement`, `Observation` or `Patient`, and a `Patient`'s `name`, `telecom` and + `address`, on the patient and on each `contact`; the root may be the resource handed in, + `contained` or a `Bundle` entry, and a position belongs to its nearest enclosing root. Only a code + of the position's own R4 value set is surfaced, matched exactly (identifier `usual` `official` + `temp` `secondary` `old`; name `usual` `official` `temp` `nickname` `anonymous` `old` `maiden`; + address `home` `work` `temp` `old` `billing`; contact point `home` `work` `temp` `old` `mobile`). + **A readable `use` does not lower `safeToSummarize`, `old` and `temp` included:** `use` is written + on most records, so refusing on presence would refuse nearly every patient, and refusing on which + code was written would be interpreting the modifier. Nothing maps a code to "not current", and no + entry is filtered, reordered or picked as the current one; that is the caller's decision. Anything + else written at `use` (`"OLD"`, `" old"`, `""`, a code from another datatype's set such as + `"maiden"` on an identifier, a `null`, a number, the `_use` form with no value, an array wrapper, + the name written twice) surfaces no code: its location is on `unreadableDatatypeUses`, + `safeToSummarize` is `false`, and `assertSafeToSummarize` throws. A position with no `use` + surfaces nothing. **Limits:** a `Practitioner` is not read here (its `identifier.use` stays on + `modifierElements`, reported on presence); a `use` carried in an extension value + (`valueIdentifier`, `valueHumanName`, `valueAddress`, `valueContactPoint`) is not read; a nested + resource of a type outside the eight, such as a contained `Organization`, is not read; and an + ended `period` on an otherwise current entry is a separate element this does not read. +- **A safety verdict is never asserted over a value the document left ambiguous.** Each negation read + runs over every coding on a `CodeableConcept` and every value written for the element it reads + (`resourceType`, `status`, `verificationStatus`, `code`, `doNotPerform`), **including through an + array wrapper around the element**, so a retraction or a refutation cannot hide in the one a + single-value lookup skipped. Where a repeated property name leaves an element with two values, + `safeToSummarize` is `false` with the locations in `shadowedProperties` instead of an affirmative + answer. +- **A single-valued element wrapped in an array is read, and reported.** FHIR JSON writes a `0..1` + element as a name/value pair and uses an array only for a repeating element, so + `{"resourceType":"Observation","status":["entered-in-error"]}` is non-conformant, and a plain + single-value read finds no code in it at all. It is realistic input, because a generic XML-to-JSON + converter array-wraps every element it emits. The negation reads see through the wrapper, an + `ARRAY_WRAPPED_SCALAR` issue (error) says where it was, and `safeToSummarize` is `false` with the + locations in `arrayWrappedScalars`. The check covers `resourceType` and the single-valued safety + elements on a resource root; deciding cardinality elsewhere would need a per-resource model, and R4 + genuinely does define repeating elements under some of the same names (`Questionnaire.code`), so a + name-only rule would report a conformant document as broken. +- **That extends one level down, to a `Coding.system` / `Coding.code` inside a `CodeableConcept`**, + which are `0..1` too and which the same converter wraps the same way, so a refuted allergy, a + recorded "no known allergy" and a retracted Condition all hinge on it. Here the wrapper is read only + where it holds **exactly one array position**, and the restriction is the safety property, not + caution: those two values are paired with each other, so a rule yielding more than one value on + either side would pair a `system` written in one position with a `code` written in another and + **assert a coding the sender never wrote**, including a recorded absence of allergy, which is a + positive clinical claim about a patient. Positions, not values: a JSON `null` inside a primitive + array is a real position whose `_`-sibling may carry an extension, so `["716186003", null]` is two + positions and is not read. A wrapper that is not read is still reported, so a negation is never + quietly skipped: either way the `Coding` draws an `ARRAY_WRAPPED_SCALAR` error and + `safeToSummarize` is `false`. + **Scope:** this covers the codings of `clinicalStatus`, `verificationStatus` and `code`, which are + the elements a safety verdict is read out of. A `Coding` anywhere else (`category`, + `interpretation`, `referenceRange.type`, a `component`'s own `code`, and anything `codingsOf` is + pointed at directly) is read exactly as it was before, without the wrapper. Reading a wrapper the + library does not also report would resolve a clinical code out of an encoding FHIR JSON does not + define and hand it back with no diagnostic anywhere. + **The type scoping stops at the element name, and it does not reach the `Coding` inside it.** The + negation reads are not type-scoped at all, so a `verificationStatus.coding.system` / `.code` + wrapper is reported at **every** resource root of **any** type, including inside `contained` and a + `Bundle.entry` -- read through where it holds one position, left unread where it holds more, and + reported either way. What makes that safe without a per-resource model is that `Coding` is a + _datatype_: its `system` and `code` are `0..1` wherever a `Coding` appears, so no question about + the enclosing resource arises. A `code` carrying SNOMED `716186003` is read only on the types the + cardinality table knows, so its wrapper is reported wherever it is read. + **One read still runs ahead of its report, and it is named rather than smoothed over:** the + `clinicalStatus` convenience field is filled off **any** resource root, so on a type the + cardinality table does not know, that value is unwrapped (or, for a multi-position wrapper, + declined) with no location reported, so `safeToSummarize` stays `true` over a value the library + declined to read. Otherwise it reaches that one field and nothing else: never `negations`, never + `valid`, never `noKnownAllergy`. A declared residual, pinned in both states, not a design. +- **An array inside an array is reported, and its contents are kept but never interpreted.** FHIR + JSON uses an array for a repeating element and for nothing else, so a list of lists has no meaning + at any position and there is no element for the reader to make of it. Left alone the model then + looks exactly like an element the sender legitimately left out: whole resources have gone missing + this way inside a `Bundle.entry`, and a refuted allergy has read back as an ordinary active one. So + the position is named on every channel. `NESTED_ARRAY` on the read (warning) and in + `validateResource` (error), the locations in `nestedArrays`, `safeToSummarize` is `false`, and + `assertSafeToSummarize` throws. `isNestedArray` marks the node for a consumer walking the model + directly. Because the shape is meaningless everywhere, this needs no cardinality rule and cannot + fire on a conformant document, so unlike the two above it the check runs at every position the + model has a node for, at every depth, including a primitive's `extension` metadata. + **The array itself is not lost.** Its exact JSON text is preserved on the node and handed back by + `nestedArrayContent`, so you can inspect or re-parse what the sender wrote (`readRawJson` will + parse it with the same precision guarantees as the rest of the codec). A repeating primitive can + nest in its value array, in its `_`-sibling array, or in both at one position, so the two channels + come back separately rather than merged. `serializeResource` writes the array back, which is the one + place the writer emits something it would not author: the alternatives are to emit the empty + element the model holds, which fabricates an object the sender never wrote, or to omit the + position, which drops content. Writing it back is also what makes the finding survive a round trip + rather than laundering away. The preserved text is the array re-rendered compactly, so member order, + every member of a repeated key and every number's exact source survive, but insignificant + whitespace does not and strings are re-escaped canonically, exactly as everywhere else this library + emits JSON. Such output is deliberately **not** spec-clean. +- **A scalar where FHIR JSON has an object is handed back too, and that one stops the writer authoring + a value.** One position over from the shape above: a string, number, boolean or `null` written where + a complex element belongs is content the reader has no element to make of it either, so it reports + `UNKNOWN_PROPERTY` and the model holds an empty element there. Emitting that element writes `{}`, + which is a **conformant** empty element, so the warning was gone the moment the output was read + back and the writer had presented an object as read at a position nothing was read at. + `serializeResource` now writes the value the sender wrote instead, so the finding survives the round + trip. The scalar is **not** modeled as a primitive, deliberately: putting it in the tree would make + it visible to every walker at a position walkers read as a complex element. It hangs off the node + (`FhirComplex.nonObjectSource`), where only the writer reads it. Such output is deliberately **not** + spec-clean, and `serializeResourceXml` does not carry it (that writer emits the empty element, the + same as it does for an array inside an array). + **The same rule reaches a primitive's `_`-sibling, which is the other place FHIR JSON has an + object.** §2.6.2.3 gives that channel the `id` and/or `extension`, so `{"_status":null}` and + `{"_status":"x"}` carry no metadata to model; the writer emits a `_`-sibling only for metadata it + has, so both used to come back as `{}` with **no diagnostic at all**: the member gone, and + `valid` and `safeToSummarize` both affirming. They now draw the same `UNKNOWN_PROPERTY` and the + text is handed back (`FhirPrimitive.nonObjectMetaSource`), so the finding survives the round trip. + **A `null` padding a repeating primitive's `_`-array is the one shape §2.6.2.3 defines and draws + nothing**; a `null` at a singleton `_` slot is never padding and does. + **Gaps, stated rather than implied:** the rule is bounded by what the reader modeled. A + `_`-sibling the reader discards whole because it is misplaced or unrecognised (one sitting on an + object or a non-primitive array, or a member of a `_`-sibling object that is neither an `id` + **string** nor an + `extension` array) leaves no node behind, so an array inside one is reported against the discarded + sibling and draws no refusal. **Which warning it draws is per member, not one code for all three.** + An unrecognised member of a `_`-sibling object draws `UNKNOWN_PROPERTY`: + `{"birthDate":"1980-01-01","_birthDate":{"foo":[["x"]]}}` reports it at `Patient.birthDate.foo`. A + `_`-sibling on an object and one on a non-primitive array instead draw + `MISPLACED_PRIMITIVE_EXTENSION` for the misplaced sibling and nothing besides: + `{"name":{"family":"Roe"},"_name":[[{"id":"x"}]]}` reports that one code at `Patient.name`, and no + unexpected-property warning. Reaching it would mean reading raw JSON the codec + does not model, which is the same problem as making the value readable. An **empty** `_`-sibling + object or array is a different clause of the spec (§2.6.2.1's "never empty") and is still deleted + silently; a `_`-sibling object's own unreadable **member** is reported but that report still does + not survive emit. Both are open and declared, not closed here. + **What it deliberately does not do is put the array in the tree.** The preserved content is text, + not an element: it is not reachable through a node's properties, items or extensions, so a list + holds exactly the items it held before, of the same kinds, with the same contents, and nothing that + walks a repeating element sees anything new. That boundary is not a matter of taste. This library + has checks that flatten a repeating element into its items and then skip whatever is not the kind + they expect, so a list holding a list would reach them as an absent value: a profile invariant, a + vital-signs unit check or a negation would go unevaluated and the resource would read as valid. + Preserving the text costs none of that. + **One further limitation, stated rather than hidden:** a scalar written beside a nested array in + the same array (`"given":[["Peter"],"James"]`) lands where an object was expected, and that scalar + is still dropped. It is reported as an unexpected property and the resource is still refused, but + unlike the array itself its content is not kept. +- **A `null` the reader reads into a primitive slot is reported and written back, which is the other + half of the rule above and used to be missing.** The bullet above covers the `null` the reader + takes to the **complex** branch. Every other `null` is a different branch, and it read with no + diagnostic at all and was then deleted on emit, so a non-conformant document came back as a clean + conformant one with the member simply gone and every layer affirmed it. + `{"identifier":[{"system":"…","value":null}]}` lost the identifier value; + `{"value":null,"unit":"mg"}` lost the magnitude and **kept the unit**, so a quantity read back as a + bare unit rather than as missing; `"status":null` lost the status. Nothing was _lost_ in the sense + the shapes above lose things (a `null` carries no content), which is precisely why it was + invisible: the output was indistinguishable from a document whose sender had legitimately omitted + the element. + **The rule is what §2.6.2.3 actually defines, not "the document wrote `null`".** FHIR JSON forbids + `null` (§2.6.2.1: "properties never have null values (except for a special case documented below)") + and carves out one exception, which is about a **repeating** primitive: the value array and the + `_`-sibling array are padded with `null` so they align index-by-index. So two conditions are + required for a `null` to be that exception, and both are checked: it sat **inside a repeating + primitive's value array**, and the slot it produced **carries an `id` or a non-empty `extension`** + for it to align with. One that is padding draws nothing and round-trips byte-for-byte exactly as + before. One that is not leaves an element with neither a value nor children, which R4 `ele-1` + requires one of: it draws `UNDEFINED_JSON_NULL` (warning) at that position, `isUndefinedNull` marks + the node, and `serializeResource` writes the `null` back so re-reading the output reproduces the + finding instead of laundering it away. Such output is deliberately **not** spec-clean, on the same + reasoning as the two shapes above. + **A singleton slot is never padding, whatever sits beside it.** §2.6.2.3 states the singleton + encoding positively ("If the primitive has an id attribute or extension, but no value, only the + property with the `_` is rendered"), so a value-absent singleton is `{"_status":{…}}` and both + `{"status":null}` and `{"status":null,"_status":{…}}` are reported. **And the set this walks is + what the reader read as a primitive, not what FHIR types as one:** the model is schema-free, so a + bare `null` at any singleton property reaches the primitive branch whatever the element's FHIR type + would be, and `{"subject":null}` on an `Observation` is reported here rather than as an unexpected + property. Only an array item, and an item of a `_`-sibling's `extension` array, reach the complex + branch. + **What it deliberately does not do, stated rather than implied.** It does not refuse. A `null` is a + non-conformant encoding of an _absent_ value, not content the reader could not read, so unlike an + array inside an array or dropped element text there is nothing unreadable at the position, and + `validateResource` and `safeToSummarize` are unchanged; refusing would also withdraw round trips + that work today, and nothing that round-trips today stops (a value-absent primitive written the + conformant way carries no `null`, so it is never marked). It is scoped to the **value** channel: a + `_`-sibling that is itself not an object (`"_status":null`) is the neighbouring position and draws + `UNKNOWN_PROPERTY` instead, on the reasoning above. **No case has ever moved between the two + codes**, because that channel drew nothing at all until it was closed. And + `serializeResourceXml` does not carry it: XML has no `null`, so it **refuses** rather than emit the + empty element (see `UNSERIALIZABLE_JSON_ONLY_SHAPE` below). +- **A primitive whose value is written as XML element text is reported, not silently read as an + absent value.** FHIR XML carries a primitive's value in the `value` attribute, so + `entered-in-error` puts a code where the model has no slot for one: the character + data is dropped and the element is left holding nothing. That is the same harm as an array inside + an array, reached through the other wire format, and it is the sharper one, because the shape a + retraction takes is an **affirmation**. Measured: a `entered-in-error` read back + as a live record, an `AllergyIntolerance` that lost its `refuted`, and a `doseQuantity` that lost + the dose **number** while its `mg` unit and UCUM code survived, all under `valid: true`. So the + position is named: `DROPPED_ELEMENT_TEXT` in `validateResource` (error), the locations in + `droppedText`, `safeToSummarize` is `false`, and `assertSafeToSummarize` throws. `isDroppedText` + marks the node for a consumer walking the model directly, and the reader's existing + `UNEXPECTED_XML_CONTENT` warning is kept alongside it rather than replaced. Like the rule above it + needs no cardinality table and cannot fire on a conformant document, so it runs at every position + the model has a node for, at every depth, and never on a document read from JSON. + **The text is not read back as the value, deliberately.** Recovering it would be a _tolerance_ for + a non-conformant encoding rather than a report of one, and this library encodes a tolerance only + when a real document shows the shape in the wild. So the value stays unread and the verdict is a + refusal rather than a repair. + **Two limitations, stated rather than implied.** Whitespace between elements is not character data + in this sense and is untouched, so ordinary indented XML is unaffected; but text written beside a + value that _did_ arrive (`entered-in-error`) is dropped too and + draws the same refusal. The rule is keyed on the reader **dropping** character data, not on the + text differing from the value: the reader never compares the two, so + `final` refuses as well, even though nothing is missing there. That + is deliberate. Deciding the document meant no harm would mean reading the text, which is the + tolerance this half does not take. +- **A boolean value outside the datatype's lexical space is reported, not read as an absent + instruction.** R4 spells a `boolean` as `true` or `false` and nothing else, so + `` and `value="Y"` (ordinary v2 and C-CDA converter output, which is how + a great deal of data reaches a FHIR surface) carry no boolean this library may read. They are not + `false`; they are **unreadable**, and the difference is the whole point: measured, a `value="1"` + read back exactly like `value="0"`, so a prescriber's "yes, do not administer" and another's "no" + produced the same answer, with nothing on any channel to say a value had been written and dropped. + So the position is named: the locations in `unreadableBooleans`, `safeToSummarize` is `false`, and + `assertSafeToSummarize` throws. **The value is still not read**, deliberately: coercing `"1"` or + `"Y"` would invent a reading the spec does not license, and it would turn `value="0"` into a JS + `false` that `serializeResource` then emits, laundering an authored value across a format change. + The channel covers `doNotPerform`, the only `boolean` `readSafety` takes off a document, on any + resource type. Its window is every resource root (so a `contained` or `Bundle.entry` order counts), + which is **the negation read's own window**: the value that is read and the value that cannot be + read are decided together, in one pass, so neither can cover a document the other does not. It is + **not** the whole of `arrayWrappedScalars`' window, and the gap is a declared residual rather than + a nicety -- that report's _element-level_ half stays on the resource types this library models a + cardinality for, so `{"resourceType":"ServiceRequest","doNotPerform":[true]}` is read here and on + `negations`, while the array wrapper it arrived in draws no `ARRAY_WRAPPED_SCALAR`. A primitive carrying only `id` / `extension` and + no value is untouched, since nothing was written there to be unread, so this cannot fire on a + conformant document in either wire format. + **Unlike the five findings above it, this one raises no `ValidationIssue` of its own**, for a narrow + and measured reason rather than a general one: the request resources that define `doNotPerform` + have no built-in schema, so the validator is silent about this element's **datatype** unless a + caller supplies one, and the readout has to hold either way. The safety layer knows the datatype + unconditionally, which is why the report lives there. +- **A code that spells a negation bar its case or its surrounding whitespace is reported, not read as + one.** A negation is matched by its exact `code`, because FHIR `code` is case-sensitive and the + datatype's lexical space has no room for surrounding whitespace (`[^\s]+(\s[^\s]+)*`). So + `{"resourceType":"Procedure","status":"NOT-DONE"}` (an upper-casing source system) and a `status` + of `" not-done"` (a padded fixed-width or CSV extract) assert no negation, and **that reading is + correct and unchanged**: folding them in would accept a non-conformant document as though it were + conformant and hand you a negation the sender never spelled. **What was wrong was that the refusal + was silent.** Measured: every such document returned `negations: []` under + `safeToSummarize: true`, with nothing on any channel to say a value had been looked at and + declined, so a caller doing exactly what this readout says to do (branch on `negations`, not on + the raw status string) read a procedure recorded as `"NOT-DONE"` as a procedure with nothing to + say about it. So the position is named: the locations in `nearMissNegationCodes`, + `safeToSummarize` is `false`, and `assertSafeToSummarize` throws. **The value is still not + coerced, trimmed or case-folded.** Unlike the location channels above it nothing is + dropped at parse time either: the value is in the model, at the element the location names, and + what the library declines is the _classification_. That is **not** a promise the value reaches a + convenience field, and the difference bites in two ordinary shapes: `status` / + `verificationStatus` are root-scoped and single-valued while this channel is document-wide, so a + near miss inside `contained` or a `Bundle.entry` leaves them holding the root's value or + `undefined`; and `verificationStatus` surfaces the preferred-system coding, so a near miss in a + second coding is not the code it shows. Walk the model at the location. + **A near miss is suppressed where the same element also spells that code exactly**, since the + negation is then classified: R4 permits translation codings beside the one from a required + binding's value set, so a `verificationStatus` carrying `refuted` from the standard system and + `REFUTED` from a local one is conformant and draws nothing. Suppressed per code, so a near miss of + a _different_ code at that element still reports. The elements are the `code`-valued ones the negation read + looks at, `status` and `verificationStatus`, at every resource root, which is the negation read's + own window. The pairs come from the same table the matches are made from, so the report cannot + cover a pair the read does not. `AllergyIntolerance.code` is deliberately outside it: SNOMED + `716186003` "no known allergy" is a _positive_ assertion whose read is root- and type-scoped, and + disclosing a near miss at every root would report the miss where an exact hit is read by nothing. + Like the boolean channel above, it raises no `ValidationIssue` of its own, so it cannot move + `valid` in either direction. **Empty on every conformant document read from JSON, bar one shape + admitted rather than claimed away**: a `CodeableConcept` may carry translation codings beside the + one a required binding's value set supplies, and R4 asks only that _one_ coding come from that set, + so a translation whose code differs from a negation code **only by case** is conformant under that + reading and is disclosed. Only the case half can be: a surrounding-whitespace value is outside + `code`'s lexical space whatever coding carries it. Over-disclosure is the fail-safe direction. **In + XML the + whitespace half is a declared limit rather than a claim**: R4 derives `code` from `xs:token`, + whose `whiteSpace=collapse` facet strips surrounding whitespace before validation, so + `` is schema-valid and a schema-validating consumer reads it as the + code. This reader is schema-free and does not collapse, so it discloses rather than reads, which is + the fail-safe direction. +- **Content written where a `code` belongs is reported, not read as an absent element.** FHIR JSON + spells a `code` as a JSON string, so `{"resourceType":"Procedure","status":{"value":"not-done"}}` + (a generic converter carrying FHIR XML's `value` attribute across as a member) and a `status` + written as a number or a boolean hold no code this library may read. Measured, every one of them + returned `negations: []` under `safeToSummarize: true`, so a procedure recorded as not done was + indistinguishable from one that was carried out. **This is a shape, which is why the two channels + above it did not catch it**: both ask about a written _value_, and an object holds no value at all, + so both answered "no" about it truthfully and the element read exactly like one the sender left + out. So the position is named: the locations in `unreadableNegationCodes`, `safeToSummarize` is + `false`, and `assertSafeToSummarize` throws. **Nothing is read through the position**, deliberately: + `{"value":"…"}` is FHIR _XML_'s spelling of a primitive, so descending into it would resolve a + negation out of an encoding no version of FHIR defines for JSON. The element is `status`, at every + resource root, which is the negation read's own window; the complement is carried in the same table + the matches are made from and applied in the same loop, so it cannot cover an element the read does + not. **Two datatypes reach a root `status`, and the question asked is about the shape rather than + about which read succeeded, so that both are cleared**: R4 spells `status` a `code` on the + overwhelming majority of types and a `CodeableConcept` on `MedicinalProductAuthorization` and + `SubstanceSpecification`, R5 adds several more including a mandatory `DeviceAssociation.status`, + and DSTU2 spells every one a `code`. So a complex **all of whose members** FHIR spells here (`coding`, + `text`, `id`, `extension`) is left alone, whether or not a code came out of it, while **any** + member outside that set is reported: `{"status":{"id":"s1","value":"not-done"}}` is the same + converter output and is reported too. An object with **no** member at all is reported as well, + `ele-1` requiring an element present in a resource to carry a value, children, or an extension. Keyed instead on "no string was read", this would refuse the + published R4 `MedicinalProductAuthorization` example. The converse is a declared limit: + a code buried under `{"coding":{…}}` at a type whose `status` is a `code` stays silent. + **`verificationStatus` is deliberately outside it** for a related reason: its shape complement is a + _primitive_ at the element, and `Condition.verificationStatus` is a `code` in DSTU2, so the same + rule would report a conformant DSTU2 document. `AllergyIntolerance.code` is outside it for the + reason that keeps "no known allergy" root- and type-scoped. Like the two channels above it this one + raises no `ValidationIssue`, so it cannot move `valid` in either direction. **Empty on every + conformant document this library has been measured against, in either wire format**: the XML reader + models a `value` attribute beside `id` and `extension` children as a primitive, so a conformant + `` is read; and a primitive whose value is _absent_ + is untouched, that being the conformant `data-absent-reason` shape and content the read never + stepped over. +- **An element a sender explicitly does not know is now distinguishable from one it never sent.** + A source system with no data for an element whose minimum cardinality is greater than zero cannot + omit it, so it writes the element present, with no value, carrying the R4 DataAbsentReason + **extension** and a reason code. Measured, both shapes reached a caller as the same answer: the + element counts as present so no required-element finding fires, and every value read returns + `undefined`, exactly as it does for an element nobody wrote. `absenceMarkers` is the read that + recovers the difference, one `{ code, location }` per declared absence, on a complex element or on + a primitive's extension metadata, in either wire format, at every depth (`contained` and + `Bundle.entry` included). It **carries the reason the sender spelled**, so `unknown` is + distinguishable from `masked`, `not-applicable` and `not-performed` on otherwise identical + instances. `absenceMarkers(resource, path)` is the standalone collector. + **This is the one location-bearing channel that leaves `safeToSummarize` standing**, and the + exception is the point rather than a hole: a declaration the caller can now read is a disclosure, + and refusing over it would withdraw an affirmation from a conformant document. **A report carries + the reason and the location and nothing else**, the reason being one of fifteen literal strings + this package spells (`ABSENCE_CODES`, the extension's own required-strength value set) rather than + anything taken off the document. + Its two neighbours behave like every channel above. **A reason outside that value set is refused, + never folded into `unknown`**: FHIR `code` is case-sensitive and excludes surrounding whitespace, + so `UNKNOWN` and `" unknown"` are not the code and coercing them would author a reason the sender + did not spell. The element is not read as populated either; the location goes on + `unreadableAbsenceMarkers`, `safeToSummarize` is `false`, and the validator raises + `ABSENCE_MARKER_UNREADABLE` (error). **A marker beside a value on one element is a contradiction + this library does not resolve**: both survive on the model and on the readout, the location goes on + `conflictingAbsenceMarkers`, and `ABSENCE_MARKER_CONFLICT` (error) says they disagree rather than + letting a consumer prefer whichever its own read reached first. A complex element "carries a value" + when it holds any member beyond `id`, `url`, `extension`, `modifierExtension` and the JSON + encoding's `resourceType`, none of which is a value a marker denies. + **Two neighbouring shapes are deliberately NOT this.** The same concepts used as a `Coding` inside + a coded element (`system` = the DataAbsentReason code system, `code` = `unknown`) are a present, + conformant coded VALUE, not an absent element, and draw nothing here. Nor is the + `Observation.dataAbsentReason` ELEMENT, an ordinary `CodeableConcept` whose `obs-6` invariant is + unchanged: the element is not the extension, so an Observation carrying one draws exactly what it + drew before and never both findings. Recognition is by the extension's **canonical URL** and + nothing that resembles it, so an extension whose `url` is the code system URI instead is not a + marker. +- **Neither writer will re-emit a document the reader MARKED** (`FhirSerializeError`, code + `DROPPED_ELEMENT_TEXT`). Say "marked", not "whose text was dropped": character data that is + `String.trim()`-empty is dropped with no flag, no marker and no finding, so a `` holding + only whitespace still emits `` and still re-reads clean. That gap is real, unchanged here, + and noted below. This is the other half of the refusal, and it exists because the finding + used to disappear across a round trip. `serializeResourceXml` emitted `` and a re-read of + that output came back clean; `serializeResource` was worse, dropping the member outright, so a + retracted `Observation` re-read as one that had never named a status. The error is value-free and + carries the bounded FHIRPath `locations` it refused over, never the text it could not encode. + Be precise about why `` is not a neutral fallback: xml.html §2.6.1 says _"FHIR elements are + never empty. If an element is present in the resource, it SHALL have either a value attribute, + child elements as defined for its type, or 1 or more extensions"_, so emitting it violates that + SHALL. + **The refusal is scoped to a model the reader MARKED, and to nothing else.** A document read from + JSON has no character-data channel and is never affected; a conformant XML document round-trips + byte-for-byte exactly as before. In particular, writing a value-absent primitive that carries **no + extension** is still permitted and still emits ``: §2.6.1's third arm ("or 1 or more + extensions") is satisfied by ``, which is what a + `data-absent-reason` emits, but an `id`-only primitive (``) has none of the three + permitted contents and remains a **pre-existing** violation this change does not address. Keep the + original document if you need the text itself; the library will not invent it for you. +- **Fail-closed on an unknown `modifierExtension`** (`UNHANDLED_MODIFIER_EXTENSION`, error): FHIR's + `?!` rule; and **`entered-in-error` surfaced** as `RETRACTED_RESOURCE` (retracted, not data). +- **Invariants** `ait-1`/`ait-2`, `con-3`/`con-4`/`con-5`, `obs-6`/`obs-7`, hand-evaluated from their + exact R4 FHIRPath by the always-on safety layer. This layer surfaces and enforces. It + never reconciles contradictions or infers clinical meaning. Every **other** profile `constraint[]` + invariant is evaluated by the FHIRPath engine (below). +- **Base constraints, with no profile**: every other error-severity constraint R4 4.0.1 declares on + the eight modeled types (`AllergyIntolerance`, `Condition`, `DiagnosticReport`, `Immunization`, + `MedicationRequest`, `MedicationStatement`, `Observation`, `Patient`) and on their + `DomainResource`, `Element` and `Extension` base: `pat-1`, `obs-3`, `con-1`, `con-2`, `imm-1`, + `dom-2` to `dom-5`, `ele-1` and `ext-1`, each transcribed verbatim and evaluated by the same + FHIRPath engine. A violation is `INVARIANT_VIOLATED` at `error`, located at the violating + occurrence. `dom-3` is decided by its own terms when nothing is contained, a supplied profile + carrying R4's `dom-3` as written included, and reported `INVARIANT_UNCHECKED` when something is, + because checking that every contained resource is referenced needs reference resolution the + subset does not have. `dom-6` (a narrative SHOULD be + present, a `warning`) is not evaluated without a profile. A contained resource is checked only + through `dom-2` to `dom-5`; its own type's constraints and its elements' `ele-1` / `ext-1` are not. + Any other resource type draws none of these. + +And Quantity / UCUM fidelity: read a measured value by the type it actually is, and its unit by the +UCUM **`code`** a machine may act on (never the display string, and **never converted**): + +```ts +import { parseResource, readObservationValue, validateResource } from "@cosyte/fhir"; + +// value[x] is an 11-way choice: a non-numeric result is never read as a number. +const { resource: titer } = parseResource( + '{"resourceType":"Observation","status":"final","valueString":"POSITIVE"}', +); +const v = readObservationValue(titer); +v?.type; // → "String" (NOT "Quantity") +v?.quantity; // → undefined (no number is fabricated) + +// A vital sign's unit is checked on the UCUM code, case- and bracket-exact: "mmHg" is not "mm[Hg]". +const { resource: bp } = parseResource( + '{"resourceType":"Observation","status":"final",' + + '"category":[{"coding":[{"system":"http://terminology.hl7.org/CodeSystem/observation-category","code":"vital-signs"}]}],' + + '"code":{"coding":[{"system":"http://loinc.org","code":"8480-6"}]},' + + '"valueQuantity":{"value":120,"unit":"mmHg","system":"http://unitsofmeasure.org","code":"mmHg"}}', +); +validateResource(bp).issues.map((i) => i.code); // → ["VITAL_SIGN_UNIT_NONCONFORMANT"] (should be "mm[Hg]") +``` + +- **`readObservationValue`** discriminates the 11 `value[x]` variants (`Quantity`, `CodeableConcept`, + `String`, `Boolean`, `Integer`, `Range`, `Ratio`, `SampledData`, `Time`, `DateTime`, `Period`) by + the one present. `quantity` is populated **only** for a `Quantity`. `readQuantity` keeps the coded + unit (`code`/`system`) distinct from the human `unit`; `validateUcumShape` checks a code's shape. +- **Vital-signs required-unit** conformance (`VITAL_SIGN_UNIT_NONCONFORMANT`, error) against the FHIR + profile's closed table, compared on the UCUM `code`; a UCUM-declared unit that is absent or malformed + is `UCUM_UNIT_UNRECOGNIZED` (warning, preserved verbatim); a vital sign whose value is not a Quantity + is `VALUE_TYPE_UNEXPECTED` (warning). +- **Dose `Quantity`** (`readMedicationDoses`) for MedicationRequest/Statement, and + `interpretation` / `referenceRange` surfaced (`readInterpretations` / `readReferenceRanges`): + **never** used to auto-convert a unit or compute an abnormal flag. + +And terminology binding validation, strength-aware and **content-free**: validate a coding's code +`system` and its binding **strength** without bundling any SNOMED / CPT / LOINC concept tables, and +never raise a false error when no terminology service is configured: + +```ts +import { parseResource, validateResource, type TerminologyService } from "@cosyte/fhir"; + +// AllergyIntolerance.code binds extensibly to a multi-system value set (RxNorm + SNOMED). An +// ICD-10-CM code is a KNOWN but unexpected system for this binding → a warning, never an error. +const { resource: allergy } = parseResource( + '{"resourceType":"AllergyIntolerance",' + + '"clinicalStatus":{"coding":[{"system":"http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical","code":"active"}]},' + + '"code":{"coding":[{"system":"http://hl7.org/fhir/sid/icd-10-cm","code":"T78.40XA"}]}}', +); +validateResource(allergy).issues.map((i) => `${i.code}/${i.severity}`); +// → ["RESOURCE_NOT_MODELED/information", "CODE_SYSTEM_UNEXPECTED/warning"] (valid stays true) + +// Value-set membership needs content the library does not bundle. Supply a terminology service. +const svc: TerminologyService = { + validateCode: ({ code }) => ({ membership: code === "7980" ? "in" : "not-in" }), +}; +validateResource(allergy, { terminology: svc }); // now membership is checked against your service +``` + +- **Frozen known-systems registry** (`KNOWN_SYSTEMS`, `isKnownSystem`): the verified `system` + URIs (LOINC, SNOMED, RxNorm, ICD-10-CM/9-CM, CPT, UCUM, NDC, CVX) as **identities, not content**. + An unrecognized system is `CODE_SYSTEM_UNKNOWN` (`information`): not a defect, just unvalidatable. +- **Binding-strength severity:** `required` → error, `extensible` → error-unless, `preferred` → + warning, `example` → information (an example binding **never** errors). A known system outside a + binding's value set is `CODE_SYSTEM_UNEXPECTED` (strength-scaled); a service's definitive `not-in` + is `CODE_NOT_IN_VALUESET`. Built-in **multi-system** bindings: allergy substance (RxNorm + SNOMED), + medication (RxNorm). +- **Pluggable terminology service** (`TerminologyService`): the one seam for value-set content, and + **none is bundled** (licensing). With none supplied, checks degrade to the content-free system + level and **never false-error**; the service receives only identities, never a resource value. + +And StructureDefinition-driven **profile validation** (US Core the target): snapshot generation, +slicing, `fixed[x]` / `pattern[x]`, and **must-support as a system obligation**. Like the terminology +layer it ships the _engine_, not the _content_: you supply the profiles (the published US Core / +vendor `StructureDefinition`s), and **nothing is bundled**. + +```ts +import { loadStructureDefinition, parseResource, validateResource } from "@cosyte/fhir"; + +// Load a US Core profile (its published JSON) into a StructureDefinition. +const profile = loadStructureDefinition(parseResource(usCoreAllergyProfileJson).resource); + +const { resource: allergy } = parseResource( + '{"resourceType":"AllergyIntolerance",' + + '"clinicalStatus":{"coding":[{"code":"active"}]},' + + '"code":{"coding":[{"code":"227493005"}]},"patient":{"reference":"Patient/1"}}', +); + +// verificationStatus is must-support and absent → information, NEVER an error (the resource stays valid). +const { issues, valid } = validateResource(allergy, { profiles: profile ? [profile] : [] }); +valid; // → true +issues.map((i) => `${i.code}/${i.severity}`); // → ["MUST_SUPPORT_ABSENT/information", …] +``` + +- **Snapshot generation** (`generateSnapshot` / `snapshotElements`) walks `baseDefinition` and merges + the differential onto the base snapshot: tightening matched elements by id, inserting slices, and + failing closed (`FhirProfileError`) on an unresolvable base or a `baseDefinition` cycle. A caller + supplies the base via a resolver; a profile that already ships a snapshot is used as-is. +- **Slicing** matches each occurrence of a sliced element to a slice by its discriminators. The R4 + set is `value | exists | pattern | type | profile` (**`position` is R5-only** and excluded). What + needs a FHIRPath engine (`type` / `profile` discriminators, reslicing) is reported + `PROFILE_SLICE_UNCHECKED` (`information`): **never silently passed**. An unmatched occurrence under + `closed` slicing is `PROFILE_SLICE_UNMATCHED` (error); a missing required slice is `CARDINALITY_MIN`. +- **`fixed[x]` vs `pattern[x]`** (`matchesFixed` / `matchesPattern`): `fixed` is exact equality + (nothing extra), `pattern` is a subset (extras allowed); decimals compared precision-exactly, never + via a float. A mismatch is `PROFILE_FIXED_MISMATCH` / `PROFILE_PATTERN_MISMATCH` (error). +- **Must-support is a system obligation, not instance-presence**: an absent must-support element is + `MUST_SUPPORT_ABSENT` at **`information`, never an error**. A strict client that rejects an absent + must-support element is the classic interop bug this rule exists to prevent. +- **Multi-version**: a `meta.profile` `canonical|version` pin the supplied set carries at a different + version is `PROFILE_VERSION_MISMATCH` (warning) rather than a silent best-effort validation. +- **Invariants**: the profile's `constraint[]` (FHIRPath) are evaluated by a **bounded, vendored + FHIRPath engine** (`tokenize` / `parseFhirPath` / `evaluateInvariant`; no runtime + dependency). A violated constraint is `INVARIANT_VIOLATED` (severity mirroring its `error` | + `warning`); an expression outside the subset raises `UnsupportedFhirPathError` and is reported + `INVARIANT_UNCHECKED` (`information`): **surfaced, never assumed to pass**. The seven named safety + invariants stay owned by the always-on safety layer; the engine covers every other constraint. + The R4 base constraints are evaluated whether or not a profile is supplied (above), and a profile + whose snapshot repeats one is reported once per occurrence, not twice. `collectInvariantIssues`, + called directly, still reports every constraint the profile carries, base ones included. +- **Deferred:** the bundled multi-version US Core IG corpus and the `validator_cli.jar` differential + (a JVM dev/CI job); the `type` / `profile` slicing discriminators and reslicing (still + `PROFILE_SLICE_UNCHECKED`: a genuine fail-safe deferral, they need per-occurrence type carriage / + recursive profile resolution). Every finding is **value-free** (a code + a FHIRPath location, + never the value). + +```ts +import { evaluateInvariant, parseResource } from "@cosyte/fhir"; + +// The bounded FHIRPath engine, judged by the reference validator's boolean coercion. +const { resource } = parseResource( + '{"resourceType":"Observation","valueString":"x","dataAbsentReason":{"text":"n"}}', +); +evaluateInvariant("dataAbsentReason.empty() or value.empty()", resource, resource); +// → { unchecked: false, satisfied: false } (obs-6 violated: value AND dataAbsentReason both present) + +evaluateInvariant("descendants().count() > 0", resource, resource); +// → { unchecked: true, satisfied: false } (descendants() is outside the subset, never a false pass) +``` + +**Authoring a profile in code: `defineProfile()`.** You don't have to hand-write +`StructureDefinition` JSON. `defineProfile(spec)` builds one from an ergonomic spec and returns the +**same model** `loadStructureDefinition` produces, so it flows straight into +`validateResource({ profiles })`. There is **one authoring path, no privileged internal shape**: the +built-in starter profiles are `defineProfile()` calls, exactly what you write. As a conservative +writer it throws a value-free `InvalidProfileError` on an author mistake (a missing `url` / `type` / +element `path`, a bad cardinality, a `max` below `min`). + +```ts +import { defineProfile, parseResource, primitive, validateResource } from "@cosyte/fhir"; + +const finalOnly = defineProfile({ + url: "https://example.org/StructureDefinition/final-observation", + type: "Observation", + differential: [ + { path: "Observation.status", fixed: { type: "Code", value: primitive("final") } }, + ], +}); + +const { resource } = parseResource('{"resourceType":"Observation","status":"preliminary"}'); +validateResource(resource, { profiles: [finalOnly] }).issues.map((i) => i.code); +// → ["PROFILE_FIXED_MISMATCH", …] +``` + +A publishable **profile starter kit** ships as worked examples / templates you extend: +`VITAL_SIGN_OBSERVATION_STARTER` (required `status`, must-support `code`, and a **sliced** `category`: +a required `VSCat` slice pins the `vital-signs` coding while the open slicing still allows other +categories, the way the real profile does) and `PATIENT_IDENTIFIER_STARTER` (`identifier` / `.system` +/ `.value` required + must-support, deliberately **no** MRN slice), plus `STARTER_PROFILES`, +`starterProfile(url)`, and `STARTER_PROFILE_BASE_URL`. Each is grounded in a public FHIR / US Core +spec page, self-contained (differential-only, no bundled base), and clearly a template, **not** an +authoritative vendor conformance statement. + +```ts +import { STARTER_PROFILES, parseResource, validateResource } from "@cosyte/fhir"; + +const { resource } = parseResource(vitalSignObservationJson); +validateResource(resource, { profiles: [...STARTER_PROFILES] }); +``` + +- **Real-world quirk corpus + differential.** Five quirk fixtures + (`test/__fixtures__/quirk-*.json`), each **grounded in a public artifact** and cited in + `test/quirk-corpus.test.ts`: a non-first `resourceType` (json.html), a scientific-notation decimal + preserved byte-exact (Synthea #675), a primitive-extension `_`-sibling misalignment that **fails + closed** (HAPI #5738), a searchset Bundle `link[next]` that survives the round-trip + (bundle-example.json), and US Core race + birthsex extensions preserved on a base Patient. The + `validator_cli.jar` differential (CI-only) runs over this corpus too. **Values are synthetic;** a + genuinely vendor-**proprietary** deviation absent from every public sample stays grounded-only. It is + never invented. Missing-must-support and version-drift quirks are covered by the profile suite. + +### XML codec and cross-format equivalence + +A **zero-dependency** FHIR XML codec that reads and writes the **same schema-free model** as the JSON +codec, so a resource is equivalent whichever wire format it arrived in. The hand-written reader is +**XXE- and billion-laughs-proof by refusal**: it refuses any `…
`, never dropped). Lenient: an + unexpected namespace or stray text draws `UNEXPECTED_XML_CONTENT` and the document is never rejected. + **Lenient is not lossless there:** an element in another namespace is modeled and flagged, but + character data written directly on a FHIR element is dropped and flagged, because a FHIR element + carries its value in the `value` attribute and the model has no slot for text. + **Names are namespace-resolved, so a prefix is a spelling and not part of the name.** FHIR XML is + defined in the `http://hl7.org/fhir` namespace, and a document may bind that namespace to a prefix + instead of making it the default, so `` and + `` are the same resource and read to the same model. The + in-scope declarations are tracked as the reader descends, including a prefix rebound partway down. + A prefix nothing in scope binds is not resolvable, so the tag is kept exactly as written and + flagged rather than guessed at. **The narrative `
` is the one element FHIR requires in a + namespace other than its parent's, so it is recognised by its expanded name + (`{http://www.w3.org/1999/xhtml}div`) under every spelling**, and is not flagged for being there. + It is carried as an opaque string together with the namespace declarations it inherited from its + ancestors **and uses**, so the fragment stands on its own; the document's own spelling is preserved + rather than rewritten, which makes a prefixed narrative namespace-equivalent to the default + spelling and not byte-identical to it. A `div` in another namespace is kept out of `Narrative.div` + only where its tag carries a **prefix**: an unprefixed `
` is spelled exactly + like the FHIR one, so it reaches that slot and is reported, not separated. **The narrative is + recognised before a resource-valued element is unwrapped**, because the content of `Narrative.div` + is XHTML and the unwrap's UpperCamelCase test is a way of spelling a FHIR resource type: applied + inside a narrative it read `
Take 5 mg
` as a contained `BR` resource + and destroyed the prose, and HTML-4-era generators do emit `
`, `
`, `

`. Nothing is + shadowed by that order: `div` names exactly one element in R4. Reading the narrative + means its contents are no longer modeled as FHIR, so a narrative spelled with a prefix, or holding + a capitalized child, reads as the same document written the other way reads, including where that + is quieter. The one way it reads + differently is **louder**: a document holding the narrative under both spellings at once is one + element written twice, so it draws `MIXED_XML_SPELLING`, which the all-default twin does not. + Every element the reader **models** is tested once for being in a namespace other than its parent's, + and reported when it is. A **prefixed** one additionally keeps its tag, and since no FHIR element + is spelled `v:code`, that is what keeps it out of the FHIR element beside it. Content reached by a + **default** declaration (``) is spelled exactly like the FHIR element, + so it is modeled as one and reported rather than separated. A child element written beside a + `value` attribute is not modeled at all: it is discarded and reported `UNKNOWN_PROPERTY`, so a + foreign one there draws no namespace report. + Two prefixes bound to the same namespace are two spellings of one name, so an element written twice + that way reads as the repeat it is. The model matches the same document spelled one way; only the + number of occurrences differs, so that element carries `MIXED_XML_SPELLING`. Nothing is lost, but a + check that reads a `0..1` element as a single value gets nothing from a repeat, and that should + never be silent. **That report compares the expanded name, not the tag alone**, so it also covers + the merges where the tag is the same and the namespace is not. Two of those are worth naming + because a document can reach them while otherwise reading as conformant: a prefix rebound between + siblings (`` beside ``), and a `

` in the FHIR + namespace landing in `Narrative.div` beside the real XHTML narrative, which is the one that costs + the most because `Narrative.div` is `0..1`. A **foreign** element reached by a **default** `xmlns` + re-declaration groups with its FHIR namesake the same way, and there the group already carried + `UNEXPECTED_XML_CONTENT`. A FHIR-namespace one carries no such flag, which is exactly why this + report is the one that covers the narrative case. +- **`serializeResourceXml`** emits compact FHIR XML that round-trips a spec-clean document + **byte-for-byte** (decimals byte-exact, never through a `number`). **Its output is not + _unconditionally_ spec-clean**: a tag name that is not an XML 1.0 `Name` and a character outside + XML 1.0 `Char` are refused rather than written (below), but `` and a name inside a `div` + string that is not a `Name` are still written, + and the byte-for-byte claim is scoped to a spec-clean input (a `
x
` carrying no XHTML namespace + comes back as `
x
`, the FHIR namespace rather than the XHTML + one the conformant repair would use). It throws `FhirSerializeError` + rather than emit a model the reader marked as having lost character data, so that finding cannot + vanish across a round trip; `serializeResource` refuses the same models for the same reason. Text + the reader drops **without** marking (whitespace only) is not covered, because there is no marker. +- **A `div` property is written back as raw markup, and that markup is checked at the branch that + writes it** (`UNSERIALIZABLE_DIV_MARKUP`). `Narrative.div` is carried as an opaque XHTML string and + emitted verbatim, so whatever the string spells becomes markup in the output. It is written only + when it parses as exactly one element whose local name is `div`; anything else is refused, because + a string that closes its own element and opens siblings puts elements into the document that the + sender never wrote. The shape that decided it: a `div` on an `AllergyIntolerance` spelled + `
ok
…716186003…` used to emit + spec-clean FHIR XML that re-read with `noKnownAllergy: true` and a `no-known-allergy` negation over + a record that had asserted nothing, with no diagnostic at either end. **Well-formedness alone is + not the line**: `` is one well-formed element, and writing it for a property + named `div` authors a status. `serializeResource` carries the string as a string and is the route + that stays open. **A string that passes is asked one more question, on a code of its own** + (`UNSERIALIZABLE_DIV_PREFIX`): whether every namespace prefix its markup names, on an element or on + an attribute, is bound by a declaration inside the string. `x` used to be written + with nothing binding `v`, which a conformant parser rejects, and this library re-read it as a + property named `v:div` rather than as the narrative. It is refused now, and so is an unbound prefix + on an inner element or an attribute, which withdraws an XML write from models that read + `valid: true`, the cost the colon refusal below already pays. The `xml` prefix is bound by + definition, and a prefix a conformant document bound on an ancestor of the `div` is not refused, + because the reader writes that declaration into the string it hands back. Passing both checks is + still not a claim that the round trip is lossless from there. +- **A shape only FHIR JSON can spell is refused rather than emitted as an empty element** + (`UNSERIALIZABLE_JSON_ONLY_SHAPE`). The JSON reader marks four positions FHIR JSON gives no meaning + to and keeps what the sender wrote there, so `serializeResource` hands it back and re-reading the + output reproduces the finding: an array inside an array, a scalar or `null` where FHIR JSON has an + object, that same shape in a primitive's `_`-sibling, and a `null` in a primitive's value channel + that padded nothing. **XML has none of those channels**: no array of arrays, no `_`-sibling (a + primitive's metadata is co-located as an `id` attribute and child `` elements), and no + `null` at all. So this writer used to emit the node the reader was left holding, and that output + re-reads with an empty issue list, and three of the four also `valid: true`. `{"value":null,"unit":"mg"}` came back through XML as a `Quantity` carrying a unit + and **no magnitude**, with an empty issue list; `{"name":[[{"family":"Roe"}]]}` came back with the + name gone and `safeToSummarize` flipped from `false` to `true`. Refusing, because there is nothing + to hand back into and inventing an XML spelling would author markup nobody wrote. **Only a model + read from JSON reaches it**: XML cannot write any of those shapes, so a document read from XML + carries no marker and nothing that round-trips today stops. `serializeResource` writes all four + back at every position that writer walks, and the re-read reproduces the finding. **It does not + walk a member a repeated property name shadowed, and this refusal does reach one**, so that is the + refusal's own limit rather than a route the shape always survives; `DUPLICATE_PROPERTY` and the + safety refusal are what carry such a document, which is refused outright now rather than + narrowed. **Value-exact, not byte-exact**, which is the same + limit the preserved text carries everywhere else in this README: + `{"performer":[{"reference":"Practitioner/1"},"Practitioner\/2"]}` comes back with the second + member spelled `"Practitioner/2"`, the same string in different bytes. Only the value-channel + `null` family is byte-identical, because a `null` has no escaping to lose. That the route stays + open is a statement about these shapes, not about the whole model. **Not** closed by it, and pinned rather than implied: + a JSON decimal comes back from XML as a string because XML carries no JSON type. +- **An array wrapper XML cannot spell back is refused rather than flattened away** + (`UNSERIALIZABLE_ARRAY_WRAPPER`). FHIR JSON writes a single-valued element as a name/value pair and + reserves the array for a repeating one (json.html §2.6.2.2), so `{"status":["entered-in-error"]}` is + a shape the spec does not define: it is reported as an error-severity `ARRAY_WRAPPED_SCALAR` and + `safeToSummarize` is `false` over it, because a single-value read finds no string in it at all. FHIR + XML spells a repeat by **repeating the element** and carries no other mark for one, so a wrapper of + fewer than two items used to emit at most one element and re-read as an ordinary single-valued + element: that document came back with `arrayWrappedScalars: []`, `safeToSummarize: true`, + `valid: true` and an empty issue list, and a wrapped `resourceType` came back as ``, the + type gate every type-scoped read stands behind gone with it. **A writer cannot decide cardinality + in general and this one does not + try**: there is no per-resource model here, and a name-only rule would emit a false error on a + conformant document (`Questionnaire.code` and `ElementDefinition.code` are `0..*` in R4). So this + takes its cardinality from the one window that already has one, the locations + `arrayWrappedScalars` reports, and inside it refuses the wrappers XML cannot write back as a + wrapper: **fewer than two items**, plus **any** wrapper on `resourceType`, where the type is the tag + and a tag cannot be repeated. **A wrapper of two or more items elsewhere is deliberately left + alone**: it is written as repeated elements, the re-read groups them into a list and the location is + reported again, so refusing it would withdraw a round trip that works today _and_ keeps the finding. + That is a statement about which wrappers this refuses, not a claim that every wrapper it lets + through survives. `serializeResource` writes the wrapper back and is the route that stays open. + **Not** closed by it: a wrapper that only a **shadowed** member carried is the repeated-property-name + case rather than this one, and both writers now refuse it (on whichever of the two codes is raised + first, which is this one where the wrapper is itself unspellable); and the window does not reach + `Observation.value[x]`, a `0..1` choice, which has a refusal of its own on a cardinality of its own + (below) rather than a wider window on this one. +- **An array wrapper around an `Observation.value[x]` choice is reported and refused** + (`ARRAY_WRAPPED_CHOICE` on the read, `UNSERIALIZABLE_CHOICE_WRAPPER` on the XML write). The same + generic-converter shape as the bullet above, at the position where the number is a **dose**. + `{"resourceType":"Observation","status":"final","valueQuantity":[{"value":5,"system":"http://unitsofmeasure.org","code":"mg"}]}` + used to read with the variant reported and no magnitude handed out, and nothing said the encoding + had been ambiguous, so one write and one re-read produced an unambiguous 5 mg under `valid: true`. + Now `readObservationValue` carries the code on `ObservationValue.encodingIssue`, `validateResource` + raises the same code at the same location at error severity, and the XML writer refuses. **The + cardinality is the value readout's own**, not a wider table: it is scoped to `Observation.value[x]` + and `Observation.component.value[x]`, both `0..1` in R4 (observation.html), at every Observation + resource root including a `contained` or `Bundle.entry` one, so a conformant document cannot draw + it and the safety layer's own wrapper window is untouched. **A wrapper of two or more items is + deliberately left alone** for the same reason as above: it writes as repeated elements, the re-read + groups them into a list and the report is raised again, byte-exact. **Nothing is read out of the + wrapper**: picking a member would author a magnitude the sender spelled ambiguously. + `serializeResource` writes the wrapper back and is the route that stays open. +- **A member a repeated property name shadowed is refused, by BOTH writers** + (`UNSERIALIZABLE_SHADOWED_PROPERTY`). The reader keeps it, validation raises an error over it and + `safeToSummarize` is `false`: **all three about the input**. Each writer walks the surviving + members only, so `{"resourceType":"Observation","status":"final","status":"entered-in-error"}` + used to come back as `{"resourceType":"Observation","status":"final"}` and + ``: both re-read with + an empty issue list, `valid` and `safeToSummarize` both `false → true`, and **the retraction in + neither output**. Which member is lost depends only on the order the sender wrote them in. + **Handing both back is not the alternative it looks like**: `JSON.parse` resolves a repeated name + last-wins where this library reads first-wins, so emitting both members hands every other consumer + the member this one calls shadowed. XML can repeat an element, but two repeated elements re-read as + a **list**, a repeating element nobody wrote. The window is `shadowedProperties`, the same call + validation raises its error from, so a model refused here already reads `valid: false`. **Not** + closed by it, and measured rather than implied: a repeated name inside a primitive's `_element` + metadata is not modeled at all, and one inside a complex in a primitive's `extension` is still + dropped by both writers, and that document reads `valid: true`, so refusing it would withdraw a round + trip from a model this library reports as clean. +- **A `resourceType` with no string in it is refused rather than deleted and the tag substituted** + (`UNSERIALIZABLE_RESOURCE_TYPE`). FHIR XML has no `resourceType` element: the type IS the tag + (xml.html). So this writer skips that property at every element it walks and takes the tag from the + property's string value, and where there is no string to take the root used to fall back to + `Resource`. `{"resourceType":{"modifierExtension":[{"url":"http://example.org/x"}]},"status":"final"}` + reads `RESOURCE_TYPE_UNKNOWN` at error severity with `valid: false`, and `safeToSummarize: false` + for the unhandled modifier extension the type gate carries; it came back as + ``, which re-reads with an + empty issue list, `valid` and `safeToSummarize` both `true`, and no modifier extension anywhere. The + property is gone from the output and the element claims a type nobody wrote. **Neither repair is + available**: writing `` is what launders, and it authors the type gate every type-scoped + safety read runs behind; coercing the value to a string authors a different type out of content the + sender wrote at another shape. **The predicate reads the FIRST `resourceType` an element wrote**, + because that is the one the writer names the tag from. **Two shapes are deliberately left**: an + element that wrote **no** `resourceType` is untouched, because a typeless complex is named + `Resource` by documented fallback and nothing is deleted there; and an element whose **first** one + IS a string keeps its tag, so the substitution never happens and what drops there is the + repeated-property-name case. The + bound is structural rather than a verdict: at the root this costs a round trip only for a model + already `valid: false`, but deeper no layer checks a nested element's type and a document read from + XML reaches it, so what is withdrawn at every refused location is a **deletion** rather than a round + trip. `serializeResource` emits a non-string `resourceType` through its ordinary path and is the + route that stays open. **Not** closed by it: a JSON decimal still comes back from XML as a string, + and `Observation.value[x]` is still outside the array-wrapper window. +- **A name carrying a colon is refused rather than written with its prefix unbound** + (`UNSERIALIZABLE_PREFIXED_NAME`). XML reads a colon in an element name as a namespace prefix, and + Namespaces in XML 1.0 requires every prefix other than `xml` and `xmlns` to be declared. The model + carries no namespace bindings, so this writer has nothing to declare one with, and it used to write + the name verbatim: ``, `<:x/>`, ``, ``, and a root read as + `` written back as ``. A conformant parser + rejects every one of those documents. This library's own re-read accepted them, and lost something + on the way: a prefix rebound between siblings (`` beside + ``) reads with `MIXED_XML_SPELLING`, and after one write and one re-read that + report was gone. The check runs at every tag position at every depth, including a resource + composed into `contained` or `Bundle.entry.resource` after it was read. **One exemption**: `xml:` + followed by a local part with no colon in it is written, because the `xml` prefix is bound by + definition; `xmlns:x` is refused. **It withdraws an XML write from documents that read + `valid: true`**, which the foreign-root refusal already does: `{"resourceType":"Patient","p:x":"v"}` + reads with an empty issue list, validates `valid: true`, and is refused. Declaring a binding instead would author a namespace the sender + never sent, and for a root whose prefix nothing bound there is no namespace to declare at all. + Every earlier refusal is raised first, so a name that both carries a colon and breaks the tag stays + `UNSERIALIZABLE_ELEMENT_NAME`. The read is unchanged, and `serializeResource` writes these names + as JSON strings and is the route that stays open. **Not** closed by it: a name with no colon that is + not an XML 1.0 `Name` (`a&b`, `1abc`) is refused on a code of its own, below, not this one, and the + local part after `xml:` is not checked, so `` is still written. A `div` string whose own + markup carries an unbound prefix never reaches this check, and is refused by the `div` branch on + `UNSERIALIZABLE_DIV_PREFIX` instead, as above. +- **A tag name that is not an XML 1.0 `Name`, and a character outside XML 1.0 `Char`, are refused + rather than written** (`UNSERIALIZABLE_XML_NAME`, `UNSERIALIZABLE_XML_CHARACTER`). The line is the + XML 1.0 grammar a conforming processor applies, not this library's own round trip: ``, + `<1abc/>`, `<-x/>` and a tag carrying U+0000 all re-read here unchanged and are all rejected by a + third-party parser, and a U+0000 in a string value used to be written raw into its `value` + attribute. The name check runs at every tag position at every depth (a property name, a name inside + `contained`, `Bundle.entry.resource` or an extension, and a `resourceType` naming a root or a + nested resource), after the tag-breaking and colon questions, so a name failing one of those keeps + its code. The character check runs on every value written as an attribute (a primitive's `value`, an + `id` written as an attribute, an `Extension.url`) and on every `div` string that passes both `div` + checks, where a numeric character reference to a non-`Char` (`�`) counts as the raw character + does and a reference inside a comment, which is never decoded, does not. **Refused, never + repaired**: no name is mangled, and no character is escaped into a reference (a reference to a + non-`Char` is itself a fatal error), replaced or dropped. The name code is raised after every + earlier refusal and the character code last of all, so no model that drew a code before moves off + it, and a model carrying both draws the name code. The message and the locations carry no name, no + value and no character; a refused name's own segment reads ``. **It withdraws an XML + write from documents that read `valid: true`**: `{"resourceType":"Patient","1abc":"v"}` reads with + an empty issue list and is refused. The discouraged code points `Char` admits (U+007F to U+009F, + U+FDD0 to U+FDEF) are written. `serializeResource` is unchanged and is the route that stays open. + **Not** closed by it: `` is a `Name` that is not namespace-well-formed and is still + written; an element or attribute name inside a `div` string is checked for `Char` and never for + `Name`, so `
<1p>x
` is still written; the reader still reads `<1abc>` + and `` back; and `validateResource` still returns `valid: true` for a `string` carrying + U+0000. +- **`nodesEquivalent`** is the JSON↔XML equivalence oracle, equal _modulo_ the two irreducible + schema-free ambiguities and only those: primitive lexical form (JSON `true`/number tokens ≡ XML + `value`-attribute strings) and singleton lists (an array-of-one ≡ a single repeated element). + +```ts +import { + parseResource, + parseResourceXml, + serializeResource, + serializeResourceXml, + nodesEquivalent, +} from "@cosyte/fhir"; + +const xml = + '' + + ''; + +const fromXml = parseResourceXml(xml).resource; +const fromJson = parseResource( + '{"resourceType":"Patient","active":true,"name":[{"given":["Jane"]}]}', +).resource; +nodesEquivalent(fromXml, fromJson); // true: equivalent, not identical (see the two moduli above) +serializeResourceXml(fromXml) === xml; // true: spec-clean round-trip + +// Equivalent is not identical, and re-serializing to JSON shows both moduli at once: `active` is +// the string "true" (XML carries every primitive as attribute text and the reader is schema-free, +// so a decimal is likewise its exact text where JSON gives a FhirDecimal), and `name` / `given` are +// single nodes rather than arrays. Precision survives either way, and `readQuantity` reads a +// magnitude in either form, but the datatype validator reads a lexical boolean as a type mismatch. +serializeResource(fromXml); // → '{"resourceType":"Patient","active":"true","name":{"given":"Jane"}}' + +// The reader refuses an XXE / entity-expansion attack loudly, never resolving or expanding it: +parseResourceXml(' ]>'); +// throws FhirXmlError { code: "DTD_FORBIDDEN" } +``` + +### Bundles, references, and Bulk NDJSON streaming + +Read a `Bundle` into an explicit readout with the one semantic distinction a consumer must never blur +(**`transaction` is all-or-nothing, `batch` is independent**), resolve the references inside it with a +**DoS-safe cycle guard**, and stream a Bulk Data `$export` line by line with **per-line error isolation +and no whole-file load**. The Bundle _artifact_ and its semantics are modeled; a transaction is **never +executed** (there is no server here). + +- **`readBundle` / `entryProcessing` / `isAtomicBundle`**: the `Bundle.type` (`BUNDLE_TYPES`) and its + entry-processing contract: `transaction` → `"atomic"` (all-or-nothing), `batch` → `"independent"`, + everything else → `"none"`. `Bundle.total` is a lexical string, never a JS `number`. +- **`resolveReference` / `buildBundleIndex` / `containedIndex`**: resolve relative / absolute / + logical / `#fragment` references against a Bundle + `contained` closure. A local miss is + `"unresolved"` (flagged, preserved); an external target is `"external"` (never false-flagged). +- **`hasContainedCycle` / `MAX_REFERENCE_DEPTH`**, a bounded, iterative (heap-based) cycle guard: a + `contained` reference cycle is **detected and reported, never followed**: no infinite loop, no stack + blow-up, no false positive on a legitimate DAG. +- **`streamNdjson` / `parseNdjsonLine`**: a streaming `application/fhir+ndjson` reader over any chunk + iterable, one resource per line, each read through the precision-preserving codec (a decimal never + through a `number`). A malformed line is isolated (reported by **line number, never content**), the + stream continues, and memory stays bounded (`LINE_TOO_LONG`). +- **New findings** (in `validateResource` for a `Bundle`): `REFERENCE_UNRESOLVED` (warning, preserved), + `CONTAINED_CYCLE` (error), `FULLURL_ID_MISMATCH` (error: a `urn:uuid` fullUrl is exempt). All + value-free (a FHIRPath location, never a value, reference, or id). + +```ts +import { parseResource, readBundle, validateResource, streamNdjson } from "@cosyte/fhir"; + +const { resource } = parseResource( + '{"resourceType":"Bundle","type":"transaction","entry":[' + + '{"fullUrl":"urn:uuid:1","resource":{"resourceType":"Patient","id":"1"},' + + '"request":{"method":"POST","url":"Patient"}}]}', +); + +readBundle(resource).atomic; // true: a transaction is all-or-nothing (a batch would be false) + +// A contained reference cycle is a bounded, typed finding, never an infinite loop: +const { issues } = validateResource( + parseResource( + '{"resourceType":"Bundle","type":"collection","entry":[{"resource":' + + '{"resourceType":"Observation","id":"o","contained":[' + + '{"resourceType":"Observation","id":"a","hasMember":[{"reference":"#b"}]},' + + '{"resourceType":"Observation","id":"b","hasMember":[{"reference":"#a"}]}]}}]}', + ).resource, +); +issues.some((i) => i.code === "CONTAINED_CYCLE"); // true + +// Stream a Bulk NDJSON export without loading the file; a bad line is isolated, not fatal: +for await (const record of streamNdjson(readableChunks)) { + if (record.error) + console.warn("bad line", record.error.line); // line number, never content + else handle(record.resource); +} +``` + +## What this will be + +FHIR is HL7's modern, resource-oriented interoperability standard, the format behind the US +regulatory push (ONC HTI-1 binds §170.315(g)(10) to **FHIR R4 + US Core + SMART on FHIR**). +`@cosyte/fhir` is the FHIR member of the cosyte parser family: a small, zero-runtime-dependency +TypeScript library that reads and writes FHIR, models its resources with correct primitive +semantics, and validates against structural rules and US Core profiles, mirroring the API shape of +[`@cosyte/hl7`](https://github.com/cosyte/hl7), the reference parser. + +## Architecture decisions + +The decisions that shape everything downstream: + +- `decimal` / `integer64` are **string-backed** and preserve lexical precision. `0.010` is never + silently normalized to `0.01`, and these primitives never round-trip through JS `number`. +- **FHIRPath**: a bounded, vendored subset in-repo, no runtime dependency, no full third-party + engine. +- **R4-first** (`4.0.1`), the US regulatory anchor. R5 and DSTU2 are **read-tolerance only**. + +## Tech stack + +Inherited from the shared `@cosyte/*` standard, by depending on the published `@cosyte/*` config +packages, not by copying files: + +- **TypeScript** (strict) via `@cosyte/tsconfig`, target **ES2023**, `NodeNext`. +- **Dual ESM + CJS + `.d.ts`** build via `tsup` (`@cosyte/tsup-config`); `attw` is a publish gate, + run through `scripts/attw.mjs`. The wrapper is there because the `attw` CLI prints "This package + does not contain types." and then exits **0**, so a tarball that lost its declarations passed the + gate. It checks that every artifact path `package.json` promises exists before the run, and + treats an untyped report afterwards as a failure. +- **Node >= 22**; package manager **pnpm 10**. +- **ESLint 10** (`@cosyte/eslint-config`) + **Prettier** (`@cosyte/prettier-config`), lint at + `--max-warnings=0`. +- **Vitest 4** + v8 coverage (`@cosyte/vitest-config`). +- **Zero runtime dependencies.** +- **License:** MIT. diff --git a/package.json b/package.json index ec147ce..6e8ab9e 100644 --- a/package.json +++ b/package.json @@ -57,10 +57,11 @@ "./package.json": "./package.json" }, "files": [ - "dist", - "README.md", - "LICENSE", - "CHANGELOG.md" + "dist/index.mjs", + "dist/index.cjs", + "dist/index.d.ts", + "dist/index.d.cts", + "LICENSE" ], "sideEffects": false, "publishConfig": { diff --git a/scripts/check-no-internal-refs.sh b/scripts/check-no-internal-refs.sh index 50cd4ac..80ab877 100755 --- a/scripts/check-no-internal-refs.sh +++ b/scripts/check-no-internal-refs.sh @@ -839,6 +839,14 @@ done # standing exclusions are named with their reasons in SCAN SURFACE above: `CHANGELOG.md` # (contested, queued) and `dist` (untracked build output this script cannot read; its # SOURCE is gated by the third pass instead). +# +# The `dist` exclusion is spelled BOTH ways on purpose. `files` names the four build +# artifacts one by one instead of the `dist` directory, so that the two sourcemaps stay out +# of the tarball. Those four paths are the SAME untracked build output the `dist` entry +# always stood for, excluded for the identical reason and gated at the same place: their +# source, by the third pass. Naming them changes what npm packs, and nothing about what this +# gate reads. Should `files` go back to naming the directory, the `dist` entry is still here +# and still works. Any other path under `dist/` is still unknown and still refused. command -v node >/dev/null || { echo "ERROR: check-no-internal-refs - node is required (to read package.json) and is not" >&2 echo " on PATH. Refusing to skip the npm-surface half of this gate." >&2 @@ -847,8 +855,18 @@ command -v node >/dev/null || { UNKNOWN_TARBALL_DOCS=$(node -e ' const pkg = JSON.parse(require("fs").readFileSync("package.json", "utf8")); // Scanned by this gate: README.md, LICENSE - // Excluded deliberately, reasons in SCAN SURFACE: CHANGELOG.md, dist - const known = new Set(["README.md", "LICENSE", "CHANGELOG.md", "dist"]); + // Excluded deliberately, reasons in SCAN SURFACE: CHANGELOG.md, dist (as the directory, + // or as the four build artifacts named one by one; same untracked output either way) + const known = new Set([ + "README.md", + "LICENSE", + "CHANGELOG.md", + "dist", + "dist/index.mjs", + "dist/index.cjs", + "dist/index.d.ts", + "dist/index.d.cts", + ]); process.stdout.write((pkg.files ?? []).filter((f) => !known.has(f)).join(" ")); ') if [ -n "$UNKNOWN_TARBALL_DOCS" ]; then