openehr 0.6.0

openEHR Reference Model types, validation, paths, AQL parsing, and change-control security primitives
Documentation
# 3. Data types

Requirement prefix: `D3`.

The `DATA_VALUE` hierarchy is every leaf value a clinical record can hold. Two
themes run through this section: **precision is preserved**, and **comparison
refuses more often than it answers**.

## Where the quantity invariants come from

The rendered RM 1.1.0 Data Types page does not include the class-definition
tables — the specification sources `include::` them from a UML export that is
generated at build time and is not in the repository. The invariants in the
Quantity part of this section were therefore read from the **Release 1.0.2
publication** (*Data Types Information Model*, Rev 2.1.1, 20 Nov 2008,
§6.2.1–6.2.12), and the RM amendment record was then checked for every change
since:

| Release | Change affecting these classes |
| --- | --- |
| 1.0.3 | `SPECRM-32``DV_PROPORTION` gains the zero-denominator invariant |
| 1.1.0 | `SPECRM-19``DV_SCALE` added; `DV_ORDINAL.limits` removed |
| 1.1.0 | `SPECRM-65``units_system`, `units_display_name` added to `DV_QUANTITY` |
| 1.1.0 | `SPECRM-94`, `SPECRM-96` — arithmetic, comparison, negative durations |

None of them altered `precision`, `accuracy`, `magnitude_status`, or the
`DV_ORDERED` range invariants, so the 1.0.2 text governs them. `DV_SCALE`, new
in 1.1.0, is taken to inherit `DV_ORDERED`'s invariants unchanged — it is a
`DV_ORDERED` descendant and the amendment describes it as `DV_ORDINAL` with a
`Real` value. That last inference is the only one not read from a source.

## Coverage

- **D3.1** The crate MUST model every concrete `DATA_VALUE` descendant in
  openEHR RM 1.1.0: `DV_BOOLEAN`, `DV_STATE`, `DV_IDENTIFIER`, `DV_TEXT`,
  `DV_CODED_TEXT`, `DV_PARAGRAPH`, `DV_ORDINAL`, `DV_SCALE`, `DV_QUANTITY`,
  `DV_COUNT`, `DV_PROPORTION`, `DV_DATE`, `DV_TIME`, `DV_DATE_TIME`,
  `DV_DURATION`, `DV_MULTIMEDIA`, `DV_PARSABLE`, `DV_URI`, `DV_EHR_URI`,
  `DV_INTERVAL`, `DV_PERIODIC_TIME_SPECIFICATION`, and
  `DV_GENERAL_TIME_SPECIFICATION`.
- **D3.2** `DV_PARAGRAPH` MUST be readable and MUST NOT be recommended. openEHR
  deprecates it in favour of markdown-formatted `DV_TEXT`, and instances written
  before the deprecation must still round-trip.

## Text and terminology

- **D3.3** `DV_TEXT.value` MUST be non-empty. A field that is present and empty
  is indistinguishable from one that is absent, and openEHR already has a way to
  say absent.
- **D3.4** `DV_TEXT.formatting` MUST be non-empty when present, and a value of
  `plain_no_newlines` MUST be refused when `value` contains a newline.
- **D3.5** `CODE_PHRASE` MUST require a non-empty `code_string` and a
  well-formed `TERMINOLOGY_ID`, and MUST NOT check the code against any
  terminology other than openEHR's own (`S1.10`).
- **D3.6** `TERM_MAPPING.match` MUST be one of `>`, `=`, `<`, `?`, modelled as a
  closed set rather than a character.
- **D3.7** `DV_CODED_TEXT.value` is required by openEHR to be the rubric of
  `defining_code`. The crate MUST check this **only** for codes in the openEHR
  support terminology, and MUST report *not checked* as an outcome distinct from
  *valid*. Reporting an unchecked external code as valid would be a claim about
  a terminology the crate cannot see.
- **D3.8** Where a code appears in more than one openEHR terminology group with
  different rubrics, agreement with **any** group MUST be accepted: a bare
  `DV_CODED_TEXT` does not know which attribute it sits on.

## Dates, times, and durations

- **D3.9** Partial precision MUST be preserved. `2024`, `2024-05`, and
  `2024-05-17` are three different dates and MUST NOT be completed to one
  another. A completed date is a fabricated clinical fact that no downstream
  reader can distinguish from a real one.
- **D3.10** The lexical form MUST be preserved exactly, including the choice
  between `Z` and `+00:00`, and including the number of digits in a fractional
  second.
- **D3.11** Date, time, and date-time MUST be validated on construction:
  component ranges, month lengths, and the full Gregorian leap rule including
  the century exceptions.
- **D3.12** A leap second (`:60`) MUST be accepted. A record that captured one
  must not be rejected on read-back.
- **D3.13** `DV_DURATION` MUST accept openEHR's leading `-`, which plain
  ISO 8601 does not, and MUST refuse designators that are out of order or
  repeated.
- **D3.13a** The extended ISO 8601 format is required; the basic format
  (`20240517`) MUST be refused. It does not appear in openEHR canonical JSON,
  and accepting it would make `2024` ambiguous. Recorded as a limitation in
  [`audit.md`]audit.md.

## Comparison

- **D3.14** Comparison MUST be partial. Where an ordering is not established,
  the answer MUST be "not comparable", never a default. Specifically:
  - two dates or times whose known components agree but whose precisions differ,
  - a local time and one carrying a UTC offset,
  - two durations whose approximations agree and whose calendar components
    differ,
  - values of different `DATA_VALUE` classes.
- **D3.14a** *(added 2026-08-21)* `INTERVAL<T>::contains` MUST treat "not
  comparable" as **not contained**, at either bound and in either direction.

  `D3.14` says what comparison answers. This says what the one caller that
  matters does with that answer, and it was load-bearing and undocumented until
  `A-39` — implemented correctly, relied on by `DvOrdered::is_abnormal`, and
  written down nowhere.

  **Fail closed.** An undecidable comparison must never admit a value into a
  range: a reference range that silently included values it cannot order would
  report them as normal. The operators `>=` and `<=` do this by accident —
  `None` makes them false — which is why the rewrite in `D3.18c` had to say it
  on purpose rather than inherit it.

  The consequence runs one step further, and is why `A-39` is a **Medium**
  rather than a note about test coverage. `is_abnormal` asks
  `normal_range.contains(&value)`. If a comparison that should have decided
  returns "not comparable" — because a match arm was deleted, or a variant was
  added without one — the value is reported as **inside no range**, and
  therefore as **not abnormal**. A wrong answer that reads as reassurance.

- **D3.15** `DV_QUANTITY` values MUST be comparable only when their `units`
  strings are equal (`S1.9`).
- **D3.16** `DV_ORDINAL` and `DV_SCALE` values MUST be comparable only when
  their symbols come from the same terminology. Ordinal `2` on a pain scale and
  ordinal `2` on a sedation scale are unrelated.
- **D3.17** `DV_PROPORTION` values MUST be comparable only when their kinds
  match.
- **D3.18** Times carrying UTC offsets MUST be normalised to UTC before
  comparison.
- **D3.18a** *Resolved by design, not by compromise.* `Eq` on `Time`, `Date`,
  `DateTime` and `Duration` is **lexical** — two values are equal when they
  were written the same way — while chronological (or, for `Duration`, length)
  order compares what the value denotes under `D3.18`. So `11:00:00Z` and
  `12:00:00+01:00` order equal semantically and are **not** `==`.

  These types therefore do **not** implement `PartialOrd`/`Ord`. Rust requires
  `a == b` wherever `partial_cmp` reports `Some(Equal)`, and no fixed
  implementation of both traits together can satisfy that here — lexical
  equality is record identity (`db:M3.28`: the text is the stored value, `.5`
  and `.50` are different strings a record must round-trip, and `Hash` must
  agree with `Eq`), while semantic ordering is what a query needs, and neither
  moves without losing the other. Semantic comparison is instead a plain method,
  `semantic_cmp(&self, other: &Self) -> Option<Ordering>`, so `<`, `sort()`,
  and `dedup()` simply do not exist for these types — there is no operator
  that could silently mean the wrong one.

  Formerly recorded as the open finding `A-32`; closed once the trait impl
  was removed rather than left to disagree with `Eq`. Pinned by
  `iso8601::eq_is_lexical_and_semantic_cmp_is_not_the_same_question`.

- **D3.18b** *(added 2026-08-21)* `D3.18a` binds **every `DV_ORDERED`
  descendant and `DATA_VALUE`**, not only the base ISO 8601 types. None of them
  implements `PartialOrd`; semantic comparison is the named method
  `DvOrdered::semantic_cmp`, and `DataValue::semantic_cmp` for the enum.

  `D3.18a` was written about lexical form, because that is where the
  disagreement was first seen. **That is not the mechanism, only one instance of
  it.** Every `DV_ORDERED` carries `OrderedAttrs` — normal range, normal status,
  other reference ranges — and every one of them derives `PartialEq` over all
  its fields while comparing only its magnitude. So:

  | These two values | `==` | `partial_cmp` |
  | --- | --- | --- |
  | `DV_DATE_TIME` `11:00:00Z` and `12:00:00+01:00` | false | `Some(Equal)` |
  | `DV_QUANTITY` `5 mg` with `precision` 1 and with 2 | false | `Some(Equal)` |
  | `DV_QUANTITY` `5 mg` with and without `units_display_name` | false | `Some(Equal)` |
  | `DV_COUNT` `5` with and without a normal range | false | `Some(Equal)` |
  | `DV_PROPORTION` `1/4` with `precision` 1 and with 2 | false | `Some(Equal)` |

  Rust requires `a == b` if and only if `partial_cmp` reports `Some(Equal)`, so
  each row is a broken trait contract: `a != b` while `a <= b` and `a >= b` are
  both true. Nothing inside this crate depended on it — every comparison here
  goes through the ordering consistently — which is exactly why it survived. A
  caller's `binary_search`, `dedup_by`, `max_by`, or `sort_by` is where it
  surfaces, in someone else's code.

  Neither trait can move. Field equality is **record identity**: a
  `DV_QUANTITY` that records its precision is not the same stored value as one
  that does not, `db:M3.28` requires the text a record round-trips to be exactly
  what arrived, and a content digest is taken over those bytes (`db:M3.43`).
  Magnitude ordering is what a query and a reference range need. Making `==`
  semantic would make a canonicaliser that rewrote `1.10` as `1.1` pass its own
  round-trip test, which is `db:D-08` reintroduced.

  Formerly the open finding `A-35`, which named five types; the survey that
  closed it found **ten**.

- **D3.18c** *(added 2026-08-21)* `INTERVAL<T>` MUST NOT be bounded on
  `PartialOrd`. It is bounded on `SemanticOrd` — a trait this crate defines,
  whose single method is the same partial comparison — and implemented for the
  primitives an interval is used over and for every type in `D3.18b`.

  A blanket `impl<T: PartialOrd> SemanticOrd for T` is deliberately **not**
  written: it would collide with the explicit impls under Rust's coherence
  rules, and more to the point it would let a type that has the `D3.18b` defect
  reach `INTERVAL<T>` again without anyone deciding that it should.

## Quantities and proportions

- **D3.18d** *(added 2026-08-22)* A **real number** in the Reference Model MUST
  preserve its lexical form, as `D3.10` requires of ISO 8601. `1.50` is not
  `1.5`.

  **Every digit is preserved**, including trailing zeros and significant digits
  beyond what an `f64` can hold or distinguish. The one departure, measured
  rather than assumed: **exponent notation is normalised** to a lowercase `e`
  with an explicit sign, so `1e5` and `1E5` are both stored as `1e+5`. No digit
  is lost and the value is unchanged. A clinical magnitude is not written in
  scientific notation, but "not in practice" is not a guarantee, so the limit is
  written down and
  `real::tests::every_digit_survives_and_only_the_exponent_form_is_normalised`
  asserts it.

  The two are the same decision for the same reason. A `DV_DATE` of `2024-05` is
  a date known to the month, and a `DV_QUANTITY` of `1.50 mg` is a quantity
  measured to two decimal places — in both cases the number of digits *is* the
  clinical content, and a layer that normalises has destroyed it before storage
  sees it. `db:D-08` is that failure realised: MySQL rewrote a magnitude of
  `1.10` as `1.1`, and `db:M3.43` moved canonical JSON onto a byte-preserving
  column because of it.

  Until 2026-08-22 the crate lost the distinction one layer earlier than MySQL
  did. `DV_QUANTITY.magnitude` was an `f64`, so `1.50` became the same value as
  `1.5` at **parse** time and no storage rule could recover it.
  `security::canonical`'s own test recorded that as the limit of the guarantee.

- **D3.18e** *(added 2026-08-22)* A real MUST carry both forms: the text as
  written, and the `f64` it denotes. The text is **authoritative**; the `f64` is
  **derived** and is what comparison uses.

  This is `db:M3.31`'s two-column instant rule, in one value instead of two
  columns, and it produces the same split `D3.18a` and `D3.18b` describe:
  equality is lexical, because `1.50` and `1.5` are different records; ordering
  is numeric, because a reference range asks which is larger. The two disagree
  on values that denote the same number, so a real MUST NOT implement
  `PartialOrd``SemanticOrd` and `DvOrdered::semantic_cmp` carry the ordering
  (`D3.18c`).

- **D3.18f** *(added 2026-08-22)* Reading a real MUST be total. A value that
  never passed a constructor — `Deserialize` writes fields directly (`L10.1a`)
  — still has to be readable, so an accessor MUST NOT panic on one.
  `D3.30a` says the same about `DV_URI`, for the same reason and after the same
  defect (`A-36`).

- **D3.19** `DV_QUANTITY` MUST require a finite magnitude and a non-empty
  `units`. A quantity with no units is a number, and openEHR has `DV_COUNT`.
- **D3.19a** The non-empty requirement in `D3.19` is a **declared narrowing** of
  openEHR's `Units_valid: units /= void`, which forbids only a missing value and
  not an empty one. What survives: every `DV_QUANTITY` this crate accepts has a
  units string that names a measured property, which is what
  `is_strictly_comparable_to` depends on (`D3.15`). What it costs: an instance
  carrying `"units": ""` is refused rather than read.
- **D3.20** `precision` MUST satisfy openEHR's `Precision_valid: precision >= -1`.
  `0` means an integral quantity; **`-1` means no limit** — any number of
  decimal places — and is a *stated* precision, distinct from the attribute
  being absent. Requiring `precision >= 0` would reject conformant data.
- **D3.20a** `is_integral` MUST be exactly `precision = 0`, as openEHR defines
  it, and MUST answer "unknown" when no precision is recorded. A magnitude that
  happens to be whole is not a quantity *declared* integral.
- **D3.21** `accuracy` MUST be finite and MUST satisfy both openEHR rules:
  `Accuracy_validity` — a percentage accuracy lies in 0–100 — and
  `Accuracy_is_percent_validity` — an accuracy of **0** must not be flagged as a
  percentage, because 0 means 100% accurate and "0%" reads as the opposite.
- **D3.21a** `-1.0` MUST be honoured as openEHR's `unknown_accuracy_value`: it
  records that accuracy was not measured, and a reader treating it as an error
  of minus one is wrong by the width of the scale. `accuracy_unknown` MUST
  answer true for it **and** for an absent accuracy, because both mean the same
  thing to a reader and only one of them is obvious.
- **D3.22** `magnitude_status` MUST be one of `=`, `<`, `>`, `<=`, `>=`, `~`,
  modelled as a closed set — openEHR's `valid_magnitude_status`. A result below
  an assay's detection limit is a real result and recording it as the limit
  loses the only part that matters.
- **D3.23** `DV_PROPORTION` MUST refuse a zero denominator, MUST require
  denominator 1 for `pk_unitary` and 100 for `pk_percent`, and MUST require
  integral parts for `pk_fraction` and `pk_integer_fraction`. These comparisons
  are exact: a tolerance would accept a denominator of 99.999 and then render it
  as a percentage.
- **D3.23a** `DV_PROPORTION.precision` MUST satisfy
  `Precision_validity: precision = 0 implies is_integral`. Note the direction:
  declaring precision `0` **asserts** the parts are whole numbers. It does not
  forbid an integral *kind* from carrying a precision, which is the reading an
  implementation usually arrives at and which rejects valid data.
- **D3.24** `DV_ORDERED` reference ranges MUST be modelled, and "outside the
  normal range" MUST be distinguishable from "no normal range recorded".
- **D3.24a** `normal_status` MUST come from the openEHR `normal statuses` code
  set (`Normal_status_validity`). A renderer prints this letter verbatim beside
  a result, so an invented one reaches a clinician.
- **D3.24b** Where both `normal_range` and `normal_status` are present, they
  MUST agree: `N` if and only if the value lies inside the range
  (`Normal_range_and_status_consistency`). They disagree when a result is copied
  from one system and its flag from another — which is the case where a
  clinician sees a normal flag beside an abnormal number.

## Encapsulated data

- **D3.25** `DV_MULTIMEDIA` MUST NOT render its bytes in any `Display` or
  `Debug` output. `Debug` MUST print media type and length instead. This is the
  one place the crate's usual "`Debug` may show everything" rule is overridden,
  because "everything" is a megabyte of base64 that ends up in a ticket.
- **D3.26** `DV_MULTIMEDIA.integrity_check` MUST be verifiable, and the
  verification MUST distinguish *passed*, *failed*, *not recorded*, *no inline
  data*, and *unsupported algorithm*. A field saying "SHA-256: …" that nothing
  checks is worse than no field, because it reads as an assurance.
- **D3.27** The crate MUST read an integrity check declared as `SHA-1` and MUST
  NOT emit one. openEHR lists SHA-1; a clinical record may outlive it, and it
  has already been outlived.
- **D3.28** `DV_PARSABLE` MUST require both `value` and `formalism` to be
  non-empty. An empty formalism leaves content nothing can decide how to read.
- **D3.29** Base64 decoding MUST tolerate embedded whitespace, which
  XML-derived payloads carry and which conveys nothing.

## URIs

- **D3.30** *(amended 2026-08-20)* `DV_URI` MUST require a well-formed scheme
  and MUST refuse spaces and control characters, **at both gates** (`L10.1`,
  `L10.1a`) — in the constructor, and in validation.
- **D3.31** *(amended 2026-08-20)* `DV_EHR_URI` MUST require the scheme `ehr`,
  **at both gates**. `LINK.target` is typed `DV_EHR_URI` precisely so that a
  link cannot point out of the record without saying so.

  Both requirements said only "MUST require", and the crate satisfied that
  reading in the constructor alone. `DV_EHR_URI` is `#[serde(transparent)]` over
  `DV_URI`, whose `Deserialize` is derived, so a link target arriving as JSON
  reached neither check: `{"value":"https://example.org/x"}` deserialized into a
  `DV_EHR_URI` whose scheme was `https`, and `{"value":"nocolon"}` deserialized
  into a `DV_URI` that **panicked** when its scheme was read. See
  [`audit.md`]audit.md **A-36**. "At both gates" is now written into the
  requirement, because "MUST require" was read as "the constructor requires"
  once and would be again.

- **D3.30a** *(added 2026-08-20)* Reading any part of a `DV_URI` — its scheme,
  or the remainder — MUST be **total**. A value that never passed a constructor
  is a value this crate is obliged to be able to look at, and the accessor's
  answer for one with no scheme is the empty string, which compares unequal to
  every real scheme and so fails closed. An accessor that panics converts a
  malformed document into a denial of service against the process reading it.
- **D3.32** Validity of a `DV_EHR_URI` is **structural only**. The crate cannot
  resolve it, and documentation MUST NOT let "valid" be read as "resolvable".

## Identifiers as values

- **D3.33** `DV_IDENTIFIER.id` MUST be non-empty.
- **D3.34** `DV_IDENTIFIER`'s `Display` MUST render the type and issuer and MUST
  NOT render the identifier (`X11.6`).