diff --git a/README.md b/README.md index 4763a71..2d52a86 100644 --- a/README.md +++ b/README.md @@ -61,965 +61,25 @@ yet, and it **never converts a unit** or evaluates a reference range. Do not dep ## 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`, `shadowedProperties`, `arrayWrappedScalars`, - `nestedArrays`, `droppedText`, `unreadableBooleans`, `nearMissNegationCodes`, - `unreadableNegationCodes`, `absenceMarkers`, `unreadableAbsenceMarkers`, - `conflictingAbsenceMarkers`) and - `safeToSummarize` are document-wide.** 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` on a `Patient` root, and `use` on a `Practitioner`'s `identifier` entries. Each - sets `safeToSummarize` to `false` and makes `assertSafeToSummarize` throw, 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`. **Reporting only:** the element is - surfaced and never interpreted, so no bound, range or inequality is read out of a `comparator` and - no unit is ever converted. Recognition is by KEY NAME (and, for the two path-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. -- **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). - -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. -- **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. +The capability readout is long enough to read better on its own page. +[What the package does today](documentation/what-works-today.md) covers the no-data-loss +core, the first three validation layers, the safety and negation spine, Quantity and UCUM +fidelity, content-free terminology binding validation, StructureDefinition-driven profile +validation with its bounded FHIRPath invariant subset, the profile starter kit, and the +real-world quirk corpus and differential. ### 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 prefixed name is written with no declaration to bind it and a - non-conformant name verbatim, so ``, `` and `<1abc/>` are all emitted, 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. Passing the check is not a claim that the round trip is lossless from there: a - root whose prefix nothing binds is accepted and re-reads as a different property, the same - unbound-prefix gap named above for element names. -- **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 whose wrapper still launders. -- **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. -- **`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" } -``` +A zero-dependency FHIR XML codec 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. +[Read the XML codec readout](documentation/xml-codec-and-cross-format-equivalence.md). ### 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); -} -``` +`readBundle` with transaction as all-or-nothing and batch as independent, reference +resolution behind a DoS-safe cycle guard, and a `streamNdjson` reader with per-line error +isolation and no whole-file load. +[Read the Bundle and streaming readout](documentation/bundles-references-and-ndjson-streaming.md). ## What this will be diff --git a/documentation/bundles-references-and-ndjson-streaming.md b/documentation/bundles-references-and-ndjson-streaming.md new file mode 100644 index 0000000..ccf42a3 --- /dev/null +++ b/documentation/bundles-references-and-ndjson-streaming.md @@ -0,0 +1,56 @@ +# Bundles, references, and Bulk NDJSON streaming + +Part of the capability readout for `@cosyte/fhir`, linked from the README. + +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); +} +``` diff --git a/documentation/what-works-today.md b/documentation/what-works-today.md new file mode 100644 index 0000000..a5b46d8 --- /dev/null +++ b/documentation/what-works-today.md @@ -0,0 +1,694 @@ +# What works today + +The capability readout for `@cosyte/fhir`, linked from the README. + +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`, `shadowedProperties`, `arrayWrappedScalars`, + `nestedArrays`, `droppedText`, `unreadableBooleans`, `nearMissNegationCodes`, + `unreadableNegationCodes`, `absenceMarkers`, `unreadableAbsenceMarkers`, + `conflictingAbsenceMarkers`) and + `safeToSummarize` are document-wide.** 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` on a `Patient` root, and `use` on a `Practitioner`'s `identifier` entries. Each + sets `safeToSummarize` to `false` and makes `assertSafeToSummarize` throw, 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`. **Reporting only:** the element is + surfaced and never interpreted, so no bound, range or inequality is read out of a `comparator` and + no unit is ever converted. Recognition is by KEY NAME (and, for the two path-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. +- **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). + +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. +- **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. diff --git a/documentation/xml-codec-and-cross-format-equivalence.md b/documentation/xml-codec-and-cross-format-equivalence.md new file mode 100644 index 0000000..a8d0a1c --- /dev/null +++ b/documentation/xml-codec-and-cross-format-equivalence.md @@ -0,0 +1,215 @@ +# XML codec and cross-format equivalence + +Part of the capability readout for `@cosyte/fhir`, linked from the README. + +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 prefixed name is written with no declaration to bind it and a + non-conformant name verbatim, so ``, `` and `<1abc/>` are all emitted, 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. Passing the check is not a claim that the round trip is lossless from there: a + root whose prefix nothing binds is accepted and re-reads as a different property, the same + unbound-prefix gap named above for element names. +- **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 whose wrapper still launders. +- **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. +- **`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" } +```