From 820265bde00fe1054f2c36c8c8fc52d4750999bc Mon Sep 17 00:00:00 2001 From: Noah Schatz Date: Fri, 25 Sep 2026 22:16:57 +0000 Subject: [PATCH] Ship the runtime files only, with a slim README, for the next publish npm refused @cosyte/fhir@0.1.0 on 2026-09-25 with a bare E403 on the staged publish (POST /-/stage/package/@cosyte%2ffhir, Release run 36193551694), exactly as it refused 0.0.11. Both carried the full tarball: a 99.6 KB README, the changelog and both sourcemaps, whose sourcesContent embeds the whole TypeScript source. The only accepted publish, 0.0.10, shipped `files` narrowed to the four runtime artifacts after npm support asked for a sanitized publish, and the recorded next experiment is that narrowed shape again now that the package exists. What changes: - `files` names dist/index.mjs, dist/index.cjs, dist/index.d.ts, dist/index.d.cts and LICENSE. npm adds README.md and package.json. The maps and CHANGELOG.md stay out: 7 files, 416.4 kB packed, 1.5 MB unpacked, the file list 0.0.10 shipped. attw is green in all four resolution modes. - README.md is a slim house-standard page instead of a stub, because the launch needs its quickstart to run from a clean install: why, status, install, the executed usage block, an API outline, a short PHI and safety section, and links to docs.cosyte.com/fhir and documentation/. It carries none of the XML-security vocabulary; the XML reader is described as accepting plain XML only. The full capability readout moves unchanged to documentation/capabilities.md, which does not ship. - check-no-internal-refs.sh derives its surface from `files` and refused the four dist paths, so they are recorded as the same deliberate exclusion the `dist` entry always was (identical to the 0.0.10 probe). Any other dist path, a sourcemap included, is still refused. - The changelog, CLAUDE.md and the publish-state notes record the 0.1.0 refusal and what the next attempt does and does not separate. No test was changed. The README quickstart suite still compiles and executes the first Usage block and asserts its claims, and the block runs with `node quickstart.mts` from a clean install of the packed tarball. No changeset: nothing requires one, and the pending 0.1.0 release is what the next Release run retries. Claude-Session: https://claude.ai/code/session_01U24bnHKNxQWMxmfN7ny7w9 --- CHANGELOG.md | 8 + CLAUDE.md | 12 +- README.md | 1303 +++-------------------------- documentation/agent-notes.md | 25 + documentation/capabilities.md | 1193 ++++++++++++++++++++++++++ package.json | 9 +- scripts/check-no-internal-refs.sh | 22 +- 7 files changed, 1371 insertions(+), 1201 deletions(-) create mode 100644 documentation/capabilities.md 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